Initial import of AbelBirdNest Stock
This commit is contained in:
@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@/components/providers/locale-provider";
|
||||
import { formatQuantity } from "@/lib/formatters";
|
||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
||||
import type { AdjustmentReasonRecord } from "@/types/master-data";
|
||||
import type { LotListItem } from "@/types/lot";
|
||||
import type { StockAdjustmentListItem } from "@/types/stock-adjustment";
|
||||
|
||||
type StockAdjustmentForm = {
|
||||
lot_id: string;
|
||||
adjustment_reason_id: string;
|
||||
adjustment_date: string;
|
||||
qty_change: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): StockAdjustmentForm => ({
|
||||
lot_id: "",
|
||||
adjustment_reason_id: "",
|
||||
adjustment_date: new Date().toISOString().slice(0, 10),
|
||||
qty_change: "",
|
||||
notes: ""
|
||||
});
|
||||
|
||||
export function StockAdjustmentsClient() {
|
||||
const { dict, locale } = useLocale();
|
||||
const [items, setItems] = useState<StockAdjustmentListItem[]>([]);
|
||||
const [lots, setLots] = useState<LotListItem[]>([]);
|
||||
const [reasons, setReasons] = useState<AdjustmentReasonRecord[]>([]);
|
||||
const [form, setForm] = useState<StockAdjustmentForm>(emptyForm);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [itemsRes, lotsRes, reasonsRes] = await Promise.all([
|
||||
fetch("/api/v1/stock-adjustments", { cache: "no-store" }),
|
||||
fetch("/api/v1/lots", { cache: "no-store" }),
|
||||
fetch("/api/v1/adjustment-reasons", { cache: "no-store" })
|
||||
]);
|
||||
|
||||
const itemsPayload = (await itemsRes.json()) as { data: StockAdjustmentListItem[] };
|
||||
const lotsPayload = (await lotsRes.json()) as { data: LotListItem[] };
|
||||
const reasonsPayload = (await reasonsRes.json()) as { data: AdjustmentReasonRecord[] };
|
||||
|
||||
if (!itemsRes.ok || !lotsRes.ok || !reasonsRes.ok) {
|
||||
throw new Error(dict.stockAdjustments.loadingError);
|
||||
}
|
||||
|
||||
setItems(itemsPayload.data);
|
||||
setLots(lotsPayload.data);
|
||||
setReasons(reasonsPayload.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : dict.stockAdjustments.loadingError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function createAdjustment() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/stock-adjustments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lot_id: form.lot_id,
|
||||
adjustment_reason_id: form.adjustment_reason_id,
|
||||
adjustment_date: form.adjustment_date,
|
||||
qty_change: Number(form.qty_change),
|
||||
notes: form.notes || null
|
||||
})
|
||||
});
|
||||
|
||||
const payload =
|
||||
(await response.json()) as DetailResponse<StockAdjustmentListItem> | ApiErrorResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
if ("errors" in payload && payload.errors) {
|
||||
const firstError = Object.values(payload.errors)[0]?.[0];
|
||||
throw new Error(firstError ?? payload.message ?? dict.common.requestFailed);
|
||||
}
|
||||
throw new Error("message" in payload ? payload.message : dict.stockAdjustments.createError);
|
||||
}
|
||||
|
||||
setForm(emptyForm());
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : dict.stockAdjustments.createError);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeLots = useMemo(
|
||||
() => lots.filter((lot) => lot.available_qty > 0),
|
||||
[lots]
|
||||
);
|
||||
const selectedLot = activeLots.find((lot) => lot.id === form.lot_id) ?? null;
|
||||
const selectedReason = reasons.find((reason) => reason.id === form.adjustment_reason_id) ?? null;
|
||||
|
||||
return (
|
||||
<section className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<div className="ops-card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="ops-overline">{dict.stockAdjustments.overline}</p>
|
||||
<h2 className="mt-2 text-[28px] font-semibold tracking-tight text-ink">
|
||||
{dict.stockAdjustments.createTitle}
|
||||
</h2>
|
||||
<p className="mt-3 text-[13px] leading-6 text-slate-500">
|
||||
{dict.stockAdjustments.helper}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm(emptyForm());
|
||||
setError(null);
|
||||
}}
|
||||
className="ops-btn-secondary"
|
||||
>
|
||||
{dict.common.formReset}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<SelectField
|
||||
label="Lot"
|
||||
value={form.lot_id}
|
||||
onChange={(value) => setForm((current) => ({ ...current, lot_id: value }))}
|
||||
options={activeLots.map((lot) => ({
|
||||
value: lot.id,
|
||||
label: `${lot.lot_code} · ${lot.grade} · ${lot.available_qty} ${lot.unit_code}`
|
||||
}))}
|
||||
placeholder={dict.stockAdjustments.chooseActiveLot}
|
||||
/>
|
||||
|
||||
{selectedLot ? (
|
||||
<div className="rounded border border-line/70 bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||||
<div className="font-medium text-ink">{selectedLot.lot_code}</div>
|
||||
<div className="mt-1">
|
||||
{selectedLot.grade} · {dict.stockAdjustments.available}{" "}
|
||||
{selectedLot.available_qty} {selectedLot.unit_code}
|
||||
</div>
|
||||
<div className="mt-1">{dict.common.warehouse}: {selectedLot.warehouse}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SelectField
|
||||
label={dict.stockAdjustments.adjustmentReason}
|
||||
value={form.adjustment_reason_id}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, adjustment_reason_id: value }))
|
||||
}
|
||||
options={reasons.map((reason) => ({
|
||||
value: reason.id,
|
||||
label: `${reason.code} - ${reason.name} (${reason.category})`
|
||||
}))}
|
||||
placeholder={dict.stockAdjustments.chooseReason}
|
||||
/>
|
||||
|
||||
{selectedReason ? (
|
||||
<div className="rounded border border-line/70 bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||||
{dict.stockAdjustments.category}: <span className="font-medium text-ink">{selectedReason.category}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field
|
||||
label={dict.stockAdjustments.adjustmentDate}
|
||||
type="date"
|
||||
value={form.adjustment_date}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, adjustment_date: value }))
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label={dict.stockAdjustments.adjustmentQty}
|
||||
value={form.qty_change}
|
||||
onChange={(value) => setForm((current) => ({ ...current, qty_change: value }))}
|
||||
placeholder={dict.stockAdjustments.adjustmentQtyPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="ops-label">{dict.common.notes}</span>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, notes: event.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
className="ops-textarea"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void createAdjustment()}
|
||||
className="ops-btn-primary"
|
||||
>
|
||||
{submitting ? dict.common.processing : dict.stockAdjustments.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-table-shell">
|
||||
<div className="ops-section-head">
|
||||
<div>
|
||||
<p className="ops-title">{dict.stockAdjustments.historyTitle}</p>
|
||||
<p className="ops-copy">{dict.stockAdjustments.historyCopy}</p>
|
||||
</div>
|
||||
<div className="ops-chip-muted">{items.length} adjustment</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="px-6 py-8 text-sm text-ink/60">{dict.common.loading}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-6 py-8 text-sm text-ink/60">{dict.stockAdjustments.noData}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ops-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>Lot</th>
|
||||
<th>{dict.stockAdjustments.reason}</th>
|
||||
<th>{dict.common.qty}</th>
|
||||
<th>{dict.stockAdjustments.before}</th>
|
||||
<th>{dict.stockAdjustments.after}</th>
|
||||
<th>{dict.common.user}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="font-semibold text-ink">{item.adjustment_no}</td>
|
||||
<td>
|
||||
<Link href={`/lots/${item.lot.id}`} className="font-medium text-ink underline-offset-4 hover:underline">
|
||||
{item.lot.lot_code}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="text-slate-600">
|
||||
{item.reason.name}
|
||||
<div className="mt-1 text-xs text-slate-500">{item.reason.category}</div>
|
||||
</td>
|
||||
<td className={item.qty_change < 0 ? "text-ember" : "text-moss"}>
|
||||
{item.qty_change > 0 ? "+" : ""}
|
||||
{formatQuantity(Math.abs(item.qty_change), locale, item.lot.unit_code)}
|
||||
</td>
|
||||
<td className="text-slate-600">{formatQuantity(item.available_qty_before, locale, item.lot.unit_code)}</td>
|
||||
<td className="text-slate-600">{formatQuantity(item.available_qty_after, locale, item.lot.unit_code)}</td>
|
||||
<td className="text-slate-600">
|
||||
{item.created_by.name}
|
||||
<div className="mt-1 text-xs text-slate-500">{item.created_by.role}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
placeholder
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="ops-label">{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="ops-input"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="ops-label">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="ops-select"
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={`${label}-${option.value}`} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user