Initial import of AbelBirdNest Stock
This commit is contained in:
@ -0,0 +1,901 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@/components/providers/locale-provider";
|
||||
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
||||
import type {
|
||||
GradeRecord,
|
||||
WarehouseLocationRecord,
|
||||
WarehouseRecord
|
||||
} from "@/types/master-data";
|
||||
import type { LotListItem } from "@/types/lot";
|
||||
import type {
|
||||
LotTransformationDetail,
|
||||
LotTransformationListItem
|
||||
} from "@/types/lot-transformation";
|
||||
|
||||
type TransformationInputForm = {
|
||||
source_lot_id: string;
|
||||
qty_used: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type TransformationOutputForm = {
|
||||
grade_id: string;
|
||||
warehouse_id: string;
|
||||
warehouse_location_id: string;
|
||||
qty_produced: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type TransformationForm = {
|
||||
transformation_type: "MIX" | "REGRADE";
|
||||
transformation_date: string;
|
||||
remainder_mode: "KEEP_SOURCE_GRADE" | "SHRINKAGE" | "";
|
||||
processing_loss_mode: "SHRINKAGE" | "";
|
||||
notes: string;
|
||||
inputs: TransformationInputForm[];
|
||||
outputs: TransformationOutputForm[];
|
||||
};
|
||||
|
||||
const createEmptyInput = (): TransformationInputForm => ({
|
||||
source_lot_id: "",
|
||||
qty_used: "",
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const createEmptyOutput = (): TransformationOutputForm => ({
|
||||
grade_id: "",
|
||||
warehouse_id: "",
|
||||
warehouse_location_id: "",
|
||||
qty_produced: "",
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const createEmptyForm = (): TransformationForm => ({
|
||||
transformation_type: "MIX",
|
||||
transformation_date: new Date().toISOString().slice(0, 10),
|
||||
remainder_mode: "",
|
||||
processing_loss_mode: "",
|
||||
notes: "",
|
||||
inputs: [createEmptyInput(), createEmptyInput()],
|
||||
outputs: [createEmptyOutput()]
|
||||
});
|
||||
|
||||
export function LotMixingClient() {
|
||||
const { locale } = useLocale();
|
||||
const [lots, setLots] = useState<LotListItem[]>([]);
|
||||
const [transformations, setTransformations] = useState<LotTransformationListItem[]>([]);
|
||||
const [grades, setGrades] = useState<GradeRecord[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseRecord[]>([]);
|
||||
const [locations, setLocations] = useState<WarehouseLocationRecord[]>([]);
|
||||
const [selectedTransformation, setSelectedTransformation] =
|
||||
useState<LotTransformationDetail | null>(null);
|
||||
const [form, setForm] = useState<TransformationForm>(createEmptyForm);
|
||||
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 [lotsRes, transformationsRes, gradesRes, warehousesRes, locationsRes] =
|
||||
await Promise.all([
|
||||
fetch("/api/v1/lots", { cache: "no-store" }),
|
||||
fetch("/api/v1/lot-transformations", { cache: "no-store" }),
|
||||
fetch("/api/v1/grades", { cache: "no-store" }),
|
||||
fetch("/api/v1/warehouses", { cache: "no-store" }),
|
||||
fetch("/api/v1/warehouse-locations", { cache: "no-store" })
|
||||
]);
|
||||
|
||||
const lotsPayload = (await lotsRes.json()) as { data: LotListItem[] };
|
||||
const transformationsPayload = (await transformationsRes.json()) as {
|
||||
data: LotTransformationListItem[];
|
||||
};
|
||||
const gradesPayload = (await gradesRes.json()) as { data: GradeRecord[] };
|
||||
const warehousesPayload = (await warehousesRes.json()) as { data: WarehouseRecord[] };
|
||||
const locationsPayload = (await locationsRes.json()) as { data: WarehouseLocationRecord[] };
|
||||
|
||||
if (
|
||||
!lotsRes.ok ||
|
||||
!transformationsRes.ok ||
|
||||
!gradesRes.ok ||
|
||||
!warehousesRes.ok ||
|
||||
!locationsRes.ok
|
||||
) {
|
||||
throw new Error(locale === "id" ? "Gagal memuat data mixing lot" : "Failed to load lot mixing data");
|
||||
}
|
||||
|
||||
setLots(lotsPayload.data);
|
||||
setTransformations(transformationsPayload.data);
|
||||
setGrades(gradesPayload.data);
|
||||
setWarehouses(warehousesPayload.data);
|
||||
setLocations(locationsPayload.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : locale === "id" ? "Gagal memuat data mixing lot" : "Failed to load lot mixing data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setForm((current) => {
|
||||
if (current.transformation_type === "REGRADE") {
|
||||
return {
|
||||
...current,
|
||||
inputs: current.inputs.length > 0 ? [current.inputs[0]] : [createEmptyInput()]
|
||||
};
|
||||
}
|
||||
|
||||
if (current.inputs.length >= 2) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
inputs: [...current.inputs, createEmptyInput()]
|
||||
};
|
||||
});
|
||||
}, [form.transformation_type]);
|
||||
|
||||
async function openTransformation(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/lot-transformations/${id}`, { cache: "no-store" });
|
||||
const payload =
|
||||
(await response.json()) as DetailResponse<LotTransformationDetail> | ApiErrorResponse;
|
||||
if (!response.ok || !("data" in payload)) {
|
||||
throw new Error(
|
||||
"message" in payload
|
||||
? payload.message
|
||||
: locale === "id"
|
||||
? "Gagal memuat detail transformasi"
|
||||
: "Failed to load transformation details"
|
||||
);
|
||||
}
|
||||
setSelectedTransformation(payload.data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: locale === "id"
|
||||
? "Gagal memuat detail transformasi"
|
||||
: "Failed to load transformation details"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm(createEmptyForm());
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function updateInput(index: number, patch: Partial<TransformationInputForm>) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
inputs: current.inputs.map((line, lineIndex) =>
|
||||
lineIndex === index ? { ...line, ...patch } : line
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
function updateOutput(index: number, patch: Partial<TransformationOutputForm>) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
outputs: current.outputs.map((line, lineIndex) =>
|
||||
lineIndex === index ? { ...line, ...patch } : line
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
function addInput() {
|
||||
if (form.transformation_type === "REGRADE") return;
|
||||
setForm((current) => ({ ...current, inputs: [...current.inputs, createEmptyInput()] }));
|
||||
}
|
||||
|
||||
function removeInput(index: number) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
inputs: current.inputs.filter((_, lineIndex) => lineIndex !== index)
|
||||
}));
|
||||
}
|
||||
|
||||
function addOutput() {
|
||||
setForm((current) => ({ ...current, outputs: [...current.outputs, createEmptyOutput()] }));
|
||||
}
|
||||
|
||||
function removeOutput(index: number) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
outputs: current.outputs.filter((_, lineIndex) => lineIndex !== index)
|
||||
}));
|
||||
}
|
||||
|
||||
async function createTransformation() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/lot-transformations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transformation_type: form.transformation_type,
|
||||
transformation_date: form.transformation_date,
|
||||
remainder_mode: form.remainder_mode || null,
|
||||
processing_loss_mode: form.processing_loss_mode || null,
|
||||
notes: form.notes,
|
||||
inputs: form.inputs.map((line) => ({
|
||||
source_lot_id: line.source_lot_id,
|
||||
qty_used: Number(line.qty_used),
|
||||
notes: line.notes || null
|
||||
})),
|
||||
outputs: form.outputs.map((line) => ({
|
||||
grade_id: line.grade_id,
|
||||
warehouse_id: line.warehouse_id,
|
||||
warehouse_location_id: line.warehouse_location_id || null,
|
||||
qty_produced: Number(line.qty_produced),
|
||||
notes: line.notes || null
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
const payload =
|
||||
(await response.json()) as DetailResponse<LotTransformationDetail> | ApiErrorResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
if ("errors" in payload && payload.errors) {
|
||||
const firstError = Object.values(payload.errors)[0]?.[0];
|
||||
throw new Error(
|
||||
firstError ??
|
||||
payload.message ??
|
||||
(locale === "id" ? "Validasi gagal" : "Validation failed")
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"message" in payload
|
||||
? payload.message
|
||||
: locale === "id"
|
||||
? "Gagal membuat transformasi"
|
||||
: "Failed to create transformation"
|
||||
);
|
||||
}
|
||||
|
||||
if (!("data" in payload)) {
|
||||
throw new Error(
|
||||
locale === "id" ? "Respons transformasi tidak valid" : "Invalid transformation response"
|
||||
);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSelectedTransformation(payload.data);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: locale === "id"
|
||||
? "Gagal membuat transformasi"
|
||||
: "Failed to create transformation"
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectableLots = useMemo(
|
||||
() =>
|
||||
lots.filter(
|
||||
(lot) =>
|
||||
lot.status !== "DEPLETED" &&
|
||||
lot.available_qty > 0
|
||||
),
|
||||
[lots]
|
||||
);
|
||||
|
||||
const inputTotal = useMemo(
|
||||
() => form.inputs.reduce((sum, line) => sum + (Number(line.qty_used) || 0), 0),
|
||||
[form.inputs]
|
||||
);
|
||||
const outputTotal = useMemo(
|
||||
() => form.outputs.reduce((sum, line) => sum + (Number(line.qty_produced) || 0), 0),
|
||||
[form.outputs]
|
||||
);
|
||||
const selectedRegradeSourceLot =
|
||||
form.transformation_type === "REGRADE"
|
||||
? selectableLots.find((lot) => lot.id === form.inputs[0]?.source_lot_id) ?? null
|
||||
: null;
|
||||
const unusedSourceRemainderQty =
|
||||
form.transformation_type === "REGRADE" && selectedRegradeSourceLot
|
||||
? Number(
|
||||
(
|
||||
selectedRegradeSourceLot.available_qty -
|
||||
(Number(form.inputs[0]?.qty_used) || 0)
|
||||
).toFixed(3)
|
||||
)
|
||||
: 0;
|
||||
const outputLossQty = Number((inputTotal - outputTotal).toFixed(3));
|
||||
|
||||
useEffect(() => {
|
||||
if (form.transformation_type !== "REGRADE") return;
|
||||
if (unusedSourceRemainderQty > 0) return;
|
||||
|
||||
setForm((current) =>
|
||||
current.remainder_mode
|
||||
? {
|
||||
...current,
|
||||
remainder_mode: ""
|
||||
}
|
||||
: current
|
||||
);
|
||||
}, [form.transformation_type, unusedSourceRemainderQty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.transformation_type !== "REGRADE") return;
|
||||
if (outputLossQty > 0) return;
|
||||
|
||||
setForm((current) =>
|
||||
current.processing_loss_mode
|
||||
? {
|
||||
...current,
|
||||
processing_loss_mode: ""
|
||||
}
|
||||
: current
|
||||
);
|
||||
}, [form.transformation_type, outputLossQty]);
|
||||
|
||||
return (
|
||||
<section className="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="ops-card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="ops-overline">Transformasi QC</p>
|
||||
<h2 className="mt-2 text-[28px] font-semibold tracking-tight text-ink">
|
||||
Mixing Lot / Ubah Grade
|
||||
</h2>
|
||||
<p className="mt-3 text-[13px] leading-6 text-slate-500">
|
||||
Pilih beberapa lot aktif di gudang, tentukan qty yang dipakai, lalu hasilkan satu
|
||||
atau lebih lot baru dengan grade baru.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={resetForm} className="ops-btn-secondary">
|
||||
Reset formulir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<SelectField
|
||||
label="Tipe transformasi"
|
||||
value={form.transformation_type}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
transformation_type: value as "MIX" | "REGRADE",
|
||||
remainder_mode:
|
||||
value === "REGRADE" ? current.remainder_mode : ""
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ value: "MIX", label: "MIX" },
|
||||
{ value: "REGRADE", label: "REGRADE" }
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
label={locale === "id" ? "Tanggal transformasi" : "Transformation date"}
|
||||
type="date"
|
||||
value={form.transformation_date}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, transformation_date: value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="ops-label">{locale === "id" ? "Catatan" : "Notes"}</span>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, notes: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="ops-textarea"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-line/70 bg-slate-50/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-ink">{locale === "id" ? "Lot Sumber" : "Source Lots"}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{form.transformation_type === "REGRADE"
|
||||
? locale === "id" ? "Ubah grade memakai tepat satu lot sumber." : "Regrade uses exactly one source lot."
|
||||
: locale === "id" ? "Mix memakai minimal dua lot sumber." : "Mixing uses at least two source lots."}
|
||||
</p>
|
||||
</div>
|
||||
{form.transformation_type === "MIX" ? (
|
||||
<button type="button" onClick={addInput} className="ops-btn-secondary">
|
||||
{locale === "id" ? "Tambah sumber" : "Add source"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{form.inputs.map((line, index) => {
|
||||
const selectedLot = selectableLots.find((lot) => lot.id === line.source_lot_id);
|
||||
return (
|
||||
<div key={`input-${index}`} className="rounded border border-line/70 bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 grid gap-4 md:grid-cols-2">
|
||||
<SelectField
|
||||
label={locale === "id" ? `Lot Sumber ${index + 1}` : `Source Lot ${index + 1}`}
|
||||
value={line.source_lot_id}
|
||||
onChange={(value) => updateInput(index, { source_lot_id: value })}
|
||||
options={selectableLots.map((lot) => ({
|
||||
value: lot.id,
|
||||
label: `${lot.lot_code} · ${lot.grade} · ${lot.available_qty} ${lot.unit_code}`
|
||||
}))}
|
||||
placeholder={locale === "id" ? "Pilih lot aktif" : "Choose active lot"}
|
||||
/>
|
||||
<Field
|
||||
label={locale === "id" ? "Qty dipakai" : "Qty used"}
|
||||
value={line.qty_used}
|
||||
onChange={(value) => updateInput(index, { qty_used: value })}
|
||||
placeholder={selectedLot ? `Max ${selectedLot.available_qty}` : "0.000"}
|
||||
/>
|
||||
</div>
|
||||
{form.transformation_type === "MIX" && form.inputs.length > 2 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInput(index)}
|
||||
className="ops-btn-secondary"
|
||||
>
|
||||
{locale === "id" ? "Hapus" : "Remove"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedLot ? (
|
||||
<div className="mt-3 rounded border border-dashed border-line/70 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
{selectedLot.grade} · {locale === "id" ? "Gudang" : "Warehouse"}{" "}
|
||||
{selectedLot.warehouse}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.transformation_type === "REGRADE" && unusedSourceRemainderQty > 0 ? (
|
||||
<div className="mt-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-4">
|
||||
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan sisa regrade" : "Regrade remainder handling"}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{locale === "id"
|
||||
? <>Masih ada sisa <span className="font-semibold text-ink">{unusedSourceRemainderQty.toFixed(3)} kg</span> dari lot sumber yang tidak dipakai untuk regrade. Pilih apakah sisa tetap berada di grade asal atau dicatat sebagai susut.</>
|
||||
: <>There is still <span className="font-semibold text-ink">{unusedSourceRemainderQty.toFixed(3)} kg</span> remaining from the source lot that was not used for regrade. Choose whether the remainder stays in the original grade or is recorded as shrinkage.</>}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="flex items-start gap-3 rounded border border-line/70 bg-white px-4 py-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="remainder-mode"
|
||||
checked={form.remainder_mode === "KEEP_SOURCE_GRADE"}
|
||||
onChange={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
remainder_mode: "KEEP_SOURCE_GRADE"
|
||||
}))
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-sm text-slate-600">
|
||||
<div className="font-medium text-ink">{locale === "id" ? "Tetap di grade sebelumnya" : "Keep in previous grade"}</div>
|
||||
<div className="mt-1">
|
||||
{locale === "id"
|
||||
? `Sisa ${unusedSourceRemainderQty.toFixed(3)} kg akan tetap berada di lot asal${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`
|
||||
: `The remaining ${unusedSourceRemainderQty.toFixed(3)} kg will stay in the source lot${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded border border-line/70 bg-white px-4 py-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="remainder-mode"
|
||||
checked={form.remainder_mode === "SHRINKAGE"}
|
||||
onChange={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
remainder_mode: "SHRINKAGE"
|
||||
}))
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-sm text-slate-600">
|
||||
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut" : "Record as shrinkage"}</div>
|
||||
<div className="mt-1">
|
||||
{locale === "id"
|
||||
? `Sisa ${unusedSourceRemainderQty.toFixed(3)} kg akan ditambahkan ke shrinkage lot sumber.`
|
||||
: `The remaining ${unusedSourceRemainderQty.toFixed(3)} kg will be added to source-lot shrinkage.`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 rounded-lg border border-line/70 bg-slate-50/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-ink">{locale === "id" ? "Lot Hasil" : "Output Lots"}</h3>
|
||||
<button type="button" onClick={addOutput} className="ops-btn-secondary">
|
||||
{locale === "id" ? "Tambah output" : "Add output"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{form.outputs.map((line, index) => {
|
||||
const locationOptions = locations.filter(
|
||||
(location) => location.warehouse_id === line.warehouse_id
|
||||
);
|
||||
return (
|
||||
<div key={`output-${index}`} className="rounded border border-line/70 bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 grid gap-4 md:grid-cols-2">
|
||||
<SearchableSelectField
|
||||
label={`Grade Hasil ${index + 1}`}
|
||||
value={line.grade_id}
|
||||
onChange={(value) => updateOutput(index, { grade_id: value })}
|
||||
options={grades.map((grade) => ({
|
||||
value: grade.id,
|
||||
label: `${grade.code} - ${grade.name}`
|
||||
}))}
|
||||
placeholder={locale === "id" ? "Pilih grade" : "Choose grade"}
|
||||
searchPlaceholder={locale === "id" ? "Cari grade..." : "Search grade..."}
|
||||
/>
|
||||
<Field
|
||||
label={locale === "id" ? "Qty hasil" : "Produced qty"}
|
||||
value={line.qty_produced}
|
||||
onChange={(value) => updateOutput(index, { qty_produced: value })}
|
||||
placeholder="0.000"
|
||||
/>
|
||||
<SelectField
|
||||
label={locale === "id" ? "Gudang" : "Warehouse"}
|
||||
value={line.warehouse_id}
|
||||
onChange={(value) =>
|
||||
updateOutput(index, {
|
||||
warehouse_id: value,
|
||||
warehouse_location_id: ""
|
||||
})
|
||||
}
|
||||
options={warehouses.map((warehouse) => ({
|
||||
value: warehouse.id,
|
||||
label: `${warehouse.code} - ${warehouse.name}`
|
||||
}))}
|
||||
placeholder={locale === "id" ? "Pilih gudang" : "Choose warehouse"}
|
||||
/>
|
||||
<SelectField
|
||||
label={locale === "id" ? "Lokasi" : "Location"}
|
||||
value={line.warehouse_location_id}
|
||||
onChange={(value) => updateOutput(index, { warehouse_location_id: value })}
|
||||
options={locationOptions.map((location) => ({
|
||||
value: location.id,
|
||||
label: `${location.code} - ${location.name}`
|
||||
}))}
|
||||
placeholder={locale === "id" ? "Opsional" : "Optional"}
|
||||
/>
|
||||
<Field
|
||||
label={locale === "id" ? "Catatan Hasil" : "Output notes"}
|
||||
value={line.notes}
|
||||
onChange={(value) => updateOutput(index, { notes: value })}
|
||||
/>
|
||||
</div>
|
||||
{form.outputs.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOutput(index)}
|
||||
className="ops-btn-secondary"
|
||||
>
|
||||
{locale === "id" ? "Hapus" : "Remove"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.transformation_type === "REGRADE" && outputLossQty > 0 ? (
|
||||
<div className="mt-6 rounded-lg border border-ember/30 bg-ember/10 px-4 py-4">
|
||||
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan selisih hasil ubah grade" : "Regrade output difference handling"}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{locale === "id"
|
||||
? <>Total output masih kurang <span className="font-semibold text-ink">{outputLossQty.toFixed(3)} kg</span> dari qty yang dipakai untuk regrade. Anda tidak bisa menyimpan sebelum selisih ini ditangani.</>
|
||||
: <>Total output is still short by <span className="font-semibold text-ink">{outputLossQty.toFixed(3)} kg</span> compared with the qty used for regrade. You cannot save before this difference is handled.</>}
|
||||
</p>
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-slate-600">
|
||||
<li>{locale === "id" ? "tambahkan lot hasil baru agar total output pas, atau" : "add a new output lot so total output matches, or"}</li>
|
||||
<li>{locale === "id" ? "catat selisih ini sebagai susut/lost." : "record this difference as shrinkage/loss."}</li>
|
||||
</ul>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="flex items-start gap-3 rounded border border-line/70 bg-white px-4 py-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="processing-loss-mode"
|
||||
checked={form.processing_loss_mode === "SHRINKAGE"}
|
||||
onChange={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
processing_loss_mode: "SHRINKAGE"
|
||||
}))
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-sm text-slate-600">
|
||||
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut/lost" : "Record as shrinkage/loss"}</div>
|
||||
<div className="mt-1">
|
||||
{locale === "id"
|
||||
? `Selisih ${outputLossQty.toFixed(3)} kg akan dicatat sebagai shrinkage pada lot sumber.`
|
||||
: `The ${outputLossQty.toFixed(3)} kg difference will be recorded as shrinkage on the source lot.`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<SummaryMetric label={locale === "id" ? "Total Qty Input" : "Total Input Qty"} value={inputTotal.toFixed(3)} />
|
||||
<SummaryMetric label={locale === "id" ? "Total Qty Output" : "Total Output Qty"} value={outputTotal.toFixed(3)} />
|
||||
<SummaryMetric label={locale === "id" ? "Selisih / Shrinkage" : "Difference / Shrinkage"} value={outputLossQty.toFixed(3)} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void createTransformation()}
|
||||
className="ops-btn-primary"
|
||||
>
|
||||
{submitting ? (locale === "id" ? "Menyimpan..." : "Saving...") : (locale === "id" ? "Simpan transformasi" : "Save transformation")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="ops-table-shell">
|
||||
<div className="ops-section-head">
|
||||
<div>
|
||||
<p className="ops-title">{locale === "id" ? "Riwayat Transformasi" : "Transformation History"}</p>
|
||||
<p className="ops-copy">{locale === "id" ? "Mixing dan ubah grade yang sudah difinalkan." : "Finalized mixing and regrade records."}</p>
|
||||
</div>
|
||||
<div className="ops-chip-muted">{transformations.length} {locale === "id" ? "transaksi" : "transactions"}</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="px-6 py-8 text-sm text-ink/60">{locale === "id" ? "Memuat data..." : "Loading data..."}</div>
|
||||
) : transformations.length === 0 ? (
|
||||
<div className="px-6 py-8 text-sm text-ink/60">{locale === "id" ? "Belum ada transformasi." : "No transformations yet."}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ops-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>{locale === "id" ? "Tipe" : "Type"}</th>
|
||||
<th>{locale === "id" ? "Qty Masuk" : "Input Qty"}</th>
|
||||
<th>{locale === "id" ? "Qty Hasil" : "Output Qty"}</th>
|
||||
<th>{locale === "id" ? "Status" : "Status"}</th>
|
||||
<th>{locale === "id" ? "Aksi" : "Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transformations.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="font-semibold text-ink">{item.transformation_no}</td>
|
||||
<td className="text-slate-600">{item.transformation_type}</td>
|
||||
<td className="text-slate-600">{item.total_input_qty.toFixed(3)}</td>
|
||||
<td className="text-slate-600">{item.total_output_qty.toFixed(3)}</td>
|
||||
<td>
|
||||
<span className="ops-chip-active">{item.status}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openTransformation(item.id)}
|
||||
className="ops-btn-secondary"
|
||||
>
|
||||
{locale === "id" ? "Detail" : "Detail"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ops-card p-6">
|
||||
<p className="text-lg font-semibold text-ink">{locale === "id" ? "Detail Transformasi" : "Transformation Details"}</p>
|
||||
{!selectedTransformation ? (
|
||||
<div className="mt-4 text-sm text-ink/60">
|
||||
{locale === "id" ? "Pilih salah satu transformasi untuk melihat lot sumber dan hasil campurannya." : "Choose a transformation to see the source lots and output results."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="rounded border border-line/70 bg-slate-50 px-4 py-4">
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{selectedTransformation.transformation_no}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-600">
|
||||
{selectedTransformation.transformation_type} ·{" "}
|
||||
{new Date(selectedTransformation.transformation_date).toLocaleDateString(locale === "id" ? "id-ID" : "en-US")}
|
||||
</div>
|
||||
{selectedTransformation.remainder_qty ? (
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{locale === "id" ? "Sisa" : "Remainder"}: {selectedTransformation.remainder_qty.toFixed(3)} kg ·{" "}
|
||||
{selectedTransformation.remainder_mode === "KEEP_SOURCE_GRADE"
|
||||
? locale === "id" ? "tetap di grade asal" : "kept in original grade"
|
||||
: selectedTransformation.remainder_mode === "SHRINKAGE"
|
||||
? locale === "id" ? "dicatat sebagai susut" : "recorded as shrinkage"
|
||||
: "-"}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTransformation.processing_loss_qty ? (
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{locale === "id" ? "Selisih output" : "Output difference"}: {selectedTransformation.processing_loss_qty.toFixed(3)} kg ·{" "}
|
||||
{selectedTransformation.processing_loss_mode === "SHRINKAGE"
|
||||
? locale === "id" ? "dicatat sebagai susut/lost" : "recorded as shrinkage/loss"
|
||||
: "-"}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTransformation.notes ? (
|
||||
<div className="mt-3 text-sm text-slate-600">
|
||||
{selectedTransformation.notes}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.08em] text-slate-500">
|
||||
{locale === "id" ? "Lot Sumber" : "Source Lots"}
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{selectedTransformation.inputs.map((input) => (
|
||||
<div key={input.id} className="rounded border border-line/70 bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/lots/${input.source_lot.id}`}
|
||||
className="font-semibold text-ink underline-offset-4 hover:underline"
|
||||
>
|
||||
{input.source_lot.lot_code}
|
||||
</Link>
|
||||
<span className="text-sm text-slate-600">
|
||||
{locale === "id" ? "Dipakai" : "Used"} {input.qty_used} {input.source_lot.unit_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{input.source_lot.grade} ·{" "}
|
||||
{input.source_lot.warehouse}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.08em] text-slate-500">
|
||||
{locale === "id" ? "Lot Hasil" : "Output Lots"}
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{selectedTransformation.outputs.map((output) => (
|
||||
<div
|
||||
key={output.id}
|
||||
className="rounded border border-line/70 bg-white px-4 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/lots/${output.result_lot.id}`}
|
||||
className="font-semibold text-ink underline-offset-4 hover:underline"
|
||||
>
|
||||
{output.result_lot.lot_code}
|
||||
</Link>
|
||||
<span className="text-sm text-slate-600">
|
||||
{locale === "id" ? "Hasil" : "Produced"} {output.qty_produced} {output.result_lot.unit_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{output.result_lot.grade} ·{" "}
|
||||
{locale === "id" ? "Biaya Rp" : "Cost Rp"} {output.unit_cost.toLocaleString(locale === "id" ? "id-ID" : "en-US")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text"
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: 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 = "Pilih opsi"
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { locale } = useLocale();
|
||||
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 === "Pilih opsi" ? (locale === "id" ? "Pilih opsi" : "Choose option") : placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={`${label}-${option.value}`} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded border border-line/70 bg-slate-50 px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-slate-500">{label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-ink">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user