903 lines
36 KiB
TypeScript
903 lines
36 KiB
TypeScript
"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 { formatCurrencyAmount, formatDecimal, formatKilogram } from "@/lib/formatters";
|
|
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({ currencyCode }: { currencyCode: string }) {
|
|
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">{formatKilogram(unusedSourceRemainderQty, locale)}</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">{formatKilogram(unusedSourceRemainderQty, locale)}</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 ${formatKilogram(unusedSourceRemainderQty, locale)} akan tetap berada di lot asal${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`
|
|
: `The remaining ${formatKilogram(unusedSourceRemainderQty, locale)} 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 ${formatKilogram(unusedSourceRemainderQty, locale)} akan ditambahkan ke shrinkage lot sumber.`
|
|
: `The remaining ${formatKilogram(unusedSourceRemainderQty, locale)} 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">{formatKilogram(outputLossQty, locale)}</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">{formatKilogram(outputLossQty, locale)}</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 ${formatKilogram(outputLossQty, locale)} akan dicatat sebagai shrinkage pada lot sumber.`
|
|
: `The ${formatKilogram(outputLossQty, locale)} 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={formatDecimal(inputTotal, locale)} />
|
|
<SummaryMetric label={locale === "id" ? "Total Qty Output" : "Total Output Qty"} value={formatDecimal(outputTotal, locale)} />
|
|
<SummaryMetric label={locale === "id" ? "Selisih / Shrinkage" : "Difference / Shrinkage"} value={formatDecimal(outputLossQty, locale)} />
|
|
</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">{formatDecimal(item.total_input_qty, locale)}</td>
|
|
<td className="text-slate-600">{formatDecimal(item.total_output_qty, locale)}</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"}: {formatKilogram(selectedTransformation.remainder_qty, locale)} ·{" "}
|
|
{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"}: {formatKilogram(selectedTransformation.processing_loss_qty, locale)} ·{" "}
|
|
{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" : "Cost"} {formatCurrencyAmount(output.unit_cost, locale, currencyCode, 0)}
|
|
</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>
|
|
);
|
|
}
|