Files
AbelBirdNest-Stock/src/features/stock-adjustments/components/stock-adjustments-client.tsx

369 lines
13 KiB
TypeScript

"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: ""
});
const adjustmentReasonCategoryLabels: Record<AdjustmentReasonRecord["category"], string> = {
ADJUSTMENT: "Penyesuaian / Adjustment",
SHRINKAGE: "Penyusutan / Shrinkage",
DAMAGE: "Kerusakan / Damage",
REGRADE: "Regrade / Regrade"
};
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} (${adjustmentReasonCategoryLabels[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">
<div>
{dict.stockAdjustments.category}:{" "}
<span className="font-medium text-ink">
{adjustmentReasonCategoryLabels[selectedReason.category]}
</span>
</div>
{selectedReason.description ? (
<div className="mt-2 text-xs leading-5 text-slate-500">
{selectedReason.description}
</div>
) : null}
</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">
{adjustmentReasonCategoryLabels[item.reason.category]}
</div>
{item.reason.description ? (
<div className="mt-1 text-xs leading-5 text-slate-500">
{item.reason.description}
</div>
) : null}
</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>
);
}