Standardize adjustment reason categories and descriptions

This commit is contained in:
2026-05-21 10:37:11 +07:00
parent d015cb0dda
commit f0ceebbbc8
13 changed files with 177 additions and 47 deletions

View File

@ -0,0 +1,8 @@
CREATE TYPE "AdjustmentReasonCategory" AS ENUM ('SHRINKAGE', 'DAMAGE', 'REGRADE', 'ADJUSTMENT');
ALTER TABLE "adjustment_reasons"
ADD COLUMN "description" VARCHAR(255);
ALTER TABLE "adjustment_reasons"
ALTER COLUMN "category" TYPE "AdjustmentReasonCategory"
USING ("category"::"AdjustmentReasonCategory");

View File

@ -8,6 +8,13 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum AdjustmentReasonCategory {
SHRINKAGE
DAMAGE
REGRADE
ADJUSTMENT
}
model Role { model Role {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(50) code String @unique @db.VarChar(50)
@ -486,7 +493,8 @@ model AdjustmentReason {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(50) code String @unique @db.VarChar(50)
name String @db.VarChar(100) name String @db.VarChar(100)
category String @db.VarChar(50) description String? @db.VarChar(255)
category AdjustmentReasonCategory
status String @default("ACTIVE") @db.VarChar(20) status String @default("ACTIVE") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -66,6 +66,7 @@ export async function PUT(request: Request, context: RouteContext) {
data: { data: {
code: resolvedCode.code, code: resolvedCode.code,
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description || null,
category: parsed.data.category, category: parsed.data.category,
status: parsed.data.status status: parsed.data.status
} }
@ -83,12 +84,14 @@ export async function PUT(request: Request, context: RouteContext) {
{ {
code: existing.code, code: existing.code,
name: existing.name, name: existing.name,
description: existing.description,
category: existing.category, category: existing.category,
status: existing.status status: existing.status
}, },
{ {
code: reason.code, code: reason.code,
name: reason.name, name: reason.name,
description: reason.description,
category: reason.category, category: reason.category,
status: reason.status status: reason.status
} }

View File

@ -45,6 +45,7 @@ export async function POST(request: Request) {
data: { data: {
code: resolvedCode.code, code: resolvedCode.code,
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description || null,
category: parsed.data.category, category: parsed.data.category,
status: parsed.data.status status: parsed.data.status
} }

View File

@ -18,6 +18,7 @@ export async function GET(request: Request) {
id: reason.id.toString(), id: reason.id.toString(),
code: reason.code, code: reason.code,
name: reason.name, name: reason.name,
description: reason.description,
category: reason.category category: reason.category
})) }))
} }

View File

