Compare commits

..

5 Commits

Author SHA1 Message Date
Deardo Satria 76fda6d1c4 Add Indonesian zod messages to pelanggan and tidy rak imports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 hours ago
Deardo Satria 3002e55bc4 Add master group product and sub group product features
Deleting a group or sub group still used by barang (or a group still
used by sub groups) is refused, because PHP queries inner join on these
codes and would silently hide the orphaned barang. Editing a sub group
only changes its name, matching ajaxdlmpdk.php; changing the code pair
would orphan barang the same way.

Group codes are generated automatically, unlike the old PHP form where
they were typed manually.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 hours ago
Deardo Satria 22d51475a7 Change form input label 4 hours ago
Deardo Satria 5abf8a7e0c Unify master sheet form and move shared master components
- Move cari-master, master-sheet-form, and hapus-master-button into
  app/(dashboard)/master/_components since only master pages use them
- Merge MasterSheetForm and MasterMultiFieldSheetForm into one form that
  accepts multiple fields; forms needing select/textarea stay custom
- Drop the initial prop and hardcode { ok: false } as the action state seed
- Blur number inputs on wheel to prevent accidental value changes
- Update CLAUDE.md to describe the new layout and rules
21 hours ago
Deardo Satria 5c89d5714f Add master rak feature 23 hours ago

@ -66,11 +66,14 @@ app/
page.tsx Server Component: ambil data lewat data/, render
actions.ts Server Actions fitur ini ('use server')
_components/ komponen khusus fitur ini
(dashboard)/master/_components/ komponen bersama semua halaman master
cari-master.tsx kotak pencarian master (prop basePath + placeholder)
master-sheet-form.tsx form sheet master, input teks saja (action, fields, defaultValues)
hapus-master-button.tsx dialog konfirmasi hapus master (id, idName, entityLabel)
api/auth/[...all]/route.ts handler Better Auth (satu-satunya route handler yang wajar)
components/
ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu)
<nama>.tsx komponen yang dipakai lintas fitur
cari-master.tsx kotak pencarian master (prop basePath + placeholder)
pagination.tsx tombol halaman daftar (prop page, hasNext, hrefPage)
data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only'
db/
@ -80,6 +83,7 @@ lib/
auth.ts config Better Auth (server)
auth-client.ts client Better Auth
pagination.ts PER_PAGE, parse `?page=`, dan href halaman
server-action.ts tipe state action (FormState/DeleteState) dan logActionError
utils.ts helper umum: cn (dibuat shadcn) dan escapeLike untuk LIKE
drizzle/ file migrasi Postgres (generated, ikut di-commit)
proxy.ts pengganti middleware di Next 16; hanya cek ringan (redirect)
@ -113,6 +117,10 @@ proxy.ts pengganti middleware di Next 16; hanya cek rin
Ke UI hanya dikirim pesan bahasa Indonesia yang jelas dan bisa ditindaklanjuti user
(mis. "Kode pelanggan sudah dipakai, coba simpan ulang"), tidak pernah pesan error DB mentah.
- Form di client memakai `useActionState` untuk menampilkan error/pending dari action.
- Form master yang isinya input teks saja memakai `MasterSheetForm`, dan dialog hapus memakai
`HapusMasterButton`; tipe state action dan log error dari `lib/server-action.ts`. Form yang
butuh select, textarea, atau lookup ditulis sendiri di `_components/` fiturnya; jangan
menambah opsi ke config `MasterSheetForm`.
- File di `data/` diawali `import "server-only"` supaya tidak bisa ter-import ke client
component. `db/` dan `lib/auth.ts` sengaja tanpa itu karena dibaca CLI `auth` di luar Next.js
(package `server-only` tidak di-install; Next.js menanganinya sendiri). Hanya `data/`, `db/`,

@ -22,6 +22,9 @@ export const navItems: NavItem[] = [
items: [
{ title: "Satuan Barang", href: "/master/satuan-barang" },
{ title: "Data Pelanggan", href: "/master/pelanggan" },
{ title: "Rak", href: "/master/rak" },
{ title: "Group Product", href: "/master/group-product" },
{ title: "Sub Group Product", href: "/master/sub-group-product" },
],
},
];

@ -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}
/>
);
}

