Files
AbelBirdNest-Stock/src/features/lot-transformations/components/lot-mixing-client.tsx

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>
);
}