@ -118,13 +118,13 @@ export async function POST(request: Request) {
status: after <= 0 ? "DEPLETED" : "ACTIVE" status: after <= 0 ? "DEPLETED" : "ACTIVE"
}; };
if (parsed.data.qty_change < 0 && reason.category.toUpperCase() === "SHRINKAGE") { if (parsed.data.qty_change < 0 && reason.category === "SHRINKAGE") {
lotUpdate.shrinkageQty = new Prisma.Decimal( lotUpdate.shrinkageQty = new Prisma.Decimal(
Number((lot.shrinkageQty.toNumber() + absChange).toFixed(3)) Number((lot.shrinkageQty.toNumber() + absChange).toFixed(3))
); );
} }
if (parsed.data.qty_change < 0 && reason.category.toUpperCase() === "DAMAGE") { if (parsed.data.qty_change < 0 && reason.category === "DAMAGE") {
lotUpdate.damagedQty = new Prisma.Decimal( lotUpdate.damagedQty = new Prisma.Decimal(
Number((lot.damagedQty.toNumber() + absChange).toFixed(3)) Number((lot.damagedQty.toNumber() + absChange).toFixed(3))
); );

View File

@ -4,25 +4,39 @@ import { FormEvent, useEffect, useMemo, useState } from "react";
import { useLocale } from "@/components/providers/locale-provider"; import { useLocale } from "@/components/providers/locale-provider";
import { PaginationFooter } from "@/components/shared/pagination-footer"; import { PaginationFooter } from "@/components/shared/pagination-footer";
import { useCurrentUser } from "@/hooks/use-current-user";
import { usePagination } from "@/hooks/use-pagination"; import { usePagination } from "@/hooks/use-pagination";
import type { ApiErrorResponse, DetailResponse } from "@/types/api"; import type { ApiErrorResponse, DetailResponse } from "@/types/api";
import type { AdjustmentReasonRecord, MasterStatus } from "@/types/master-data"; import type {
AdjustmentReasonCategory,
AdjustmentReasonRecord,
MasterStatus
} from "@/types/master-data";
type FormValues = { type FormValues = {
code: string;
name: string; name: string;
category: string; description: string;
category: AdjustmentReasonCategory;
status: MasterStatus; status: MasterStatus;
}; };
const emptyForm: FormValues = { const emptyForm: FormValues = {
code: "",
name: "", name: "",
category: "", description: "",
category: "ADJUSTMENT",
status: "ACTIVE" status: "ACTIVE"
}; };
const categoryOptions: Array<{ value: AdjustmentReasonCategory; label: string }> = [
{ value: "ADJUSTMENT", label: "Penyesuaian / Adjustment" },
{ value: "SHRINKAGE", label: "Penyusutan / Shrinkage" },
{ value: "DAMAGE", label: "Kerusakan / Damage" },
{ value: "REGRADE", label: "Regrade / Regrade" }
];
function getCategoryLabel(category: AdjustmentReasonCategory) {
return categoryOptions.find((option) => option.value === category)?.label ?? category;
}
export function AdjustmentReasonsClient() { export function AdjustmentReasonsClient() {
const { dict } = useLocale(); const { dict } = useLocale();
const [items, setItems] = useState<AdjustmentReasonRecord[]>([]); const [items, setItems] = useState<AdjustmentReasonRecord[]>([]);
@ -32,14 +46,18 @@ export function AdjustmentReasonsClient() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { canEditCode } = useCurrentUser();
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
if (!keyword) return items; if (!keyword) return items;
return items.filter((item) => return items.filter((item) =>
[item.code, item.name, item.category].some((value) => value.toLowerCase().includes(keyword)) [item.code, item.name, item.description ?? "", item.category].some((value) =>
value.toLowerCase().includes(keyword)
)
); );
}, [items, search]); }, [items, search]);
const pagination = usePagination(filteredItems, 20); const pagination = usePagination(filteredItems, 20);
async function loadItems() { async function loadItems() {
@ -70,8 +88,8 @@ export function AdjustmentReasonsClient() {
function startEdit(item: AdjustmentReasonRecord) { function startEdit(item: AdjustmentReasonRecord) {
setEditingId(item.id); setEditingId(item.id);
setForm({ setForm({
code: item.code,
name: item.name, name: item.name,
description: item.description ?? "",
category: item.category, category: item.category,
status: item.status as MasterStatus status: item.status as MasterStatus
}); });
@ -82,17 +100,20 @@ export function AdjustmentReasonsClient() {
event.preventDefault(); event.preventDefault();
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
const response = await fetch( const response = await fetch(
editingId ? `/api/v1/adjustment-reasons/${editingId}` : "/api/v1/adjustment-reasons", editingId ? `/api/v1/adjustment-reasons/${editingId}` : "/api/v1/adjustment-reasons",
{ {
method: editingId ? "PUT" : "POST", method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form) body: JSON.stringify({ ...form, code: "" })
} }
); );
const payload = const payload =
(await response.json()) as DetailResponse<AdjustmentReasonRecord> | ApiErrorResponse; (await response.json()) as DetailResponse<AdjustmentReasonRecord> | ApiErrorResponse;
if (!response.ok) { if (!response.ok) {
if ("errors" in payload && payload.errors) { if ("errors" in payload && payload.errors) {
const firstError = Object.values(payload.errors)[0]?.[0]; const firstError = Object.values(payload.errors)[0]?.[0];
@ -100,6 +121,7 @@ export function AdjustmentReasonsClient() {
} }
throw new Error("message" in payload ? payload.message : dict.common.requestFailed); throw new Error("message" in payload ? payload.message : dict.common.requestFailed);
} }
resetForm(); resetForm();
await loadItems(); await loadItems();
} catch (err) { } catch (err) {
@ -111,6 +133,7 @@ export function AdjustmentReasonsClient() {
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!window.confirm(`${dict.common.delete} adjustment reason ini?`)) return; if (!window.confirm(`${dict.common.delete} adjustment reason ini?`)) return;
try { try {
const response = await fetch(`/api/v1/adjustment-reasons/${id}`, { const response = await fetch(`/api/v1/adjustment-reasons/${id}`, {
method: "DELETE" method: "DELETE"
@ -142,29 +165,52 @@ export function AdjustmentReasonsClient() {
</p> </p>
</div> </div>
{editingId ? ( {editingId ? (
<button type="button" onClick={resetForm} className="ops-btn-secondary">{dict.common.cancelEdit}</button> <button type="button" onClick={resetForm} className="ops-btn-secondary">
{dict.common.cancelEdit}
</button>
) : null} ) : null}
</div> </div>
<form className="mt-6 space-y-4" onSubmit={handleSubmit}> <form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field
label={dict.master.code}
value={form.code}
readOnly={!canEditCode}
placeholder={canEditCode ? "ADJ00001" : "Auto"}
onChange={(value) => setForm((current) => ({ ...current, code: value }))}
/>
<Field <Field
label={dict.master.name} label={dict.master.name}
value={form.name} value={form.name}
onChange={(value) => setForm((current) => ({ ...current, name: value }))} onChange={(value) => setForm((current) => ({ ...current, name: value }))}
/> />
<Field <label className="block">
label="Kategori" <span className="ops-label">Kategori</span>
value={form.category} <select
onChange={(value) => setForm((current) => ({ ...current, category: value }))} value={form.category}
/> onChange={(event) =>
setForm((current) => ({
...current,
category: event.target.value as AdjustmentReasonCategory
}))
}
className="ops-select"
>
{categoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
<label className="block">
<span className="ops-label">{dict.master.description}</span>
<textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={4}
className="ops-textarea"
placeholder="Penjelasan kapan alasan ini dipakai"
/>
</label>
<label className="block"> <label className="block">
<span className="ops-label">{dict.master.status}</span> <span className="ops-label">{dict.master.status}</span>
<select <select
@ -181,11 +227,13 @@ export function AdjustmentReasonsClient() {
<option value="INACTIVE">{dict.master.inactive}</option> <option value="INACTIVE">{dict.master.inactive}</option>
</select> </select>
</label> </label>
{error ? ( {error ? (
<div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember"> <div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">
{error} {error}
</div> </div>
) : null} ) : null}
<button type="submit" disabled={submitting} className="ops-btn-primary"> <button type="submit" disabled={submitting} className="ops-btn-primary">
{submitting {submitting
? dict.common.processing ? dict.common.processing
@ -195,6 +243,7 @@ export function AdjustmentReasonsClient() {
</button> </button>
</form> </form>
</div> </div>
<div className="ops-table-shell"> <div className="ops-table-shell">
<div className="ops-section-head"> <div className="ops-section-head">
<div> <div>
@ -205,6 +254,7 @@ export function AdjustmentReasonsClient() {
{items.length} {dict.master.dataCount} {items.length} {dict.master.dataCount}
</div> </div>
</div> </div>
{!loading && items.length > 0 ? ( {!loading && items.length > 0 ? (
<div className="border-t border-line/70 px-6 py-4"> <div className="border-t border-line/70 px-6 py-4">
<label className="block"> <label className="block">
@ -212,18 +262,17 @@ export function AdjustmentReasonsClient() {
<input <input
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder="Cari kode, nama, atau kategori..." placeholder="Cari kode, nama, deskripsi, atau kategori..."
className="ops-input" className="ops-input"
/> />
</label> </label>
</div> </div>
) : null} ) : null}
{loading ? ( {loading ? (
<div className="px-6 py-8 text-sm text-ink/60">{dict.common.loading}</div> <div className="px-6 py-8 text-sm text-ink/60">{dict.common.loading}</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="px-6 py-8 text-sm text-ink/60"> <div className="px-6 py-8 text-sm text-ink/60">{dict.master.noData}</div>
{dict.master.noData}
</div>
) : filteredItems.length === 0 ? ( ) : filteredItems.length === 0 ? (
<div className="px-6 py-8 text-sm text-ink/60"> <div className="px-6 py-8 text-sm text-ink/60">
Tidak ada alasan penyesuaian yang cocok dengan pencarian. Tidak ada alasan penyesuaian yang cocok dengan pencarian.
@ -236,6 +285,7 @@ export function AdjustmentReasonsClient() {
<tr> <tr>
<th>{dict.master.code}</th> <th>{dict.master.code}</th>
<th>{dict.master.name}</th> <th>{dict.master.name}</th>
<th>{dict.master.description}</th>
<th>Kategori</th> <th>Kategori</th>
<th>{dict.master.status}</th> <th>{dict.master.status}</th>
<th>{dict.common.actions}</th> <th>{dict.common.actions}</th>
@ -246,16 +296,35 @@ export function AdjustmentReasonsClient() {
<tr key={item.id}> <tr key={item.id}>
<td className="font-semibold text-ink">{item.code}</td> <td className="font-semibold text-ink">{item.code}</td>
<td>{item.name}</td> <td>{item.name}</td>
<td className="text-slate-600">{item.category}</td> <td className="text-slate-600">{item.description || "-"}</td>
<td className="text-slate-600">{getCategoryLabel(item.category)}</td>
<td> <td>
<span className={item.status === "INACTIVE" ? "ops-chip-danger" : "ops-chip-active"}> <span
{item.status === "INACTIVE" ? dict.master.inactive : dict.master.active} className={
item.status === "INACTIVE" ? "ops-chip-danger" : "ops-chip-active"
}
>
{item.status === "INACTIVE"
? dict.master.inactive
: dict.master.active}
</span> </span>
</td> </td>
<td> <td>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" onClick={() => startEdit(item)} className="ops-btn-secondary">{dict.common.edit}</button> <button
<button type="button" onClick={() => void handleDelete(item.id)} className="ops-btn-danger">{dict.common.delete}</button> type="button"
onClick={() => startEdit(item)}
className="ops-btn-secondary"
>
{dict.common.edit}
</button>
<button
type="button"
onClick={() => void handleDelete(item.id)}
className="ops-btn-danger"
>
{dict.common.delete}
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -271,7 +340,9 @@ export function AdjustmentReasonsClient() {
totalItems={pagination.totalItems} totalItems={pagination.totalItems}
itemLabel="adjustment reason" itemLabel="adjustment reason"
onPrev={() => pagination.setPage((page) => Math.max(1, page - 1))} onPrev={() => pagination.setPage((page) => Math.max(1, page - 1))}
onNext={() => pagination.setPage((page) => Math.min(pagination.totalPages, page + 1))} onNext={() =>
pagination.setPage((page) => Math.min(pagination.totalPages, page + 1))
}
/> />
</> </>
)} )}
@ -284,13 +355,11 @@ function Field({
label, label,
value, value,
onChange, onChange,
readOnly = false,
placeholder placeholder
}: { }: {
label: string; label: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
readOnly?: boolean;
placeholder?: string; placeholder?: string;
}) { }) {
return ( return (
@ -298,7 +367,6 @@ function Field({
<span className="ops-label">{label}</span> <span className="ops-label">{label}</span>
<input <input
value={value} value={value}
readOnly={readOnly}
placeholder={placeholder} placeholder={placeholder}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
className="ops-input" className="ops-input"

View File

@ -4,7 +4,8 @@ type SerializableAdjustmentReason = {
id: bigint; id: bigint;
code: string; code: string;
name: string; name: string;
category: string; description: string | null;
category: AdjustmentReasonRecord["category"];
status: string; status: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@ -17,6 +18,7 @@ export function serializeAdjustmentReason(
id: reason.id.toString(), id: reason.id.toString(),
code: reason.code, code: reason.code,
name: reason.name, name: reason.name,
description: reason.description,
category: reason.category, category: reason.category,
status: reason.status, status: reason.status,
created_at: reason.createdAt.toISOString(), created_at: reason.createdAt.toISOString(),

View File

@ -1,8 +1,16 @@
import { z } from "zod"; import { z } from "zod";
const adjustmentReasonCategorySchema = z.enum([
"SHRINKAGE",
"DAMAGE",
"REGRADE",
"ADJUSTMENT"
]);
export const adjustmentReasonInputSchema = z.object({ export const adjustmentReasonInputSchema = z.object({
code: z.string().trim().max(50).optional().or(z.literal("")), code: z.string().trim().max(50).optional().or(z.literal("")),
name: z.string().trim().min(1, "Nama alasan wajib diisi").max(100), name: z.string().trim().min(1, "Nama alasan wajib diisi").max(100),
category: z.string().trim().min(1, "Category wajib diisi").max(50), description: z.string().trim().max(255).optional().or(z.literal("")),
category: adjustmentReasonCategorySchema,
status: z.enum(["ACTIVE", "INACTIVE"]).default("ACTIVE") status: z.enum(["ACTIVE", "INACTIVE"]).default("ACTIVE")
}); });

View File

@ -26,6 +26,13 @@ const emptyForm = (): StockAdjustmentForm => ({
notes: "" notes: ""
}); });
const adjustmentReasonCategoryLabels: Record<AdjustmentReasonRecord["category"], string> = {
ADJUSTMENT: "Penyesuaian / Adjustment",
SHRINKAGE: "Penyusutan / Shrinkage",
DAMAGE: "Kerusakan / Damage",
REGRADE: "Regrade / Regrade"
};
export function StockAdjustmentsClient() { export function StockAdjustmentsClient() {
const { dict, locale } = useLocale(); const { dict, locale } = useLocale();
const [items, setItems] = useState<StockAdjustmentListItem[]>([]); const [items, setItems] = useState<StockAdjustmentListItem[]>([]);
@ -167,14 +174,24 @@ export function StockAdjustmentsClient() {
} }
options={reasons.map((reason) => ({ options={reasons.map((reason) => ({
value: reason.id, value: reason.id,
label: `${reason.code} - ${reason.name} (${reason.category})` label: `${reason.code} - ${reason.name} (${adjustmentReasonCategoryLabels[reason.category]})`
}))} }))}
placeholder={dict.stockAdjustments.chooseReason} placeholder={dict.stockAdjustments.chooseReason}
/> />
{selectedReason ? ( {selectedReason ? (
<div className="rounded border border-line/70 bg-slate-50 px-4 py-4 text-sm text-slate-600"> <div className="rounded border border-line/70 bg-slate-50 px-4 py-4 text-sm text-slate-600">
{dict.stockAdjustments.category}: <span className="font-medium text-ink">{selectedReason.category}</span> <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> </div>
) : null} ) : null}
@ -261,7 +278,14 @@ export function StockAdjustmentsClient() {
</td> </td>
<td className="text-slate-600"> <td className="text-slate-600">
{item.reason.name} {item.reason.name}
<div className="mt-1 text-xs text-slate-500">{item.reason.category}</div> <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>
<td className={item.qty_change < 0 ? "text-ember" : "text-moss"}> <td className={item.qty_change < 0 ? "text-ember" : "text-moss"}>
{item.qty_change > 0 ? "+" : ""} {item.qty_change > 0 ? "+" : ""}

View File

@ -20,7 +20,8 @@ type SerializableStockAdjustment = {
id: bigint; id: bigint;
code: string; code: string;
name: string; name: string;
category: string; description: string | null;
category: StockAdjustmentListItem["reason"]["category"];
}; };
createdBy: { createdBy: {
id: bigint; id: bigint;
@ -46,6 +47,7 @@ export function serializeStockAdjustment(
id: item.adjustmentReason.id.toString(), id: item.adjustmentReason.id.toString(),
code: item.adjustmentReason.code, code: item.adjustmentReason.code,
name: item.adjustmentReason.name, name: item.adjustmentReason.name,
description: item.adjustmentReason.description,
category: item.adjustmentReason.category category: item.adjustmentReason.category
}, },
qty_change: item.qtyChange.toNumber(), qty_change: item.qtyChange.toNumber(),

View File

@ -1,4 +1,5 @@
export type MasterStatus = "ACTIVE" | "INACTIVE"; export type MasterStatus = "ACTIVE" | "INACTIVE";
export type AdjustmentReasonCategory = "SHRINKAGE" | "DAMAGE" | "REGRADE" | "ADJUSTMENT";
export type GradePriceStandardRecord = { export type GradePriceStandardRecord = {
id: string; id: string;
@ -49,7 +50,8 @@ export type AdjustmentReasonRecord = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
category: string; description: string | null;
category: AdjustmentReasonCategory;
status: string; status: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View File

@ -1,3 +1,5 @@
import type { AdjustmentReasonCategory } from "@/types/master-data";
export type StockAdjustmentListItem = { export type StockAdjustmentListItem = {
id: string; id: string;
adjustment_no: string; adjustment_no: string;
@ -12,7 +14,8 @@ export type StockAdjustmentListItem = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
category: string; description: string | null;
category: AdjustmentReasonCategory;
}; };
qty_change: number; qty_change: number;
available_qty_before: number; available_qty_before: number;