Compare commits
5 Commits
770ecbd204
...
76fda6d1c4
| Author | SHA1 | Date |
|---|---|---|
|
|
76fda6d1c4 | 2 hours ago |
|
|
3002e55bc4 | 2 hours ago |
|
|
22d51475a7 | 3 hours ago |
|
|
5abf8a7e0c | 21 hours ago |
|
|
5c89d5714f | 22 hours ago |
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { DeleteState } from "@/lib/server-action";
|
||||
|
||||
type HapusMasterButtonProps = {
|
||||
id: number | string;
|
||||
idName: string;
|
||||
nama: string;
|
||||
entityLabel: string;
|
||||
action: (prevState: DeleteState, formData: FormData) => Promise<DeleteState>;
|
||||
};
|
||||
|
||||
export function HapusMasterButton({
|
||||
id,
|
||||
idName,
|
||||
nama,
|
||||
entityLabel,
|
||||
action,
|
||||
}: HapusMasterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (prevState: DeleteState, formData: FormData) => {
|
||||
const result = await action(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
{ ok: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title={`Hapus ${entityLabel}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span className="sr-only">Hapus</span>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Hapus {nama || `${entityLabel} ${id}`}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Data {entityLabel} {id} akan dihapus permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name={idName} value={id} />
|
||||
{state.error && (
|
||||
<p className="mb-4 text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
|
||||
<Button type="submit" variant="destructive" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Hapus
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
|
||||
import type { FormState } from "@/lib/server-action";
|
||||
|
||||
// Hanya untuk input teks biasa. Form yang butuh select, textarea, atau lookup ditulis sendiri
|
||||
// di _components fitur itu, jangan menambah opsi ke config ini.
|
||||
export type MasterField<F extends string = string> = {
|
||||
name: F;
|
||||
label: string;
|
||||
type?: React.HTMLInputTypeAttribute;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type MasterSheetFormProps<F extends string> = {
|
||||
action: (
|
||||
prevState: FormState<F>,
|
||||
formData: FormData,
|
||||
) => Promise<FormState<F>>;
|
||||
fields: readonly MasterField<F>[];
|
||||
defaultValues?: Partial<Record<F, string | number | null>>;
|
||||
hiddenFields?: Record<string, string | number>;
|
||||
title: string;
|
||||
description: string;
|
||||
trigger: React.ReactElement;
|
||||
};
|
||||
|
||||
export function MasterSheetForm<F extends string>({
|
||||
action,
|
||||
fields,
|
||||
defaultValues,
|
||||
hiddenFields,
|
||||
title,
|
||||
description,
|
||||
trigger,
|
||||
}: MasterSheetFormProps<F>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (prevState: FormState<F>, formData: FormData) => {
|
||||
const result = await action(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
{ ok: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger render={trigger} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4 overflow-y-auto p-4"
|
||||
>
|
||||
{hiddenFields &&
|
||||
Object.entries(hiddenFields).map(([name, value]) => (
|
||||
<input key={name} type="hidden" name={name} value={value} />
|
||||
))}
|
||||
|
||||
{fields.map((field) => {
|
||||
const errors = state.fieldErrors?.[field.name];
|
||||
|
||||
return (
|
||||
<Field key={field.name} data-invalid={!!errors}>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && " *"}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type={field.type}
|
||||
onWheel={
|
||||
field.type === "number"
|
||||
? (e) => e.currentTarget.blur()
|
||||
: undefined
|
||||
}
|
||||
defaultValue={
|
||||
state.values?.[field.name] ??
|
||||
defaultValues?.[field.name] ??
|
||||
""
|
||||
}
|
||||
aria-invalid={!!errors}
|
||||
required={field.required}
|
||||
/>
|
||||
<FieldError errors={errors?.map((message) => ({ message }))} />
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
|
||||
{state.error && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Simpan
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
import { PencilIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import { listGroupProduct } from "@/data/group-product";
|
||||
import { halamanHref, parsePage } from "@/lib/pagination";
|
||||
import { HapusGroupProductButton } from "./hapus-group-product-button";
|
||||
import { GroupProductForm } from "./group-product-form";
|
||||
|
||||
export async function DaftarGroupProduct({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
await getCurrentUser();
|
||||
|
||||
const { search, page: pageParam } = await searchParams;
|
||||
const page = parsePage(pageParam);
|
||||
const { rows, hasNext } = await listGroupProduct(search, page);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs tracking-wide text-muted-foreground uppercase">
|
||||
<th className="px-3 py-2.5 font-medium">Kode</th>
|
||||
<th className="px-3 py-2.5 font-medium">Nama</th>
|
||||
<th className="px-3 py-2.5 font-medium">Kategori</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">
|
||||
Persen Thiwul
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows.map((g) => (
|
||||
<tr
|
||||
key={g.kdStrip}
|
||||
className="transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap text-muted-foreground">
|
||||
{g.kdStrip}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{g.namaStrip}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{g.kelJns || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{g.persenTiwul}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<GroupProductForm
|
||||
groupProduct={g}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Edit group product"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<HapusGroupProductButton
|
||||
kdStrip={g.kdStrip}
|
||||
nama={g.namaStrip}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{page > 1
|
||||
? "Halaman ini kosong"
|
||||
: "Tidak ada group product"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
hasNext={hasNext}
|
||||
hrefPage={(p) => halamanHref("/master/group-product", p, search)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MasterSheetForm,
|
||||
type MasterField,
|
||||
} from "@/app/(dashboard)/master/_components/master-sheet-form";
|
||||
import type { GroupProduct } from "@/data/group-product";
|
||||
import { simpanGroupProduct, type SimpanGroupProductState } from "../actions";
|
||||
|
||||
type GroupProductFormProps = {
|
||||
groupProduct?: GroupProduct;
|
||||
trigger: React.ReactElement;
|
||||
};
|
||||
|
||||
type FieldName = keyof NonNullable<SimpanGroupProductState["values"]>;
|
||||
|
||||
const fields: MasterField<FieldName>[] = [
|
||||
{ name: "namaStrip", label: "Nama Group Product", required: true },
|
||||
{ name: "murah1", label: "Batas Bawah Murah", type: "number" },
|
||||
{ name: "murah2", label: "Batas Atas Murah", type: "number" },
|
||||
{ name: "sedang1", label: "Batas Bawah Sedang", type: "number" },
|
||||
{ name: "sedang2", label: "Batas Atas Sedang", type: "number" },
|
||||
{ name: "mahal1", label: "Batas Bawah Mahal", type: "number" },
|
||||
{ name: "mahal2", label: "Batas Atas Mahal", type: "number" },
|
||||
{ name: "benikMurah", label: "Bonus Benik Murah", type: "number" },
|
||||
{ name: "benikSedang", label: "Bonus Benik Sedang", type: "number" },
|
||||
{ name: "benikMahal", label: "Bonus Benik Mahal", type: "number" },
|
||||
{ name: "kelBrg", label: "Kelompok Barang" },
|
||||
{ name: "kelJns", label: "Kategori (P, W, A, S)" },
|
||||
{ name: "persenTiwul", label: "Persen Thiwul", type: "number" },
|
||||
];
|
||||
|
||||
export function GroupProductForm({
|
||||
groupProduct,
|
||||
trigger,
|
||||
}: GroupProductFormProps) {
|
||||
return (
|
||||
<MasterSheetForm
|
||||
action={simpanGroupProduct}
|
||||
fields={fields}
|
||||
defaultValues={groupProduct}
|
||||
hiddenFields={
|
||||
groupProduct ? { kdStrip: groupProduct.kdStrip } : undefined
|
||||
}
|
||||
title={
|
||||
groupProduct ? `Edit ${groupProduct.namaStrip}` : "Tambah Group Product"
|
||||
}
|
||||
description={
|
||||
groupProduct
|
||||
? `Kode: ${groupProduct.kdStrip}`
|
||||
: "Isi detail group product baru"
|
||||
}
|
||||
trigger={trigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||
import { hapusGroupProduct } from "../actions";
|
||||
|
||||
type HapusGroupProductButtonProps = {
|
||||
kdStrip: string;
|
||||
nama: string;
|
||||
};
|
||||
|
||||
export function HapusGroupProductButton({
|
||||
kdStrip,
|
||||
nama,
|
||||
}: HapusGroupProductButtonProps) {
|
||||
return (
|
||||
<HapusMasterButton
|
||||
id={kdStrip}
|
||||
idName="kdStrip"
|
||||
nama={nama}
|
||||
entityLabel="group product"
|
||||
action={hapusGroupProduct}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GroupProductForm } from "./group-product-form";
|
||||
|
||||
export function TambahGroupProductButton() {
|
||||
return (
|
||||
<GroupProductForm
|
||||
trigger={
|
||||
<Button>
|
||||
Tambah
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import {
|
||||
countPemakaianGroupProduct,
|
||||
deleteGroupProduct,
|
||||
insertGroupProduct,
|
||||
updateGroupProduct,
|
||||
type GroupProductInput,
|
||||
} from "@/data/group-product";
|
||||
import {
|
||||
logActionError,
|
||||
type DeleteState,
|
||||
type FormState,
|
||||
} from "@/lib/server-action";
|
||||
|
||||
type GroupProductField = keyof typeof groupProductSchema.shape;
|
||||
export type SimpanGroupProductState = FormState<GroupProductField> & {
|
||||
kdStrip?: string;
|
||||
};
|
||||
|
||||
export type HapusGroupProductState = DeleteState;
|
||||
|
||||
// Batas atas = rentang kolom `int` MariaDB.
|
||||
const angka = z.coerce
|
||||
.number({ error: "Harus berupa angka" })
|
||||
.int("Harus bilangan bulat")
|
||||
.min(0, "Tidak boleh negatif")
|
||||
.max(2_000_000_000, "Maksimal 2.000.000.000");
|
||||
|
||||
const groupProductSchema = z.object({
|
||||
namaStrip: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Nama wajib diisi")
|
||||
.max(50, "Nama maksimal 50 karakter"),
|
||||
murah1: angka,
|
||||
murah2: angka,
|
||||
sedang1: angka,
|
||||
sedang2: angka,
|
||||
mahal1: angka,
|
||||
mahal2: angka,
|
||||
benikMurah: angka,
|
||||
benikSedang: angka,
|
||||
benikMahal: angka,
|
||||
kelBrg: z.string().trim().max(1, "Kelompok barang maksimal 1 karakter"),
|
||||
kelJns: z.string().trim().max(1, "Kategori maksimal 1 karakter"),
|
||||
persenTiwul: z.coerce
|
||||
.number({ error: "Harus berupa angka" })
|
||||
.min(0, "Tidak boleh negatif")
|
||||
.max(100, "Maksimal 100"),
|
||||
});
|
||||
|
||||
function emptyStringToNull(value: string): string | null {
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
|
||||
// Kolom angka dikosongkan berarti 0, kecuali persen tiwul yang default-nya 0.025.
|
||||
function angkaAtauDefault(value: string, def: string): string {
|
||||
return value === "" ? def : value;
|
||||
}
|
||||
|
||||
export async function simpanGroupProduct(
|
||||
_prevState: SimpanGroupProductState,
|
||||
formData: FormData,
|
||||
): Promise<SimpanGroupProductState> {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const fields = Object.keys(groupProductSchema.shape) as GroupProductField[];
|
||||
const raw = Object.fromEntries(
|
||||
fields.map((field) => [field, String(formData.get(field) ?? "")]),
|
||||
) as Record<GroupProductField, string>;
|
||||
|
||||
const values: Record<GroupProductField, string> = {
|
||||
...raw,
|
||||
murah1: angkaAtauDefault(raw.murah1, "0"),
|
||||
murah2: angkaAtauDefault(raw.murah2, "0"),
|
||||
sedang1: angkaAtauDefault(raw.sedang1, "0"),
|
||||
sedang2: angkaAtauDefault(raw.sedang2, "0"),
|
||||
mahal1: angkaAtauDefault(raw.mahal1, "0"),
|
||||
mahal2: angkaAtauDefault(raw.mahal2, "0"),
|
||||
benikMurah: angkaAtauDefault(raw.benikMurah, "0"),
|
||||
benikSedang: angkaAtauDefault(raw.benikSedang, "0"),
|
||||
benikMahal: angkaAtauDefault(raw.benikMahal, "0"),
|
||||
persenTiwul: angkaAtauDefault(raw.persenTiwul, "0.025"),
|
||||
};
|
||||
|
||||
const parsed = groupProductSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const input: GroupProductInput = {
|
||||
...parsed.data,
|
||||
kelBrg: emptyStringToNull(parsed.data.kelBrg),
|
||||
};
|
||||
const kdStrip = String(formData.get("kdStrip") ?? "").trim();
|
||||
|
||||
try {
|
||||
if (kdStrip === "") {
|
||||
const kdBaru = await insertGroupProduct(input);
|
||||
revalidatePath("/master/group-product");
|
||||
return { ok: true, kdStrip: kdBaru };
|
||||
}
|
||||
|
||||
const updated = await updateGroupProduct(kdStrip, input);
|
||||
if (!updated) {
|
||||
return { ok: false, error: "Group product tidak ditemukan", values };
|
||||
}
|
||||
revalidatePath("/master/group-product");
|
||||
return { ok: true, kdStrip };
|
||||
} catch (error) {
|
||||
logActionError(
|
||||
"simpanGroupProduct",
|
||||
{ userId: user.id, kdStrip: kdStrip || "(baru)", nama: input.namaStrip },
|
||||
error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Gagal menyimpan group product, coba simpan ulang",
|
||||
values,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function hapusGroupProduct(
|
||||
_prevState: HapusGroupProductState,
|
||||
formData: FormData,
|
||||
): Promise<HapusGroupProductState> {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const kdStrip = String(formData.get("kdStrip") ?? "").trim();
|
||||
if (kdStrip === "") {
|
||||
return { ok: false, error: "Kode group product tidak ada" };
|
||||
}
|
||||
|
||||
try {
|
||||
const dipakai = await countPemakaianGroupProduct(kdStrip);
|
||||
if (dipakai.barang > 0 || dipakai.subGroup > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Group product masih dipakai ${dipakai.barang} barang dan ${dipakai.subGroup} sub group product, tidak bisa dihapus`,
|
||||
};
|
||||
}
|
||||
|
||||
const deleted = await deleteGroupProduct(kdStrip);
|
||||
if (!deleted) {
|
||||
return { ok: false, error: "Group product tidak ditemukan" };
|
||||
}
|
||||
revalidatePath("/master/group-product");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
logActionError("hapusGroupProduct", { userId: user.id, kdStrip }, error);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Gagal menghapus group product, coba hapus ulang",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
|
||||
import { DaftarGroupProduct } from "./_components/daftar-group-product";
|
||||
import { TambahGroupProductButton } from "./_components/tambah-group-product-button";
|
||||
|
||||
export default function GroupProductPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Data Group Product</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Suspense fallback={null}>
|
||||
<CariMaster
|
||||
basePath="/master/group-product"
|
||||
placeholder="Cari group product..."
|
||||
/>
|
||||
</Suspense>
|
||||
<TambahGroupProductButton />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
|
||||
<DaftarGroupProduct searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,80 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { hapusPelanggan, type HapusPelangganState } from "../actions";
|
||||
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||
import { hapusPelanggan } from "../actions";
|
||||
|
||||
type HapusPelangganButtonProps = {
|
||||
kdCustomer: string;
|
||||
nama: string;
|
||||
};
|
||||
|
||||
const initial: HapusPelangganState = { ok: false };
|
||||
|
||||
export function HapusPelangganButton({
|
||||
kdCustomer,
|
||||
nama,
|
||||
}: HapusPelangganButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (prevState: HapusPelangganState, formData: FormData) => {
|
||||
const result = await hapusPelanggan(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
initial,
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title="Hapus pelanggan"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span className="sr-only">Hapus</span>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hapus {nama}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Data pelanggan {kdCustomer} akan dihapus permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="kdCustomer" value={kdCustomer} />
|
||||
{state.error && (
|
||||
<p className="mb-4 text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
|
||||
<Button type="submit" variant="destructive" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Hapus
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<HapusMasterButton
|
||||
id={kdCustomer}
|
||||
idName="kdCustomer"
|
||||
nama={nama}
|
||||
entityLabel="pelanggan"
|
||||
action={hapusPelanggan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,191 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
|
||||
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
|
||||
MasterSheetForm,
|
||||
type MasterField,
|
||||
} from "@/app/(dashboard)/master/_components/master-sheet-form";
|
||||
import type { Pelanggan } from "@/data/pelanggan";
|
||||
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
|
||||
|
||||
type PelangganFormProps = {
|
||||
pelanggan?: Pelanggan;
|
||||
trigger: React.ReactElement;
|
||||
};
|
||||
|
||||
const initial: SimpanPelangganState = { ok: false };
|
||||
|
||||
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Sheet ditutup di sini, bukan di useEffect: effect dengan deps [state.ok] tidak
|
||||
// jalan lagi saat simpan sukses kedua (true -> true), jadi sheet tetap terbuka.
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (prevState: SimpanPelangganState, formData: FormData) => {
|
||||
const result = await simpanPelanggan(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
initial,
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger render={trigger} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{pelanggan
|
||||
? `Kode: ${pelanggan.kdCustomer}`
|
||||
: "Isi detail pelanggan baru"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4 overflow-y-auto p-4"
|
||||
>
|
||||
{pelanggan && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="kdCustomer"
|
||||
value={pelanggan.kdCustomer}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormInput
|
||||
name="nama"
|
||||
label="Nama *"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.nama}
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
name="alias"
|
||||
label="Alias"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.alias}
|
||||
/>
|
||||
<FormInput
|
||||
name="ktp"
|
||||
label="No. KTP"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.ktp}
|
||||
/>
|
||||
<FormInput
|
||||
name="alamat"
|
||||
label="Alamat *"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.alamat}
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
name="wilayah"
|
||||
label="Wilayah"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.wilayah}
|
||||
/>
|
||||
<FormInput
|
||||
name="telp"
|
||||
label="Telepon"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.telp}
|
||||
/>
|
||||
<FormInput
|
||||
name="ponsel"
|
||||
label="Ponsel"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.ponsel}
|
||||
/>
|
||||
<FormInput
|
||||
name="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.email}
|
||||
/>
|
||||
<FormInput
|
||||
name="jenisCustomer"
|
||||
label="Jenis Customer"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.jenisCustomer}
|
||||
/>
|
||||
<FormInput
|
||||
name="upline"
|
||||
label="Upline"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.upline}
|
||||
/>
|
||||
<FormInput
|
||||
name="btsBon"
|
||||
label="BTS Bon"
|
||||
type="number"
|
||||
state={state}
|
||||
defaultValue={pelanggan?.btsBon}
|
||||
/>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Simpan
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
type FieldName = keyof NonNullable<SimpanPelangganState["values"]>;
|
||||
|
||||
type FormInputProps = Omit<
|
||||
React.ComponentProps<"input">,
|
||||
"name" | "defaultValue"
|
||||
> & {
|
||||
name: FieldName;
|
||||
label: string;
|
||||
state: SimpanPelangganState;
|
||||
defaultValue?: string | number | null;
|
||||
};
|
||||
|
||||
// Isian dari server (state.values) didahulukan supaya ketikan user tidak hilang
|
||||
// saat validasi gagal; kalau belum ada, pakai data pelanggan yang sedang diedit.
|
||||
function FormInput({
|
||||
name,
|
||||
label,
|
||||
state,
|
||||
defaultValue,
|
||||
...props
|
||||
}: FormInputProps) {
|
||||
const errors = state.fieldErrors?.[name];
|
||||
const fields: MasterField<FieldName>[] = [
|
||||
{ name: "nama", label: "Nama", required: true },
|
||||
{ name: "alias", label: "Alias" },
|
||||
{ name: "ktp", label: "No. KTP" },
|
||||
{ name: "alamat", label: "Alamat", required: true },
|
||||
{ name: "wilayah", label: "Wilayah" },
|
||||
{ name: "telp", label: "Telepon" },
|
||||
{ name: "ponsel", label: "Ponsel" },
|
||||
{ name: "email", label: "Email", type: "email" },
|
||||
{ name: "jenisCustomer", label: "Jenis Customer" },
|
||||
{ name: "upline", label: "Upline" },
|
||||
{ name: "btsBon", label: "Batas Bon", type: "number" },
|
||||
];
|
||||
|
||||
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
|
||||
return (
|
||||
<Field data-invalid={!!errors}>
|
||||
<FieldLabel htmlFor={name}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
defaultValue={state.values?.[name] ?? defaultValue ?? ""}
|
||||
aria-invalid={!!errors}
|
||||
{...props}
|
||||
/>
|
||||
<FieldError errors={errors?.map((message) => ({ message }))} />
|
||||
</Field>
|
||||
<MasterSheetForm
|
||||
action={simpanPelanggan}
|
||||
fields={fields}
|
||||
defaultValues={pelanggan}
|
||||
hiddenFields={
|
||||
pelanggan ? { kdCustomer: pelanggan.kdCustomer } : undefined
|
||||
}
|
||||
title={pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
|
||||
description={
|
||||
pelanggan
|
||||
? `Kode: ${pelanggan.kdCustomer}`
|
||||
: "Isi detail pelanggan baru"
|
||||
}
|
||||
trigger={trigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
import { PencilIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import { listRak } from "@/data/rak";
|
||||
import { halamanHref, parsePage } from "@/lib/pagination";
|
||||
import { HapusRakButton } from "./hapus-rak-button";
|
||||
import { RakForm } from "./rak-form";
|
||||
|
||||
export async function DaftarRak({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
await getCurrentUser();
|
||||
|
||||
const { search, page: pageParam } = await searchParams;
|
||||
const page = parsePage(pageParam);
|
||||
const { rows, hasNext } = await listRak(search, page);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs tracking-wide text-muted-foreground uppercase">
|
||||
<th className="px-3 py-2.5 font-medium">ID</th>
|
||||
<th className="px-3 py-2.5 font-medium">Nama Rak</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.idRak}
|
||||
className="transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{r.idRak}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{r.namaRak}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<RakForm
|
||||
rak={r}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Edit rak"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<HapusRakButton idRak={r.idRak} nama={r.namaRak ?? ""} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{page > 1 ? "Halaman ini kosong" : "Tidak ada rak"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
hasNext={hasNext}
|
||||
hrefPage={(p) => halamanHref("/master/rak", p, search)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||
import { hapusRak } from "../actions";
|
||||
|
||||
type HapusRakButtonProps = {
|
||||
idRak: number;
|
||||
nama: string;
|
||||
};
|
||||
|
||||
export function HapusRakButton({ idRak, nama }: HapusRakButtonProps) {
|
||||
return (
|
||||
<HapusMasterButton
|
||||
id={idRak}
|
||||
idName="idRak"
|
||||
nama={nama}
|
||||
entityLabel="rak"
|
||||
action={hapusRak}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import {
|
||||
MasterSheetForm,
|
||||
type MasterField,
|
||||
} from "@/app/(dashboard)/master/_components/master-sheet-form";
|
||||
import type { Rak } from "@/data/rak";
|
||||
import { simpanRak, type SimpanRakState } from "../actions";
|
||||
|
||||
type RakFormProps = {
|
||||
rak?: Rak;
|
||||
trigger: ReactElement;
|
||||
};
|
||||
|
||||
type FieldName = keyof NonNullable<SimpanRakState["values"]>;
|
||||
|
||||
const fields: MasterField<FieldName>[] = [
|
||||
{ name: "namaRak", label: "Nama Rak", required: true },
|
||||
];
|
||||
|
||||
export function RakForm({ rak, trigger }: RakFormProps) {
|
||||
return (
|
||||
<MasterSheetForm
|
||||
action={simpanRak}
|
||||
fields={fields}
|
||||
defaultValues={rak && { namaRak: rak.namaRak }}
|
||||
hiddenFields={rak && { idRak: rak.idRak }}
|
||||
title={rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}
|
||||
description={rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"}
|
||||
trigger={trigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak";
|
||||
import {
|
||||
logActionError,
|
||||
type DeleteState,
|
||||
type FormState,
|
||||
} from "@/lib/server-action";
|
||||
|
||||
export type SimpanRakState = FormState<"namaRak">;
|
||||
export type HapusRakState = DeleteState;
|
||||
|
||||
const rakSchema = z.object({
|
||||
namaRak: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Nama rak wajib diisi")
|
||||
.max(30, "Maksimal 30 karakter"),
|
||||
});
|
||||
|
||||
export async function simpanRak(
|
||||
_prevState: SimpanRakState,
|
||||
formData: FormData,
|
||||
): Promise<SimpanRakState> {
|
||||
const user = await getCurrentUser();
|
||||
const values = {
|
||||
namaRak: String(formData.get("namaRak") ?? ""),
|
||||
};
|
||||
|
||||
const parsed = rakSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const input: RakInput = parsed.data;
|
||||
const idRaw = String(formData.get("idRak") ?? "");
|
||||
const id = idRaw ? Number.parseInt(idRaw, 10) : null;
|
||||
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) {
|
||||
return { ok: false, error: "ID rak tidak valid", values };
|
||||
}
|
||||
|
||||
try {
|
||||
if (id !== null) {
|
||||
const updated = await updateRak(id, input);
|
||||
if (!updated) {
|
||||
return { ok: false, error: "Rak tidak ditemukan", values };
|
||||
}
|
||||
revalidatePath("/master/rak");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await insertRak(input);
|
||||
revalidatePath("/master/rak");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
logActionError(
|
||||
"simpanRak",
|
||||
{
|
||||
userId: user.id,
|
||||
idRak: id ?? "(baru)",
|
||||
nama: input.namaRak,
|
||||
},
|
||||
error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Gagal menyimpan rak, coba simpan ulang",
|
||||
values,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function hapusRak(
|
||||
_prevState: HapusRakState,
|
||||
formData: FormData,
|
||||
): Promise<HapusRakState> {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const id = Number.parseInt(String(formData.get("idRak") ?? ""), 10);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
return { ok: false, error: "ID rak tidak ada" };
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await deleteRak(id);
|
||||
if (!deleted) {
|
||||
return { ok: false, error: "Rak tidak ditemukan" };
|
||||
}
|
||||
revalidatePath("/master/rak");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
logActionError("hapusRak", { userId: user.id, id }, error);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Gagal menghapus rak, coba hapus ulang",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { Suspense } from "react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
|
||||
import { DaftarRak } from "./_components/daftar-rak";
|
||||
import { RakForm } from "./_components/rak-form";
|
||||
|
||||
export default function RakPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Data Rak</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Suspense fallback={null}>
|
||||
<CariMaster basePath="/master/rak" placeholder="Cari rak..." />
|
||||
</Suspense>
|
||||
<RakForm
|
||||
trigger={
|
||||
<Button>
|
||||
Tambah
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
|
||||
<DaftarRak searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,82 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { hapusSatuanBarang, type HapusSatuanBarangState } from "../actions";
|
||||
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||
import { hapusSatuanBarang } from "../actions";
|
||||
|
||||
type HapusSatuanBarangButtonProps = {
|
||||
idSatuanBarang: number;
|
||||
nama: string;
|
||||
};
|
||||
|
||||
const initial: HapusSatuanBarangState = { ok: false };
|
||||
|
||||
export function HapusSatuanBarangButton({
|
||||
idSatuanBarang,
|
||||
nama,
|
||||
}: HapusSatuanBarangButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (prevState: HapusSatuanBarangState, formData: FormData) => {
|
||||
const result = await hapusSatuanBarang(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
initial,
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title="Hapus satuan"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span className="sr-only">Hapus</span>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Hapus {nama || `satuan ${idSatuanBarang}`}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Data satuan {idSatuanBarang} akan dihapus permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="idSatuanBarang" value={idSatuanBarang} />
|
||||
{state.error && (
|
||||
<p className="mb-4 text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
|
||||
<Button type="submit" variant="destructive" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Hapus
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<HapusMasterButton
|
||||
id={idSatuanBarang}
|
||||
idName="idSatuanBarang"
|
||||
nama={nama}
|
||||
entityLabel="satuan"
|
||||
action={hapusSatuanBarang}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,96 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
|
||||
MasterSheetForm,
|
||||
type MasterField,
|
||||
} from "@/app/(dashboard)/master/_components/master-sheet-form";
|
||||
import type { SatuanBarang } from "@/data/satuan-barang";
|
||||
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
|
||||
|
||||
type SatuanBarangFormProps = {
|
||||
satuan?: SatuanBarang;
|
||||
trigger: React.ReactElement;
|
||||
trigger: ReactElement;
|
||||
};
|
||||
|
||||
const initial: SimpanSatuanBarangState = { ok: false };
|
||||
type FieldName = keyof NonNullable<SimpanSatuanBarangState["values"]>;
|
||||
|
||||
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (prevState: SimpanSatuanBarangState, formData: FormData) => {
|
||||
const result = await simpanSatuanBarang(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
initial,
|
||||
);
|
||||
|
||||
const errors = state.fieldErrors?.namaSatuanBarang;
|
||||
const fields: MasterField<FieldName>[] = [
|
||||
{ name: "namaSatuanBarang", label: "Nama Satuan", required: true },
|
||||
];
|
||||
|
||||
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger render={trigger} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{satuan
|
||||
? `ID: ${satuan.idSatuanBarang}`
|
||||
: "Isi nama satuan barang baru"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4 overflow-y-auto p-4"
|
||||
>
|
||||
{satuan && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="idSatuanBarang"
|
||||
value={satuan.idSatuanBarang}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Field data-invalid={!!errors}>
|
||||
<FieldLabel htmlFor="namaSatuanBarang">Nama Satuan *</FieldLabel>
|
||||
<Input
|
||||
id="namaSatuanBarang"
|
||||
name="namaSatuanBarang"
|
||||
defaultValue={
|
||||
state.values?.namaSatuanBarang ?? satuan?.namaSatuanBarang ?? ""
|
||||
}
|
||||
aria-invalid={!!errors}
|
||||
required
|
||||
/>
|
||||
<FieldError errors={errors?.map((message) => ({ message }))} />
|
||||
</Field>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Simpan
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<MasterSheetForm
|
||||
action={simpanSatuanBarang}
|
||||
fields={fields}
|
||||
defaultValues={satuan && { namaSatuanBarang: satuan.namaSatuanBarang }}
|
||||
hiddenFields={satuan && { idSatuanBarang: satuan.idSatuanBarang }}
|
||||
title={satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
|
||||
description={
|
||||
satuan ? `ID: ${satuan.idSatuanBarang}` : "Isi nama satuan barang baru"
|
||||
}
|
||||
trigger={trigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import { PencilIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import { listGroupProductOptions } from "@/data/group-product";
|
||||
import { listSubGroupProduct } from "@/data/sub-group-product";
|
||||
import { halamanHref, parsePage } from "@/lib/pagination";
|
||||
import { HapusSubGroupProductButton } from "./hapus-sub-group-product-button";
|
||||
import { SubGroupProductForm } from "./sub-group-product-form";
|
||||
|
||||
export async function DaftarSubGroupProduct({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
await getCurrentUser();
|
||||
|
||||
const { search, page: pageParam } = await searchParams;
|
||||
const page = parsePage(pageParam);
|
||||
const [{ rows, hasNext }, groupProducts] = await Promise.all([
|
||||
listSubGroupProduct(search, page),
|
||||
listGroupProductOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs tracking-wide text-muted-foreground uppercase">
|
||||
<th className="px-3 py-2.5 font-medium">Kode</th>
|
||||
<th className="px-3 py-2.5 font-medium">Nama</th>
|
||||
<th className="px-3 py-2.5 font-medium">Group Product</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows.map((s) => (
|
||||
<tr key={s.id} className="transition-colors hover:bg-muted/40">
|
||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap text-muted-foreground">
|
||||
{s.kodeKedProduk}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{s.kedProduk}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{s.namaStrip
|
||||
? `${s.kodeStrip} - ${s.namaStrip}`
|
||||
: (s.kodeStrip ?? "-")}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<SubGroupProductForm
|
||||
subGroupProduct={s}
|
||||
groupProducts={groupProducts}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Edit sub group product"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<HapusSubGroupProductButton
|
||||
id={s.id}
|
||||
nama={s.kedProduk}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-3 py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{page > 1
|
||||
? "Halaman ini kosong"
|
||||
: "Tidak ada sub group product"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
hasNext={hasNext}
|
||||
hrefPage={(p) => halamanHref("/master/sub-group-product", p, search)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||
import { hapusSubGroupProduct } from "../actions";
|
||||
|
||||
type HapusSubGroupProductButtonProps = {
|
||||
id: number;
|
||||
nama: string;
|
||||
};
|
||||
|
||||
export function HapusSubGroupProductButton({
|
||||
id,
|
||||
nama,
|
||||
}: HapusSubGroupProductButtonProps) {
|
||||
return (
|
||||
<HapusMasterButton
|
||||
id={id}
|
||||
idName="id"
|
||||
nama={nama}
|
||||
entityLabel="sub group product"
|
||||
action={hapusSubGroupProduct}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
|
||||
import type { GroupProductOption } from "@/data/group-product";
|
||||
import type { SubGroupProduct } from "@/data/sub-group-product";
|
||||
import {
|
||||
simpanSubGroupProduct,
|
||||
type SimpanSubGroupProductState,
|
||||
} from "../actions";
|
||||
|
||||
// Gaya meniru Input (components/ui/input.tsx); belum ada komponen Select di components/ui.
|
||||
const selectClass =
|
||||
"h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30";
|
||||
|
||||
type SubGroupProductFormProps = {
|
||||
subGroupProduct?: SubGroupProduct;
|
||||
groupProducts: GroupProductOption[];
|
||||
trigger: React.ReactElement;
|
||||
};
|
||||
|
||||
export function SubGroupProductForm({
|
||||
subGroupProduct,
|
||||
groupProducts,
|
||||
trigger,
|
||||
}: SubGroupProductFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Kode dikunci saat edit karena barang menunjuk sub group lewat pasangan kode.
|
||||
const isEdit = subGroupProduct !== undefined;
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (
|
||||
prevState: SimpanSubGroupProductState,
|
||||
formData: FormData,
|
||||
): Promise<SimpanSubGroupProductState> => {
|
||||
const result = await simpanSubGroupProduct(prevState, formData);
|
||||
if (result.ok) setOpen(false);
|
||||
return result;
|
||||
},
|
||||
{ ok: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger render={trigger} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{subGroupProduct
|
||||
? `Edit ${subGroupProduct.kedProduk}`
|
||||
: "Tambah Sub Group Product"}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{subGroupProduct
|
||||
? "Kode dan group product tidak bisa diubah, hanya nama"
|
||||
: "Isi detail sub group product baru"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4 overflow-y-auto p-4"
|
||||
>
|
||||
{subGroupProduct && (
|
||||
<input type="hidden" name="id" value={subGroupProduct.id} />
|
||||
)}
|
||||
|
||||
<Field data-invalid={!!state.fieldErrors?.kodeStrip}>
|
||||
<FieldLabel htmlFor="kodeStrip">
|
||||
Group Product <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<select
|
||||
id="kodeStrip"
|
||||
name="kodeStrip"
|
||||
className={selectClass}
|
||||
defaultValue={
|
||||
subGroupProduct?.kodeStrip ?? state.values?.kodeStrip ?? ""
|
||||
}
|
||||
disabled={isEdit}
|
||||
required
|
||||
aria-invalid={!!state.fieldErrors?.kodeStrip}
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
className="bg-popover text-popover-foreground"
|
||||
disabled={true}
|
||||
>
|
||||
Pilih group product
|
||||
</option>
|
||||
{groupProducts.map((g) => (
|
||||
<option
|
||||
key={g.kdStrip}
|
||||
value={g.kdStrip}
|
||||
className="bg-popover text-popover-foreground"
|
||||
>
|
||||
{g.kdStrip} - {g.namaStrip}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError
|
||||
errors={state.fieldErrors?.kodeStrip?.map((message) => ({
|
||||
message,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!state.fieldErrors?.kodeKedProduk}>
|
||||
<FieldLabel htmlFor="kodeKedProduk">Kode *</FieldLabel>
|
||||
<Input
|
||||
id="kodeKedProduk"
|
||||
name="kodeKedProduk"
|
||||
maxLength={1}
|
||||
defaultValue={
|
||||
subGroupProduct?.kodeKedProduk ??
|
||||
state.values?.kodeKedProduk ??
|
||||
""
|
||||
}
|
||||
disabled={isEdit}
|
||||
aria-invalid={!!state.fieldErrors?.kodeKedProduk}
|
||||
required
|
||||
/>
|
||||
<FieldError
|
||||
errors={state.fieldErrors?.kodeKedProduk?.map((message) => ({
|
||||
message,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!state.fieldErrors?.kedProduk}>
|
||||
<FieldLabel htmlFor="kedProduk">Nama *</FieldLabel>
|
||||
<Input
|
||||
id="kedProduk"
|
||||
name="kedProduk"
|
||||
defaultValue={
|
||||
state.values?.kedProduk ?? subGroupProduct?.kedProduk ?? ""
|
||||
}
|
||||
aria-invalid={!!state.fieldErrors?.kedProduk}
|
||||
required
|
||||
/>
|
||||
<FieldError
|
||||
errors={state.fieldErrors?.kedProduk?.map((message) => ({
|
||||
message,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2Icon className="size-4 animate-spin" />}
|
||||
Simpan
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { GroupProductOption } from "@/data/group-product";
|
||||
import { SubGroupProductForm } from "./sub-group-product-form";
|
||||
|
||||
export function TambahSubGroupProductButton({
|
||||
groupProducts,
|
||||
}: {
|
||||
groupProducts: GroupProductOption[];
|
||||
}) {
|
||||
return (
|
||||
<SubGroupProductForm
|
||||
groupProducts={groupProducts}
|
||||
trigger={
|
||||
<Button>
|
||||
Tambah
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import {
|
||||
SubGroupProductDitolakError,
|
||||
countPemakaianSubGroupProduct,
|
||||
deleteSubGroupProduct,
|
||||
insertSubGroupProduct,
|
||||
updateSubGroupProduct,
|
||||
} from "@/data/sub-group-product";
|
||||
import {
|
||||
logActionError,
|
||||
type DeleteState,
|
||||
type FormState,
|
||||
} from "@/lib/server-action";
|
||||
|
||||
type SubGroupProductField = keyof typeof subGroupProductSchema.shape;
|
||||
export type SimpanSubGroupProductState = FormState<SubGroupProductField> & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
export type HapusSubGroupProductState = DeleteState;
|
||||
|
||||
const subGroupProductSchema = z.object({
|
||||
kodeKedProduk: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Kode wajib diisi")
|
||||
.max(1, "Kode maksimal 1 karakter"),
|
||||
kodeStrip: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Group product wajib dipilih")
|
||||
.max(2, "Kode group product maksimal 2 karakter"),
|
||||
kedProduk: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Nama wajib diisi")
|
||||
.max(50, "Nama maksimal 50 karakter"),
|
||||
});
|
||||
|
||||
// Edit hanya mengubah nama; kode dikunci (lihat SubGroupProductUpdate).
|
||||
const updateSchema = subGroupProductSchema.pick({ kedProduk: true });
|
||||
|
||||
export async function simpanSubGroupProduct(
|
||||
_prevState: SimpanSubGroupProductState,
|
||||
formData: FormData,
|
||||
): Promise<SimpanSubGroupProductState> {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const fields = Object.keys(
|
||||
subGroupProductSchema.shape,
|
||||
) as SubGroupProductField[];
|
||||
const values = Object.fromEntries(
|
||||
fields.map((field) => [field, String(formData.get(field) ?? "")]),
|
||||
) as Record<SubGroupProductField, string>;
|
||||
|
||||
const idRaw = String(formData.get("id") ?? "").trim();
|
||||
const id = idRaw === "" ? null : Number.parseInt(idRaw, 10);
|
||||
if (id !== null && !Number.isSafeInteger(id)) {
|
||||
return { ok: false, error: "Sub group product tidak valid", values };
|
||||
}
|
||||
|
||||
try {
|
||||
if (id === null) {
|
||||
const parsed = subGroupProductSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const idBaru = await insertSubGroupProduct(parsed.data);
|
||||
revalidatePath("/master/sub-group-product");
|
||||
return { ok: true, id: idBaru };
|
||||
}
|
||||
|
||||
const parsed = updateSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await updateSubGroupProduct(id, parsed.data);
|
||||
if (!updated) {
|
||||
return { ok: false, error: "Sub group product tidak ditemukan", values };
|
||||
}
|
||||
revalidatePath("/master/sub-group-product");
|
||||
return { ok: true, id };
|
||||
} catch (error) {
|
||||
// Penolakan aturan bisnis adalah validasi biasa, bukan error tak terduga, jadi tidak di-log.
|
||||
if (error instanceof SubGroupProductDitolakError) {
|
||||
return { ok: false, error: error.message, values };
|
||||
}
|
||||
logActionError(
|
||||
"simpanSubGroupProduct",
|
||||
{ userId: user.id, id: id ?? "(baru)", ...values },
|
||||
error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Gagal menyimpan sub group product, coba simpan ulang",
|
||||
values,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function hapusSubGroupProduct(
|
||||
_prevState: HapusSubGroupProductState,
|
||||
formData: FormData,
|
||||
): Promise<HapusSubGroupProductState> {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const id = Number.parseInt(String(formData.get("id") ?? ""), 10);
|
||||
if (!Number.isSafeInteger(id)) {
|
||||
return { ok: false, error: "Sub group product tidak valid" };
|
||||
}
|
||||
|
||||
try {
|
||||
const dipakai = await countPemakaianSubGroupProduct(id);
|
||||
if (dipakai > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Sub group product masih dipakai ${dipakai} barang, tidak bisa dihapus`,
|
||||
};
|
||||
}
|
||||
|
||||
const deleted = await deleteSubGroupProduct(id);
|
||||
if (!deleted) {
|
||||
return { ok: false, error: "Sub group product tidak ditemukan" };
|
||||
}
|
||||
revalidatePath("/master/sub-group-product");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
logActionError("hapusSubGroupProduct", { userId: user.id, id }, error);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Gagal menghapus sub group product, coba hapus ulang",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
|
||||
import { getCurrentUser } from "@/data/auth";
|
||||
import { listGroupProductOptions } from "@/data/group-product";
|
||||
import { DaftarSubGroupProduct } from "./_components/daftar-sub-group-product";
|
||||
import { TambahSubGroupProductButton } from "./_components/tambah-sub-group-product-button";
|
||||
|
||||
export default function SubGroupProductPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Data Sub Group Product</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Suspense fallback={null}>
|
||||
<CariMaster
|
||||
basePath="/master/sub-group-product"
|
||||
placeholder="Cari sub group product..."
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<TambahSubGroupProduct />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
|
||||
<DaftarSubGroupProduct searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function TambahSubGroupProduct() {
|
||||
await getCurrentUser();
|
||||
const groupProducts = await listGroupProductOptions();
|
||||
|
||||
return <TambahSubGroupProductButton groupProducts={groupProducts} />;
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { asc, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { amigoDb } from "@/db/amigo";
|
||||
import { barang, kedalaman_produk, strip } from "@/db/amigo/schema";
|
||||
import { PER_PAGE } from "@/lib/pagination";
|
||||
import { escapeLike } from "@/lib/utils";
|
||||
|
||||
export type GroupProduct = {
|
||||
kdStrip: string;
|
||||
namaStrip: string;
|
||||
murah1: number;
|
||||
murah2: number;
|
||||
sedang1: number;
|
||||
sedang2: number;
|
||||
mahal1: number;
|
||||
mahal2: number;
|
||||
benikMurah: number;
|
||||
benikSedang: number;
|
||||
benikMahal: number;
|
||||
kelBrg: string | null;
|
||||
kelJns: string;
|
||||
persenTiwul: number;
|
||||
};
|
||||
|
||||
export type GroupProductPage = {
|
||||
rows: GroupProduct[];
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
// kdStrip dibuat sistem, bukan isian form.
|
||||
export type GroupProductInput = Omit<GroupProduct, "kdStrip">;
|
||||
|
||||
function isKdStripExist(error: unknown): boolean {
|
||||
const e = error as {
|
||||
errno?: number;
|
||||
code?: string;
|
||||
cause?: { errno?: number; code?: string };
|
||||
};
|
||||
|
||||
return (
|
||||
e?.errno === 1062 ||
|
||||
e?.code === "ER_DUP_ENTRY" ||
|
||||
e?.cause?.errno === 1062 ||
|
||||
e?.cause?.code === "ER_DUP_ENTRY"
|
||||
);
|
||||
}
|
||||
|
||||
// Kode = 2 digit urut dari kode tertinggi ("09" -> "10"); semua kode existing numerik.
|
||||
// Sengaja otomatis, padahal PHP lama mengisinya manual (`ajaxstrip.php`).
|
||||
async function generateKodeStrip(): Promise<string> {
|
||||
const [row] = await amigoDb
|
||||
.select({ kdStrip: strip.kd_strip })
|
||||
.from(strip)
|
||||
.orderBy(desc(strip.kd_strip))
|
||||
.limit(1);
|
||||
|
||||
const urut = Number.parseInt(row?.kdStrip ?? "", 10);
|
||||
return String(Number.isNaN(urut) ? 0 : urut + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
export async function insertGroupProduct(
|
||||
input: GroupProductInput,
|
||||
): Promise<string> {
|
||||
// MyISAM tidak punya transaksi; baca max + insert bisa balapan dengan PHP lama,
|
||||
// jadi kalau kode sudah dipakai proses lain, ulangi baca max + insert.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const kdStrip = await generateKodeStrip();
|
||||
try {
|
||||
await amigoDb.insert(strip).values({
|
||||
kd_strip: kdStrip,
|
||||
nama_strip: input.namaStrip,
|
||||
murah1: input.murah1,
|
||||
murah2: input.murah2,
|
||||
sedang1: input.sedang1,
|
||||
sedang2: input.sedang2,
|
||||
mahal1: input.mahal1,
|
||||
mahal2: input.mahal2,
|
||||
benik_murah: input.benikMurah,
|
||||
benik_sedang: input.benikSedang,
|
||||
benik_mahal: input.benikMahal,
|
||||
kel_brg: input.kelBrg,
|
||||
kel_jns: input.kelJns,
|
||||
persen_tiwul: input.persenTiwul,
|
||||
});
|
||||
|
||||
return kdStrip;
|
||||
} catch (error) {
|
||||
if (!isKdStripExist(error) || attempt === 2) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Kode group product baru sedang dipakai proses lain");
|
||||
}
|
||||
|
||||
export async function updateGroupProduct(
|
||||
kdStrip: string,
|
||||
input: GroupProductInput,
|
||||
): Promise<boolean> {
|
||||
const [res] = await amigoDb
|
||||
.update(strip)
|
||||
.set({
|
||||
nama_strip: input.namaStrip,
|
||||
murah1: input.murah1,
|
||||
murah2: input.murah2,
|
||||
sedang1: input.sedang1,
|
||||
sedang2: input.sedang2,
|
||||
mahal1: input.mahal1,
|
||||
mahal2: input.mahal2,
|
||||
benik_murah: input.benikMurah,
|
||||
benik_sedang: input.benikSedang,
|
||||
benik_mahal: input.benikMahal,
|
||||
kel_brg: input.kelBrg,
|
||||
kel_jns: input.kelJns,
|
||||
persen_tiwul: input.persenTiwul,
|
||||
})
|
||||
.where(eq(strip.kd_strip, kdStrip));
|
||||
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
export type PemakaianGroupProduct = {
|
||||
barang: number;
|
||||
subGroup: number;
|
||||
};
|
||||
|
||||
export async function countPemakaianGroupProduct(
|
||||
kdStrip: string,
|
||||
): Promise<PemakaianGroupProduct> {
|
||||
const [[barangRow], [subGroupRow]] = await Promise.all([
|
||||
amigoDb
|
||||
.select({ total: count() })
|
||||
.from(barang)
|
||||
.where(eq(barang.kode_strip, kdStrip)),
|
||||
amigoDb
|
||||
.select({ total: count() })
|
||||
.from(kedalaman_produk)
|
||||
.where(eq(kedalaman_produk.kode_strip, kdStrip)),
|
||||
]);
|
||||
|
||||
return { barang: barangRow?.total ?? 0, subGroup: subGroupRow?.total ?? 0 };
|
||||
}
|
||||
|
||||
export async function deleteGroupProduct(kdStrip: string): Promise<boolean> {
|
||||
const [res] = await amigoDb.delete(strip).where(eq(strip.kd_strip, kdStrip));
|
||||
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
export type GroupProductOption = {
|
||||
kdStrip: string;
|
||||
namaStrip: string;
|
||||
};
|
||||
|
||||
// Untuk select di form sub group product; jumlah group sedikit, jadi tanpa pagination.
|
||||
// `cache`: dipanggil beberapa komponen di satu render, query cukup sekali per request.
|
||||
export const listGroupProductOptions = cache(
|
||||
async (): Promise<GroupProductOption[]> =>
|
||||
amigoDb
|
||||
.select({ kdStrip: strip.kd_strip, namaStrip: strip.nama_strip })
|
||||
.from(strip)
|
||||
.orderBy(asc(strip.kd_strip)),
|
||||
);
|
||||
|
||||
export async function listGroupProduct(
|
||||
search?: string,
|
||||
page = 1,
|
||||
): Promise<GroupProductPage> {
|
||||
const q = search?.trim();
|
||||
const pattern = q ? `%${escapeLike(q)}%` : "";
|
||||
|
||||
const rows = await amigoDb
|
||||
.select({
|
||||
kdStrip: strip.kd_strip,
|
||||
namaStrip: strip.nama_strip,
|
||||
murah1: strip.murah1,
|
||||
murah2: strip.murah2,
|
||||
sedang1: strip.sedang1,
|
||||
sedang2: strip.sedang2,
|
||||
mahal1: strip.mahal1,
|
||||
mahal2: strip.mahal2,
|
||||
benikMurah: strip.benik_murah,
|
||||
benikSedang: strip.benik_sedang,
|
||||
benikMahal: strip.benik_mahal,
|
||||
kelBrg: strip.kel_brg,
|
||||
kelJns: strip.kel_jns,
|
||||
persenTiwul: strip.persen_tiwul,
|
||||
})
|
||||
.from(strip)
|
||||
.where(
|
||||
q
|
||||
? or(like(strip.nama_strip, pattern), like(strip.kd_strip, pattern))
|
||||
: undefined,
|
||||
)
|
||||
.orderBy(asc(strip.kd_strip))
|
||||
.limit(PER_PAGE + 1)
|
||||
.offset((page - 1) * PER_PAGE);
|
||||
|
||||
return {
|
||||
rows: rows.slice(0, PER_PAGE),
|
||||
hasNext: rows.length > PER_PAGE,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import "server-only";
|
||||
|
||||
import { asc, eq, like } from "drizzle-orm";
|
||||
import { amigoDb } from "@/db/amigo";
|
||||
import { rak } from "@/db/amigo/schema";
|
||||
import { PER_PAGE } from "@/lib/pagination";
|
||||
import { escapeLike } from "@/lib/utils";
|
||||
|
||||
export type Rak = {
|
||||
idRak: number;
|
||||
namaRak: string | null;
|
||||
};
|
||||
|
||||
export type RakPage = {
|
||||
rows: Rak[];
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
export type RakInput = {
|
||||
namaRak: NonNullable<Rak["namaRak"]>;
|
||||
};
|
||||
|
||||
export async function insertRak(input: RakInput): Promise<number> {
|
||||
const [res] = await amigoDb.insert(rak).values({
|
||||
namaRak: input.namaRak,
|
||||
});
|
||||
|
||||
return res.insertId;
|
||||
}
|
||||
|
||||
export async function updateRak(
|
||||
idRak: number,
|
||||
input: RakInput,
|
||||
): Promise<boolean> {
|
||||
const [res] = await amigoDb
|
||||
.update(rak)
|
||||
.set({ namaRak: input.namaRak })
|
||||
.where(eq(rak.idRak, idRak));
|
||||
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function deleteRak(idRak: number): Promise<boolean> {
|
||||
const [res] = await amigoDb.delete(rak).where(eq(rak.idRak, idRak));
|
||||
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function listRak(search?: string, page = 1): Promise<RakPage> {
|
||||
const q = search?.trim();
|
||||
const pattern = q ? `%${escapeLike(q)}%` : "";
|
||||
|
||||
const rows = await amigoDb
|
||||
.select({
|
||||
idRak: rak.idRak,
|
||||
namaRak: rak.namaRak,
|
||||
})
|
||||
.from(rak)
|
||||
.where(q ? like(rak.namaRak, pattern) : undefined)
|
||||
.orderBy(asc(rak.namaRak), asc(rak.idRak))
|
||||
.limit(PER_PAGE + 1)
|
||||
.offset((page - 1) * PER_PAGE);
|
||||
|
||||
return {
|
||||
rows: rows.slice(0, PER_PAGE),
|
||||
hasNext: rows.length > PER_PAGE,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, count, eq, like, or } from "drizzle-orm";
|
||||
import { amigoDb } from "@/db/amigo";
|
||||
import { barang, kedalaman_produk, strip } from "@/db/amigo/schema";
|
||||
import { PER_PAGE } from "@/lib/pagination";
|
||||
import { escapeLike } from "@/lib/utils";
|
||||
|
||||
export type SubGroupProduct = {
|
||||
id: number;
|
||||
kodeKedProduk: string;
|
||||
kedProduk: string;
|
||||
kodeStrip: string | null;
|
||||
namaStrip: string | null;
|
||||
};
|
||||
|
||||
export type SubGroupProductPage = {
|
||||
rows: SubGroupProduct[];
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
// Identitas sub group = pasangan (kodeStrip, kodeKedProduk); `id` hanya primary key teknis.
|
||||
export type SubGroupProductInput = {
|
||||
kodeKedProduk: string;
|
||||
kedProduk: string;
|
||||
kodeStrip: string;
|
||||
};
|
||||
|
||||
// Kode tidak ikut diubah: barang menunjuk sub group lewat pasangan kode, jadi mengganti kode
|
||||
// membuat barang yatim. PHP lama juga hanya mengubah nama (`ajaxdlmpdk.php`).
|
||||
export type SubGroupProductUpdate = Pick<SubGroupProductInput, "kedProduk">;
|
||||
|
||||
// Dilempar untuk isian yang ditolak aturan bisnis (kode dobel, group tidak ada), supaya action
|
||||
// bisa membedakannya dari error DB lain yang perlu di-log.
|
||||
export class SubGroupProductDitolakError extends Error {}
|
||||
|
||||
async function findSubGroupProduct(
|
||||
kodeStrip: string,
|
||||
kodeKedProduk: string,
|
||||
): Promise<{ id: number } | undefined> {
|
||||
const [row] = await amigoDb
|
||||
.select({ id: kedalaman_produk.id })
|
||||
.from(kedalaman_produk)
|
||||
.where(
|
||||
and(
|
||||
eq(kedalaman_produk.kode_strip, kodeStrip),
|
||||
eq(kedalaman_produk.kode_ked_produk, kodeKedProduk),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function isGroupProductExist(kodeStrip: string): Promise<boolean> {
|
||||
const [row] = await amigoDb
|
||||
.select({ kdStrip: strip.kd_strip })
|
||||
.from(strip)
|
||||
.where(eq(strip.kd_strip, kodeStrip))
|
||||
.limit(1);
|
||||
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
export async function insertSubGroupProduct(
|
||||
input: SubGroupProductInput,
|
||||
): Promise<number> {
|
||||
if (!(await isGroupProductExist(input.kodeStrip))) {
|
||||
throw new SubGroupProductDitolakError(
|
||||
`Group product ${input.kodeStrip} tidak ditemukan, pilih ulang group product`,
|
||||
);
|
||||
}
|
||||
|
||||
// Tidak ada unique index di DB (MyISAM), jadi cek dobel manual sebelum insert.
|
||||
if (await findSubGroupProduct(input.kodeStrip, input.kodeKedProduk)) {
|
||||
throw new SubGroupProductDitolakError(
|
||||
`Kode ${input.kodeKedProduk} sudah dipakai di group product ${input.kodeStrip}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [res] = await amigoDb.insert(kedalaman_produk).values({
|
||||
kode_ked_produk: input.kodeKedProduk,
|
||||
ked_produk: input.kedProduk,
|
||||
kode_strip: input.kodeStrip,
|
||||
});
|
||||
|
||||
return res.insertId;
|
||||
}
|
||||
|
||||
export async function updateSubGroupProduct(
|
||||
id: number,
|
||||
input: SubGroupProductUpdate,
|
||||
): Promise<boolean> {
|
||||
const [res] = await amigoDb
|
||||
.update(kedalaman_produk)
|
||||
.set({ ked_produk: input.kedProduk })
|
||||
.where(eq(kedalaman_produk.id, id));
|
||||
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
// `barang` menunjuk sub group lewat pasangan (kode_strip, ked_produk), bukan lewat id.
|
||||
export async function countPemakaianSubGroupProduct(
|
||||
id: number,
|
||||
): Promise<number> {
|
||||
const [row] = await amigoDb
|
||||
.select({ total: count() })
|
||||
.from(barang)
|
||||
.innerJoin(
|
||||
kedalaman_produk,
|
||||
and(
|
||||
eq(barang.kode_strip, kedalaman_produk.kode_strip),
|
||||
eq(barang.ked_produk, kedalaman_produk.kode_ked_produk),
|
||||
),
|
||||
)
|
||||
.where(eq(kedalaman_produk.id, id));
|
||||
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
export async function deleteSubGroupProduct(id: number): Promise<boolean> {
|
||||
const [res] = await amigoDb
|
||||
.delete(kedalaman_produk)
|
||||
.where(eq(kedalaman_produk.id, id));
|
||||
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function listSubGroupProduct(
|
||||
search?: string,
|
||||
page = 1,
|
||||
): Promise<SubGroupProductPage> {
|
||||
const q = search?.trim();
|
||||
const pattern = q ? `%${escapeLike(q)}%` : "";
|
||||
|
||||
const rows = await amigoDb
|
||||
.select({
|
||||
id: kedalaman_produk.id,
|
||||
kodeKedProduk: kedalaman_produk.kode_ked_produk,
|
||||
kedProduk: kedalaman_produk.ked_produk,
|
||||
kodeStrip: kedalaman_produk.kode_strip,
|
||||
namaStrip: strip.nama_strip,
|
||||
})
|
||||
.from(kedalaman_produk)
|
||||
// leftJoin: ada data lama yang menunjuk kode group yang sudah tidak ada.
|
||||
.leftJoin(strip, eq(kedalaman_produk.kode_strip, strip.kd_strip))
|
||||
.where(
|
||||
q
|
||||
? or(
|
||||
like(kedalaman_produk.ked_produk, pattern),
|
||||
like(kedalaman_produk.kode_strip, pattern),
|
||||
like(kedalaman_produk.kode_ked_produk, pattern),
|
||||
like(strip.nama_strip, pattern),
|
||||
)
|
||||
: undefined,
|
||||
)
|
||||
// id sebagai pengurut terakhir: urutan antar-query tetap stabil di batas halaman.
|
||||
.orderBy(
|
||||
asc(kedalaman_produk.kode_strip),
|
||||
asc(kedalaman_produk.kode_ked_produk),
|
||||
asc(kedalaman_produk.id),
|
||||
)
|
||||
.limit(PER_PAGE + 1)
|
||||
.offset((page - 1) * PER_PAGE);
|
||||
|
||||
return {
|
||||
rows: rows.slice(0, PER_PAGE),
|
||||
hasNext: rows.length > PER_PAGE,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
export type FormState<F extends string = string> = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Partial<Record<F, string[]>>;
|
||||
values?: Partial<Record<F, string>>;
|
||||
};
|
||||
|
||||
export type DeleteState = { ok: boolean; error?: string };
|
||||
|
||||
export function logActionError(
|
||||
action: string,
|
||||
context: Record<string, unknown>,
|
||||
error: unknown,
|
||||
): void {
|
||||
console.error(`[${action}] gagal`, context, error);
|
||||
}
|
||||
Loading…
Reference in New Issue