Compare commits

..

No commits in common. '76fda6d1c428cca15c85cfac9dc1ccb4c9fc0a21' and '770ecbd20447a3d73e55b673391af412bef98ffc' have entirely different histories.

@ -66,14 +66,11 @@ app/
page.tsx Server Component: ambil data lewat data/, render page.tsx Server Component: ambil data lewat data/, render
actions.ts Server Actions fitur ini ('use server') actions.ts Server Actions fitur ini ('use server')
_components/ komponen khusus fitur ini _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) api/auth/[...all]/route.ts handler Better Auth (satu-satunya route handler yang wajar)
components/ components/
ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu) ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu)
<nama>.tsx komponen yang dipakai lintas fitur <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) pagination.tsx tombol halaman daftar (prop page, hasNext, hrefPage)
data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only' data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only'
db/ db/
@ -83,7 +80,6 @@ lib/
auth.ts config Better Auth (server) auth.ts config Better Auth (server)
auth-client.ts client Better Auth auth-client.ts client Better Auth
pagination.ts PER_PAGE, parse `?page=`, dan href halaman 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 utils.ts helper umum: cn (dibuat shadcn) dan escapeLike untuk LIKE
drizzle/ file migrasi Postgres (generated, ikut di-commit) drizzle/ file migrasi Postgres (generated, ikut di-commit)
proxy.ts pengganti middleware di Next 16; hanya cek ringan (redirect) proxy.ts pengganti middleware di Next 16; hanya cek ringan (redirect)
@ -117,10 +113,6 @@ proxy.ts pengganti middleware di Next 16; hanya cek rin
Ke UI hanya dikirim pesan bahasa Indonesia yang jelas dan bisa ditindaklanjuti user 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. (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 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 - 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 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/`, (package `server-only` tidak di-install; Next.js menanganinya sendiri). Hanya `data/`, `db/`,

@ -22,9 +22,6 @@ export const navItems: NavItem[] = [
items: [ items: [
{ title: "Satuan Barang", href: "/master/satuan-barang" }, { title: "Satuan Barang", href: "/master/satuan-barang" },
{ title: "Data Pelanggan", href: "/master/pelanggan" }, { 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" },
], ],
}, },
]; ];

@ -1,86 +0,0 @@
"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>
);
}

@ -1,124 +0,0 @@
"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>
);
}

@ -1,100 +0,0 @@
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)}
/>
</>
);
}

@ -1,56 +0,0 @@
"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}
/>
);
}

@ -1,22 +0,0 @@
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}
/>
);
}

@ -1,19 +0,0 @@
"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>
}
/>
);
}

@ -1,165 +0,0 @@
"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",
};
}
}

@ -1,31 +0,0 @@
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,22 +1,80 @@
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button"; "use client";
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 (
<HapusMasterButton <AlertDialog open={open} onOpenChange={setOpen}>
id={kdCustomer} <AlertDialogTrigger
idName="kdCustomer" render={
nama={nama} <Button
entityLabel="pelanggan" variant="ghost"
action={hapusPelanggan} 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>
); );
} }

@ -1,49 +1,191 @@
"use client"; "use client";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
MasterSheetForm, Sheet,
type MasterField, SheetContent,
} from "@/app/(dashboard)/master/_components/master-sheet-form"; SheetDescription,
import type { Pelanggan } from "@/data/pelanggan"; 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 { simpanPelanggan, type SimpanPelangganState } from "../actions";
import type { Pelanggan } from "@/data/pelanggan";
type PelangganFormProps = { type PelangganFormProps = {
pelanggan?: Pelanggan; pelanggan?: Pelanggan;
trigger: React.ReactElement; trigger: React.ReactElement;
}; };
type FieldName = keyof NonNullable<SimpanPelangganState["values"]>; const initial: SimpanPelangganState = { ok: false };
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) { 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 ( return (
<MasterSheetForm <Sheet open={open} onOpenChange={setOpen}>
action={simpanPelanggan} <SheetTrigger render={trigger} />
fields={fields} <SheetContent>
defaultValues={pelanggan} <SheetHeader>
hiddenFields={ <SheetTitle>
pelanggan ? { kdCustomer: pelanggan.kdCustomer } : undefined {pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
} </SheetTitle>
title={pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"} <SheetDescription>
description={ {pelanggan
pelanggan
? `Kode: ${pelanggan.kdCustomer}` ? `Kode: ${pelanggan.kdCustomer}`
: "Isi detail pelanggan baru" : "Isi detail pelanggan baru"}
} </SheetDescription>
trigger={trigger} </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];
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>
); );
} }

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

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

@ -1,85 +0,0 @@
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)}
/>
</>
);
}

@ -1,19 +0,0 @@
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}
/>
);
}

@ -1,33 +0,0 @@
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}
/>
);
}

@ -1,105 +0,0 @@
"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",
};
}
}

@ -1,37 +0,0 @@
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,22 +1,82 @@
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button"; "use client";
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 (
<HapusMasterButton <AlertDialog open={open} onOpenChange={setOpen}>
id={idSatuanBarang} <AlertDialogTrigger
idName="idSatuanBarang" render={
nama={nama} <Button
entityLabel="satuan" variant="ghost"
action={hapusSatuanBarang} 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>
); );
} }

@ -1,35 +1,96 @@
import type { ReactElement } from "react"; "use client";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
MasterSheetForm, Sheet,
type MasterField, SheetContent,
} from "@/app/(dashboard)/master/_components/master-sheet-form"; 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 { 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: ReactElement; trigger: React.ReactElement;
}; };
type FieldName = keyof NonNullable<SimpanSatuanBarangState["values"]>; const initial: SimpanSatuanBarangState = { ok: false };
const fields: MasterField<FieldName>[] = [
{ name: "namaSatuanBarang", label: "Nama Satuan", required: true },
];
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) { 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;
return ( return (
<MasterSheetForm <Sheet open={open} onOpenChange={setOpen}>
action={simpanSatuanBarang} <SheetTrigger render={trigger} />
fields={fields} <SheetContent>
defaultValues={satuan && { namaSatuanBarang: satuan.namaSatuanBarang }} <SheetHeader>
hiddenFields={satuan && { idSatuanBarang: satuan.idSatuanBarang }} <SheetTitle>
title={satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"} {satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
description={ </SheetTitle>
satuan ? `ID: ${satuan.idSatuanBarang}` : "Isi nama satuan barang baru" <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 ?? ""
} }
trigger={trigger} 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>
); );
} }

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

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

@ -1,98 +0,0 @@
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)}
/>
</>
);
}

@ -1,22 +0,0 @@
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}
/>
);
}

@ -1,173 +0,0 @@
"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>
);
}

@ -1,25 +0,0 @@
"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>
}
/>
);
}

@ -1,148 +0,0 @@
"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",
};
}
}

@ -1,42 +0,0 @@
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} />;
}

@ -1,204 +0,0 @@
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,
};
}

@ -1,68 +0,0 @@
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,
};
}

@ -1,170 +0,0 @@
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,
};
}

@ -1,16 +0,0 @@
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