369 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|