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")
}
enum AdjustmentReasonCategory {
SHRINKAGE
DAMAGE
REGRADE
ADJUSTMENT
}
model Role {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(50)
@ -486,7 +493,8 @@ model AdjustmentReason {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(50)
name String @db.VarChar(100)
category String @db.VarChar(50)
description String? @db.VarChar(255)
category AdjustmentReasonCategory
status String @default("ACTIVE") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

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

View File

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

View File

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

View File

@ -118,13 +118,13 @@ export async function POST(request: Request) {
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(
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(
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 { PaginationFooter } from "@/components/shared/pagination-footer";
import { useCurrentUser } from "@/hooks/use-current-user";
import { usePagination } from "@/hooks/use-pagination";
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 = {
code: string;
name: string;
category: string;
description: string;
category: AdjustmentReasonCategory;
status: MasterStatus;
};
const emptyForm: FormValues = {
code: "",
name: "",
category: "",
description: "",
category: "ADJUSTMENT",
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() {
const { dict } = useLocale();
const [items, setItems] = useState<AdjustmentReasonRecord[]>([]);
@ -32,14 +46,18 @@ export function AdjustmentReasonsClient() {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { canEditCode } = useCurrentUser();
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
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]);
const pagination = usePagination(filteredItems, 20);
async function loadItems() {
@ -70,8 +88,8 @@ export function AdjustmentReasonsClient() {
function startEdit(item: AdjustmentReasonRecord) {
setEditingId(item.id);
setForm({
code: item.code,
name: item.name,
description: item.description ?? "",
category: item.category,
status: item.status as MasterStatus
});
@ -82,17 +100,20 @@ export function AdjustmentReasonsClient() {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const response = await fetch(
editingId ? `/api/v1/adjustment-reasons/${editingId}` : "/api/v1/adjustment-reasons",
{
method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form)
body: JSON.stringify({ ...form, code: "" })
}
);
const payload =
(await response.json()) as DetailResponse<AdjustmentReasonRecord> | ApiErrorResponse;
if (!response.ok) {
if ("errors" in payload && payload.errors) {
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);
}
resetForm();
await loadItems();
} catch (err) {
@ -111,6 +133,7 @@ export function AdjustmentReasonsClient() {
async function handleDelete(id: string) {
if (!window.confirm(`${dict.common.delete} adjustment reason ini?`)) return;
try {
const response = await fetch(`/api/v1/adjustment-reasons/${id}`, {
method: "DELETE"
@ -142,29 +165,52 @@ export function AdjustmentReasonsClient() {
</p>
</div>
{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}
</div>
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<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
label={dict.master.name}
value={form.name}
onChange={(value) => setForm((current) => ({ ...current, name: value }))}
/>
<Field
label="Kategori"
value={form.category}
onChange={(value) => setForm((current) => ({ ...current, category: value }))}
/>
<label className="block">
<span className="ops-label">Kategori</span>
<select
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>
<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">
<span className="ops-label">{dict.master.status}</span>
<select
@ -181,11 +227,13 @@ export function AdjustmentReasonsClient() {
<option value="INACTIVE">{dict.master.inactive}</option>
</select>
</label>
{error ? (
<div className="rounded border border-ember/30 bg-ember/10 px-4 py-3 text-sm text-ember">
{error}
</div>
) : null}
<button type="submit" disabled={submitting} className="ops-btn-primary">
{submitting
? dict.common.processing
@ -195,6 +243,7 @@ export function AdjustmentReasonsClient() {
</button>
</form>
</div>
<div className="ops-table-shell">
<div className="ops-section-head">
<div>
@ -205,6 +254,7 @@ export function AdjustmentReasonsClient() {
{items.length} {dict.master.dataCount}
</div>
</div>
{!loading && items.length > 0 ? (
<div className="border-t border-line/70 px-6 py-4">
<label className="block">
@ -212,18 +262,17 @@ export function AdjustmentReasonsClient() {
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Cari kode, nama, atau kategori..."
placeholder="Cari kode, nama, deskripsi, atau kategori..."
className="ops-input"
/>
</label>
</div>
) : null}
{loading ? (
<div className="px-6 py-8 text-sm text-ink/60">{dict.common.loading}</div>
) : items.length === 0 ? (
<div className="px-6 py-8 text-sm text-ink/60">
{dict.master.noData}
</div>
<div className="px-6 py-8 text-sm text-ink/60">{dict.master.noData}</div>
) : filteredItems.length === 0 ? (
<div className="px-6 py-8 text-sm text-ink/60">
Tidak ada alasan penyesuaian yang cocok dengan pencarian.
@ -236,6 +285,7 @@ export function AdjustmentReasonsClient() {
<tr>
<th>{dict.master.code}</th>
<th>{dict.master.name}</th>
<th>{dict.master.description}</th>
<th>Kategori</th>
<th>{dict.master.status}</th>
<th>{dict.common.actions}</th>
@ -246,16 +296,35 @@ export function AdjustmentReasonsClient() {
<tr key={item.id}>
<td className="font-semibold text-ink">{item.code}</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>
<span className={item.status === "INACTIVE" ? "ops-chip-danger" : "ops-chip-active"}>
{item.status === "INACTIVE" ? dict.master.inactive : dict.master.active}
<span
className={
item.status === "INACTIVE" ? "ops-chip-danger" : "ops-chip-active"
}
>
{item.status === "INACTIVE"
? dict.master.inactive
: dict.master.active}
</span>
</td>
<td>
<div className="flex gap-2">
<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>
<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>
</td>
</tr>
@ -271,7 +340,9 @@ export function AdjustmentReasonsClient() {
totalItems={pagination.totalItems}
itemLabel="adjustment reason"
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,
value,
onChange,
readOnly = false,
placeholder
}: {
label: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
placeholder?: string;
}) {
return (
@ -298,7 +367,6 @@ function Field({
<span className="ops-label">{label}</span>
<input
value={value}
readOnly={readOnly}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="ops-input"

View File

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

View File

@ -1,8 +1,16 @@
import { z } from "zod";
const adjustmentReasonCategorySchema = z.enum([
"SHRINKAGE",
"DAMAGE",
"REGRADE",
"ADJUSTMENT"
]);
export const adjustmentReasonInputSchema = z.object({
code: z.string().trim().max(50).optional().or(z.literal("")),
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")
});

View File

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

View File

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

View File

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

View File

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