@ -9,30 +9,43 @@ import {
updatePelanggan,
type PelangganInput,
} from "@/data/pelanggan";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
type PelangganField = keyof typeof pelangganSchema.shape;
export type SimpanPelangganState = {
ok: boolean;
error?: string;
fieldErrors?: Partial<Record<PelangganField, string[]>>;
values?: Partial<Record<PelangganField, string>>;
export type SimpanPelangganState = FormState<PelangganField> & {
kdCustomer?: string;
};
export type HapusPelangganState = { ok: boolean; error?: string };
export type HapusPelangganState = DeleteState;
const pelangganSchema = z.object({
nama: z.string().trim().min(1, "Nama wajib diisi").max(100),
alias: z.string().trim().max(11),
ktp: z.string().trim().max(16),
alamat: z.string().trim().min(1, "Alamat wajib diisi").max(100),
wilayah: z.string().trim().max(200),
telp: z.string().trim().max(15),
ponsel: z.string().trim().max(15),
email: z.string().trim().max(30),
jenisCustomer: z.string().trim().max(1),
upline: z.string().trim().max(11),
btsBon: z.coerce.number().int().min(0).max(2_000_000_000),
nama: z
.string()
.trim()
.min(1, "Nama wajib diisi")
.max(100, "Nama maksimal 100 karakter"),
alias: z.string().trim().max(11, "Alias maksimal 11 karakter"),
ktp: z.string().trim().max(16, "No. KTP maksimal 16 karakter"),
alamat: z
.string()
.trim()
.min(1, "Alamat wajib diisi")
.max(100, "Alamat maksimal 100 karakter"),
wilayah: z.string().trim().max(200, "Wilayah maksimal 200 karakter"),
telp: z.string().trim().max(15, "Telepon maksimal 15 karakter"),
ponsel: z.string().trim().max(15, "Ponsel maksimal 15 karakter"),
email: z.string().trim().max(30, "Email maksimal 30 karakter"),
jenisCustomer: z.string().trim().max(1, "Jenis customer maksimal 1 karakter"),
upline: z.string().trim().max(11, "Upline maksimal 11 karakter"),
btsBon: 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"),
});
function emptyStringToNull(value: string): string | null {
@ -84,8 +97,8 @@ export async function simpanPelanggan(
revalidatePath("/master/pelanggan");
return { ok: true, kdCustomer };
} catch (error) {
console.error(
"[simpanPelanggan] gagal",
logActionError(
"simpanPelanggan",
{ userId: user.id, kdCustomer: kdCustomer || "(baru)", nama: input.nama },
error,
);
@ -116,11 +129,7 @@ export async function hapusPelanggan(
revalidatePath("/master/pelanggan");
return { ok: true };
} catch (error) {
console.error(
"[hapusPelanggan] gagal",
{ userId: user.id, kdCustomer },
error,
);
logActionError("hapusPelanggan", { userId: user.id, kdCustomer }, error);
return {
ok: false,
error: "Gagal menghapus pelanggan, coba hapus ulang",

@ -1,6 +1,6 @@
import { Suspense } from "react";
import { CariMaster } from "@/components/cari-master";
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
import { DaftarPelanggan } from "./_components/daftar-pelanggan";
import { TambahPelangganButton } from "./_components/tambah-pelanggan-button";

@ -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}
/>
);
}

@ -10,16 +10,14 @@ import {
updateSatuanBarang,
type SatuanBarangInput,
} from "@/data/satuan-barang";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
export type SimpanSatuanBarangState = {
ok: boolean;
error?: string;
fieldErrors?: { namaSatuanBarang?: string[] };
values?: { namaSatuanBarang?: string };
idSatuanBarang?: number;
};
export type HapusSatuanBarangState = { ok: boolean; error?: string };
export type SimpanSatuanBarangState = FormState<"namaSatuanBarang">;
export type HapusSatuanBarangState = DeleteState;
const satuanBarangSchema = z.object({
namaSatuanBarang: z
@ -61,15 +59,15 @@ export async function simpanSatuanBarang(
return { ok: false, error: "Satuan tidak ditemukan", values };
}
revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: id };
return { ok: true };
}
const idBaru = await insertSatuanBarang(input);
await insertSatuanBarang(input);
revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: idBaru };
return { ok: true };
} catch (error) {
console.error(
"[simpanSatuanBarang] gagal",
logActionError(
"simpanSatuanBarang",
{
userId: user.id,
idSatuanBarang: id ?? "(baru)",
@ -112,7 +110,7 @@ export async function hapusSatuanBarang(
revalidatePath("/master/satuan-barang");
return { ok: true };
} catch (error) {
console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error);
logActionError("hapusSatuanBarang", { userId: user.id, id }, error);
return {
ok: false,
error: "Gagal menghapus satuan, coba hapus ulang",

@ -2,7 +2,7 @@ import { Suspense } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CariMaster } from "@/components/cari-master";
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
import { DaftarSatuanBarang } from "./_components/daftar-satuan-barang";
import { SatuanBarangForm } from "./_components/satuan-barang-form";

@ -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…
Cancel
Save