Standardize adjustment reason categories and descriptions
This commit is contained in:
@ -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");
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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")
|
||||
});
|
||||
|
||||
@ -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 ? "+" : ""}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user