Compare commits
5 Commits
770ecbd204
...
76fda6d1c4
| Author | SHA1 | Date |
|---|---|---|
|
|
76fda6d1c4 | 4 hours ago |
|
|
3002e55bc4 | 4 hours ago |
|
|
22d51475a7 | 6 hours ago |
|
|
5abf8a7e0c | 23 hours ago |
|
|
5c89d5714f | 1 day 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 { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||||
|
import { hapusPelanggan } from "../actions";
|
||||||
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";
|
|
||||||
|
|
||||||
type HapusPelangganButtonProps = {
|
type HapusPelangganButtonProps = {
|
||||||
kdCustomer: string;
|
kdCustomer: string;
|
||||||
nama: string;
|
nama: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial: HapusPelangganState = { ok: false };
|
|
||||||
|
|
||||||
export function HapusPelangganButton({
|
export function HapusPelangganButton({
|
||||||
kdCustomer,
|
kdCustomer,
|
||||||
nama,
|
nama,
|
||||||
}: HapusPelangganButtonProps) {
|
}: 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 (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
<HapusMasterButton
|
||||||
<AlertDialogTrigger
|
id={kdCustomer}
|
||||||
render={
|
idName="kdCustomer"
|
||||||
<Button
|
nama={nama}
|
||||||
variant="ghost"
|
entityLabel="pelanggan"
|
||||||
size="icon-sm"
|
action={hapusPelanggan}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,191 +1,49 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useState } from "react";
|
|
||||||
import { Loader2Icon } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
MasterSheetForm,
|
||||||
SheetContent,
|
type MasterField,
|
||||||
SheetDescription,
|
} from "@/app/(dashboard)/master/_components/master-sheet-form";
|
||||||
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";
|
|
||||||
import type { Pelanggan } from "@/data/pelanggan";
|
import type { Pelanggan } from "@/data/pelanggan";
|
||||||
|
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
|
||||||
|
|
||||||
type PelangganFormProps = {
|
type PelangganFormProps = {
|
||||||
pelanggan?: Pelanggan;
|
pelanggan?: Pelanggan;
|
||||||
trigger: React.ReactElement;
|
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 FieldName = keyof NonNullable<SimpanPelangganState["values"]>;
|
||||||
|
|
||||||
type FormInputProps = Omit<
|
const fields: MasterField<FieldName>[] = [
|
||||||
React.ComponentProps<"input">,
|
{ name: "nama", label: "Nama", required: true },
|
||||||
"name" | "defaultValue"
|
{ name: "alias", label: "Alias" },
|
||||||
> & {
|
{ name: "ktp", label: "No. KTP" },
|
||||||
name: FieldName;
|
{ name: "alamat", label: "Alamat", required: true },
|
||||||
label: string;
|
{ name: "wilayah", label: "Wilayah" },
|
||||||
state: SimpanPelangganState;
|
{ name: "telp", label: "Telepon" },
|
||||||
defaultValue?: string | number | null;
|
{ name: "ponsel", label: "Ponsel" },
|
||||||
};
|
{ name: "email", label: "Email", type: "email" },
|
||||||
|
{ name: "jenisCustomer", label: "Jenis Customer" },
|
||||||
// Isian dari server (state.values) didahulukan supaya ketikan user tidak hilang
|
{ name: "upline", label: "Upline" },
|
||||||
// saat validasi gagal; kalau belum ada, pakai data pelanggan yang sedang diedit.
|
{ name: "btsBon", label: "Batas Bon", type: "number" },
|
||||||
function FormInput({
|
];
|
||||||
name,
|
|
||||||
label,
|
|
||||||
state,
|
|
||||||
defaultValue,
|
|
||||||
...props
|
|
||||||
}: FormInputProps) {
|
|
||||||
const errors = state.fieldErrors?.[name];
|
|
||||||
|
|
||||||
|
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={!!errors}>
|
<MasterSheetForm
|
||||||
<FieldLabel htmlFor={name}>{label}</FieldLabel>
|
action={simpanPelanggan}
|
||||||
<Input
|
fields={fields}
|
||||||
id={name}
|
defaultValues={pelanggan}
|
||||||
name={name}
|
hiddenFields={
|
||||||
defaultValue={state.values?.[name] ?? defaultValue ?? ""}
|
pelanggan ? { kdCustomer: pelanggan.kdCustomer } : undefined
|
||||||
aria-invalid={!!errors}
|
}
|
||||||
{...props}
|
title={pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
|
||||||
|
description={
|
||||||
|
pelanggan
|
||||||
|
? `Kode: ${pelanggan.kdCustomer}`
|
||||||
|
: "Isi detail pelanggan baru"
|
||||||
|
}
|
||||||
|
trigger={trigger}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={errors?.map((message) => ({ message }))} />
|
|
||||||
</Field>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
|
||||||
|
import { hapusSatuanBarang } from "../actions";
|
||||||
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";
|
|
||||||
|
|
||||||
type HapusSatuanBarangButtonProps = {
|
type HapusSatuanBarangButtonProps = {
|
||||||
idSatuanBarang: number;
|
idSatuanBarang: number;
|
||||||
nama: string;
|
nama: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial: HapusSatuanBarangState = { ok: false };
|
|
||||||
|
|
||||||
export function HapusSatuanBarangButton({
|
export function HapusSatuanBarangButton({
|
||||||
idSatuanBarang,
|
idSatuanBarang,
|
||||||
nama,
|
nama,
|
||||||
}: HapusSatuanBarangButtonProps) {
|
}: 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 (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
<HapusMasterButton
|
||||||
<AlertDialogTrigger
|
id={idSatuanBarang}
|
||||||
render={
|
idName="idSatuanBarang"
|
||||||
<Button
|
nama={nama}
|
||||||
variant="ghost"
|
entityLabel="satuan"
|
||||||
size="icon-sm"
|
action={hapusSatuanBarang}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,96 +1,35 @@
|
|||||||
"use client";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { useActionState, useState } from "react";
|
|
||||||
import { Loader2Icon } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
MasterSheetForm,
|
||||||
SheetContent,
|
type MasterField,
|
||||||
SheetDescription,
|
} from "@/app/(dashboard)/master/_components/master-sheet-form";
|
||||||
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 { SatuanBarang } from "@/data/satuan-barang";
|
import type { SatuanBarang } from "@/data/satuan-barang";
|
||||||
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
|
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
|
||||||
|
|
||||||
type SatuanBarangFormProps = {
|
type SatuanBarangFormProps = {
|
||||||
satuan?: SatuanBarang;
|
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 fields: MasterField<FieldName>[] = [
|
||||||
const [open, setOpen] = useState(false);
|
{ name: "namaSatuanBarang", label: "Nama Satuan", required: true },
|
||||||
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;
|
|
||||||
|
|
||||||
|
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<MasterSheetForm
|
||||||
<SheetTrigger render={trigger} />
|
action={simpanSatuanBarang}
|
||||||
<SheetContent>
|
fields={fields}
|
||||||
<SheetHeader>
|
defaultValues={satuan && { namaSatuanBarang: satuan.namaSatuanBarang }}
|
||||||
<SheetTitle>
|
hiddenFields={satuan && { idSatuanBarang: satuan.idSatuanBarang }}
|
||||||
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
|
title={satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
|
||||||
</SheetTitle>
|
description={
|
||||||
<SheetDescription>
|
satuan ? `ID: ${satuan.idSatuanBarang}` : "Isi nama satuan barang baru"
|
||||||
{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}
|
trigger={trigger}
|
||||||
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,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