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>
4 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>
4 hours ago
Deardo Satria 22d51475a7 Change form input label 6 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
23 hours ago
Deardo Satria 5c89d5714f Add master rak feature 1 day ago

@ -66,11 +66,14 @@ 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/
@ -80,6 +83,7 @@ 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)
@ -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 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,6 +22,9 @@ 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" },
], ],
}, },
]; ];

@ -0,0 +1,86 @@
"use client";
import { useActionState, useState } from "react";
import { Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { DeleteState } from "@/lib/server-action";
type HapusMasterButtonProps = {
id: number | string;
idName: string;
nama: string;
entityLabel: string;
action: (prevState: DeleteState, formData: FormData) => Promise<DeleteState>;
};
export function HapusMasterButton({
id,
idName,
nama,
entityLabel,
action,
}: HapusMasterButtonProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: DeleteState, formData: FormData) => {
const result = await action(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
{ ok: false },
);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
title={`Hapus ${entityLabel}`}
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Hapus {nama || `${entityLabel} ${id}`}?
</AlertDialogTitle>
<AlertDialogDescription>
Data {entityLabel} {id} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name={idName} value={id} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
}

@ -0,0 +1,124 @@
"use client";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import type { FormState } from "@/lib/server-action";
// Hanya untuk input teks biasa. Form yang butuh select, textarea, atau lookup ditulis sendiri
// di _components fitur itu, jangan menambah opsi ke config ini.
export type MasterField<F extends string = string> = {
name: F;
label: string;
type?: React.HTMLInputTypeAttribute;
required?: boolean;
};
type MasterSheetFormProps<F extends string> = {
action: (
prevState: FormState<F>,
formData: FormData,
) => Promise<FormState<F>>;
fields: readonly MasterField<F>[];
defaultValues?: Partial<Record<F, string | number | null>>;
hiddenFields?: Record<string, string | number>;
title: string;
description: string;
trigger: React.ReactElement;
};
export function MasterSheetForm<F extends string>({
action,
fields,
defaultValues,
hiddenFields,
title,
description,
trigger,
}: MasterSheetFormProps<F>) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: FormState<F>, formData: FormData) => {
const result = await action(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
{ ok: false },
);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{hiddenFields &&
Object.entries(hiddenFields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
{fields.map((field) => {
const errors = state.fieldErrors?.[field.name];
return (
<Field key={field.name} data-invalid={!!errors}>
<FieldLabel htmlFor={field.name}>
{field.label}
{field.required && " *"}
</FieldLabel>
<Input
id={field.name}
name={field.name}
type={field.type}
onWheel={
field.type === "number"
? (e) => e.currentTarget.blur()
: undefined
}
defaultValue={
state.values?.[field.name] ??
defaultValues?.[field.name] ??
""
}
aria-invalid={!!errors}
required={field.required}
/>
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
);
})}
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

@ -0,0 +1,100 @@
import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/pagination";
import { getCurrentUser } from "@/data/auth";
import { listGroupProduct } from "@/data/group-product";
import { halamanHref, parsePage } from "@/lib/pagination";
import { HapusGroupProductButton } from "./hapus-group-product-button";
import { GroupProductForm } from "./group-product-form";
export async function DaftarGroupProduct({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
await getCurrentUser();
const { search, page: pageParam } = await searchParams;
const page = parsePage(pageParam);
const { rows, hasNext } = await listGroupProduct(search, page);
return (
<>
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs tracking-wide text-muted-foreground uppercase">
<th className="px-3 py-2.5 font-medium">Kode</th>
<th className="px-3 py-2.5 font-medium">Nama</th>
<th className="px-3 py-2.5 font-medium">Kategori</th>
<th className="px-3 py-2.5 text-right font-medium">
Persen Thiwul
</th>
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((g) => (
<tr
key={g.kdStrip}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap text-muted-foreground">
{g.kdStrip}
</td>
<td className="px-3 py-2 font-medium">{g.namaStrip}</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{g.kelJns || "-"}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{g.persenTiwul}
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<GroupProductForm
groupProduct={g}
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Edit group product"
>
<PencilIcon />
<span className="sr-only">Edit</span>
</Button>
}
/>
<HapusGroupProductButton
kdStrip={g.kdStrip}
nama={g.namaStrip}
/>
</div>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td
colSpan={5}
className="px-3 py-10 text-center text-muted-foreground"
>
{page > 1
? "Halaman ini kosong"
: "Tidak ada group product"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<Pagination
page={page}
hasNext={hasNext}
hrefPage={(p) => halamanHref("/master/group-product", p, search)}
/>
</>
);
}

@ -0,0 +1,56 @@
"use client";
import {
MasterSheetForm,
type MasterField,
} from "@/app/(dashboard)/master/_components/master-sheet-form";
import type { GroupProduct } from "@/data/group-product";
import { simpanGroupProduct, type SimpanGroupProductState } from "../actions";
type GroupProductFormProps = {
groupProduct?: GroupProduct;
trigger: React.ReactElement;
};
type FieldName = keyof NonNullable<SimpanGroupProductState["values"]>;
const fields: MasterField<FieldName>[] = [
{ name: "namaStrip", label: "Nama Group Product", required: true },
{ name: "murah1", label: "Batas Bawah Murah", type: "number" },
{ name: "murah2", label: "Batas Atas Murah", type: "number" },
{ name: "sedang1", label: "Batas Bawah Sedang", type: "number" },
{ name: "sedang2", label: "Batas Atas Sedang", type: "number" },
{ name: "mahal1", label: "Batas Bawah Mahal", type: "number" },
{ name: "mahal2", label: "Batas Atas Mahal", type: "number" },
{ name: "benikMurah", label: "Bonus Benik Murah", type: "number" },
{ name: "benikSedang", label: "Bonus Benik Sedang", type: "number" },
{ name: "benikMahal", label: "Bonus Benik Mahal", type: "number" },
{ name: "kelBrg", label: "Kelompok Barang" },
{ name: "kelJns", label: "Kategori (P, W, A, S)" },
{ name: "persenTiwul", label: "Persen Thiwul", type: "number" },
];
export function GroupProductForm({
groupProduct,
trigger,
}: GroupProductFormProps) {
return (
<MasterSheetForm
action={simpanGroupProduct}
fields={fields}
defaultValues={groupProduct}
hiddenFields={
groupProduct ? { kdStrip: groupProduct.kdStrip } : undefined
}
title={
groupProduct ? `Edit ${groupProduct.namaStrip}` : "Tambah Group Product"
}
description={
groupProduct
? `Kode: ${groupProduct.kdStrip}`
: "Isi detail group product baru"
}
trigger={trigger}
/>
);
}

@ -0,0 +1,22 @@
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusGroupProduct } from "../actions";
type HapusGroupProductButtonProps = {
kdStrip: string;
nama: string;
};
export function HapusGroupProductButton({
kdStrip,
nama,
}: HapusGroupProductButtonProps) {
return (
<HapusMasterButton
id={kdStrip}
idName="kdStrip"
nama={nama}
entityLabel="group product"
action={hapusGroupProduct}
/>
);
}

@ -0,0 +1,19 @@
"use client";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { GroupProductForm } from "./group-product-form";
export function TambahGroupProductButton() {
return (
<GroupProductForm
trigger={
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
);
}

@ -0,0 +1,165 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/data/auth";
import {
countPemakaianGroupProduct,
deleteGroupProduct,
insertGroupProduct,
updateGroupProduct,
type GroupProductInput,
} from "@/data/group-product";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
type GroupProductField = keyof typeof groupProductSchema.shape;
export type SimpanGroupProductState = FormState<GroupProductField> & {
kdStrip?: string;
};
export type HapusGroupProductState = DeleteState;
// Batas atas = rentang kolom `int` MariaDB.
const angka = z.coerce
.number({ error: "Harus berupa angka" })
.int("Harus bilangan bulat")
.min(0, "Tidak boleh negatif")
.max(2_000_000_000, "Maksimal 2.000.000.000");
const groupProductSchema = z.object({
namaStrip: z
.string()
.trim()
.min(1, "Nama wajib diisi")
.max(50, "Nama maksimal 50 karakter"),
murah1: angka,
murah2: angka,
sedang1: angka,
sedang2: angka,
mahal1: angka,
mahal2: angka,
benikMurah: angka,
benikSedang: angka,
benikMahal: angka,
kelBrg: z.string().trim().max(1, "Kelompok barang maksimal 1 karakter"),
kelJns: z.string().trim().max(1, "Kategori maksimal 1 karakter"),
persenTiwul: z.coerce
.number({ error: "Harus berupa angka" })
.min(0, "Tidak boleh negatif")
.max(100, "Maksimal 100"),
});
function emptyStringToNull(value: string): string | null {
return value === "" ? null : value;
}
// Kolom angka dikosongkan berarti 0, kecuali persen tiwul yang default-nya 0.025.
function angkaAtauDefault(value: string, def: string): string {
return value === "" ? def : value;
}
export async function simpanGroupProduct(
_prevState: SimpanGroupProductState,
formData: FormData,
): Promise<SimpanGroupProductState> {
const user = await getCurrentUser();
const fields = Object.keys(groupProductSchema.shape) as GroupProductField[];
const raw = Object.fromEntries(
fields.map((field) => [field, String(formData.get(field) ?? "")]),
) as Record<GroupProductField, string>;
const values: Record<GroupProductField, string> = {
...raw,
murah1: angkaAtauDefault(raw.murah1, "0"),
murah2: angkaAtauDefault(raw.murah2, "0"),
sedang1: angkaAtauDefault(raw.sedang1, "0"),
sedang2: angkaAtauDefault(raw.sedang2, "0"),
mahal1: angkaAtauDefault(raw.mahal1, "0"),
mahal2: angkaAtauDefault(raw.mahal2, "0"),
benikMurah: angkaAtauDefault(raw.benikMurah, "0"),
benikSedang: angkaAtauDefault(raw.benikSedang, "0"),
benikMahal: angkaAtauDefault(raw.benikMahal, "0"),
persenTiwul: angkaAtauDefault(raw.persenTiwul, "0.025"),
};
const parsed = groupProductSchema.safeParse(values);
if (!parsed.success) {
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const input: GroupProductInput = {
...parsed.data,
kelBrg: emptyStringToNull(parsed.data.kelBrg),
};
const kdStrip = String(formData.get("kdStrip") ?? "").trim();
try {
if (kdStrip === "") {
const kdBaru = await insertGroupProduct(input);
revalidatePath("/master/group-product");
return { ok: true, kdStrip: kdBaru };
}
const updated = await updateGroupProduct(kdStrip, input);
if (!updated) {
return { ok: false, error: "Group product tidak ditemukan", values };
}
revalidatePath("/master/group-product");
return { ok: true, kdStrip };
} catch (error) {
logActionError(
"simpanGroupProduct",
{ userId: user.id, kdStrip: kdStrip || "(baru)", nama: input.namaStrip },
error,
);
return {
ok: false,
error: "Gagal menyimpan group product, coba simpan ulang",
values,
};
}
}
export async function hapusGroupProduct(
_prevState: HapusGroupProductState,
formData: FormData,
): Promise<HapusGroupProductState> {
const user = await getCurrentUser();
const kdStrip = String(formData.get("kdStrip") ?? "").trim();
if (kdStrip === "") {
return { ok: false, error: "Kode group product tidak ada" };
}
try {
const dipakai = await countPemakaianGroupProduct(kdStrip);
if (dipakai.barang > 0 || dipakai.subGroup > 0) {
return {
ok: false,
error: `Group product masih dipakai ${dipakai.barang} barang dan ${dipakai.subGroup} sub group product, tidak bisa dihapus`,
};
}
const deleted = await deleteGroupProduct(kdStrip);
if (!deleted) {
return { ok: false, error: "Group product tidak ditemukan" };
}
revalidatePath("/master/group-product");
return { ok: true };
} catch (error) {
logActionError("hapusGroupProduct", { userId: user.id, kdStrip }, error);
return {
ok: false,
error: "Gagal menghapus group product, coba hapus ulang",
};
}
}

@ -0,0 +1,31 @@
import { Suspense } from "react";
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
import { DaftarGroupProduct } from "./_components/daftar-group-product";
import { TambahGroupProductButton } from "./_components/tambah-group-product-button";
export default function GroupProductPage({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
return (
<div className="p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Data Group Product</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariMaster
basePath="/master/group-product"
placeholder="Cari group product..."
/>
</Suspense>
<TambahGroupProductButton />
</div>
</div>
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
<DaftarGroupProduct searchParams={searchParams} />
</Suspense>
</div>
);
}

@ -1,80 +1,22 @@
"use client"; import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusPelanggan } from "../actions";
import { useActionState, useState } from "react";
import { Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { hapusPelanggan, type HapusPelangganState } from "../actions";
type HapusPelangganButtonProps = { type HapusPelangganButtonProps = {
kdCustomer: string; kdCustomer: string;
nama: string; nama: string;
}; };
const initial: HapusPelangganState = { ok: false };
export function HapusPelangganButton({ export function HapusPelangganButton({
kdCustomer, kdCustomer,
nama, nama,
}: HapusPelangganButtonProps) { }: HapusPelangganButtonProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: HapusPelangganState, formData: FormData) => {
const result = await hapusPelanggan(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
return ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <HapusMasterButton
<AlertDialogTrigger id={kdCustomer}
render={ idName="kdCustomer"
<Button nama={nama}
variant="ghost" entityLabel="pelanggan"
size="icon-sm" action={hapusPelanggan}
className="text-destructive hover:bg-destructive/10" />
title="Hapus pelanggan"
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus {nama}?</AlertDialogTitle>
<AlertDialogDescription>
Data pelanggan {kdCustomer} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="kdCustomer" value={kdCustomer} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
); );
} }

@ -1,191 +1,49 @@
"use client"; "use client";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
Sheet, MasterSheetForm,
SheetContent, type MasterField,
SheetDescription, } from "@/app/(dashboard)/master/_components/master-sheet-form";
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
import type { Pelanggan } from "@/data/pelanggan"; import type { Pelanggan } from "@/data/pelanggan";
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
type PelangganFormProps = { type PelangganFormProps = {
pelanggan?: Pelanggan; pelanggan?: Pelanggan;
trigger: React.ReactElement; trigger: React.ReactElement;
}; };
const initial: SimpanPelangganState = { ok: false };
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
const [open, setOpen] = useState(false);
// Sheet ditutup di sini, bukan di useEffect: effect dengan deps [state.ok] tidak
// jalan lagi saat simpan sukses kedua (true -> true), jadi sheet tetap terbuka.
const [state, formAction, pending] = useActionState(
async (prevState: SimpanPelangganState, formData: FormData) => {
const result = await simpanPelanggan(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>
{pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
</SheetTitle>
<SheetDescription>
{pelanggan
? `Kode: ${pelanggan.kdCustomer}`
: "Isi detail pelanggan baru"}
</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{pelanggan && (
<input
type="hidden"
name="kdCustomer"
value={pelanggan.kdCustomer}
/>
)}
<FormInput
name="nama"
label="Nama *"
state={state}
defaultValue={pelanggan?.nama}
required
/>
<FormInput
name="alias"
label="Alias"
state={state}
defaultValue={pelanggan?.alias}
/>
<FormInput
name="ktp"
label="No. KTP"
state={state}
defaultValue={pelanggan?.ktp}
/>
<FormInput
name="alamat"
label="Alamat *"
state={state}
defaultValue={pelanggan?.alamat}
required
/>
<FormInput
name="wilayah"
label="Wilayah"
state={state}
defaultValue={pelanggan?.wilayah}
/>
<FormInput
name="telp"
label="Telepon"
state={state}
defaultValue={pelanggan?.telp}
/>
<FormInput
name="ponsel"
label="Ponsel"
state={state}
defaultValue={pelanggan?.ponsel}
/>
<FormInput
name="email"
label="Email"
type="email"
state={state}
defaultValue={pelanggan?.email}
/>
<FormInput
name="jenisCustomer"
label="Jenis Customer"
state={state}
defaultValue={pelanggan?.jenisCustomer}
/>
<FormInput
name="upline"
label="Upline"
state={state}
defaultValue={pelanggan?.upline}
/>
<FormInput
name="btsBon"
label="BTS Bon"
type="number"
state={state}
defaultValue={pelanggan?.btsBon}
/>
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
type FieldName = keyof NonNullable<SimpanPelangganState["values"]>; type FieldName = keyof NonNullable<SimpanPelangganState["values"]>;
type FormInputProps = Omit< const fields: MasterField<FieldName>[] = [
React.ComponentProps<"input">, { name: "nama", label: "Nama", required: true },
"name" | "defaultValue" { name: "alias", label: "Alias" },
> & { { name: "ktp", label: "No. KTP" },
name: FieldName; { name: "alamat", label: "Alamat", required: true },
label: string; { name: "wilayah", label: "Wilayah" },
state: SimpanPelangganState; { name: "telp", label: "Telepon" },
defaultValue?: string | number | null; { name: "ponsel", label: "Ponsel" },
}; { name: "email", label: "Email", type: "email" },
{ name: "jenisCustomer", label: "Jenis Customer" },
// Isian dari server (state.values) didahulukan supaya ketikan user tidak hilang { name: "upline", label: "Upline" },
// saat validasi gagal; kalau belum ada, pakai data pelanggan yang sedang diedit. { name: "btsBon", label: "Batas Bon", type: "number" },
function FormInput({ ];
name,
label,
state,
defaultValue,
...props
}: FormInputProps) {
const errors = state.fieldErrors?.[name];
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
return ( return (
<Field data-invalid={!!errors}> <MasterSheetForm
<FieldLabel htmlFor={name}>{label}</FieldLabel> action={simpanPelanggan}
<Input fields={fields}
id={name} defaultValues={pelanggan}
name={name} hiddenFields={
defaultValue={state.values?.[name] ?? defaultValue ?? ""} pelanggan ? { kdCustomer: pelanggan.kdCustomer } : undefined
aria-invalid={!!errors} }
{...props} title={pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
/> description={
<FieldError errors={errors?.map((message) => ({ message }))} /> pelanggan
</Field> ? `Kode: ${pelanggan.kdCustomer}`
: "Isi detail pelanggan baru"
}
trigger={trigger}
/>
); );
} }

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

@ -0,0 +1,85 @@
import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/pagination";
import { getCurrentUser } from "@/data/auth";
import { listRak } from "@/data/rak";
import { halamanHref, parsePage } from "@/lib/pagination";
import { HapusRakButton } from "./hapus-rak-button";
import { RakForm } from "./rak-form";
export async function DaftarRak({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
await getCurrentUser();
const { search, page: pageParam } = await searchParams;
const page = parsePage(pageParam);
const { rows, hasNext } = await listRak(search, page);
return (
<>
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs tracking-wide text-muted-foreground uppercase">
<th className="px-3 py-2.5 font-medium">ID</th>
<th className="px-3 py-2.5 font-medium">Nama Rak</th>
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((r) => (
<tr
key={r.idRak}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{r.idRak}
</td>
<td className="px-3 py-2 font-medium">{r.namaRak}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<RakForm
rak={r}
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Edit rak"
>
<PencilIcon />
<span className="sr-only">Edit</span>
</Button>
}
/>
<HapusRakButton idRak={r.idRak} nama={r.namaRak ?? ""} />
</div>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td
colSpan={3}
className="px-3 py-10 text-center text-muted-foreground"
>
{page > 1 ? "Halaman ini kosong" : "Tidak ada rak"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<Pagination
page={page}
hasNext={hasNext}
hrefPage={(p) => halamanHref("/master/rak", p, search)}
/>
</>
);
}

@ -0,0 +1,19 @@
import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusRak } from "../actions";
type HapusRakButtonProps = {
idRak: number;
nama: string;
};
export function HapusRakButton({ idRak, nama }: HapusRakButtonProps) {
return (
<HapusMasterButton
id={idRak}
idName="idRak"
nama={nama}
entityLabel="rak"
action={hapusRak}
/>
);
}

@ -0,0 +1,33 @@
import type { ReactElement } from "react";
import {
MasterSheetForm,
type MasterField,
} from "@/app/(dashboard)/master/_components/master-sheet-form";
import type { Rak } from "@/data/rak";
import { simpanRak, type SimpanRakState } from "../actions";
type RakFormProps = {
rak?: Rak;
trigger: ReactElement;
};
type FieldName = keyof NonNullable<SimpanRakState["values"]>;
const fields: MasterField<FieldName>[] = [
{ name: "namaRak", label: "Nama Rak", required: true },
];
export function RakForm({ rak, trigger }: RakFormProps) {
return (
<MasterSheetForm
action={simpanRak}
fields={fields}
defaultValues={rak && { namaRak: rak.namaRak }}
hiddenFields={rak && { idRak: rak.idRak }}
title={rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}
description={rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"}
trigger={trigger}
/>
);
}

@ -0,0 +1,105 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/data/auth";
import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
export type SimpanRakState = FormState<"namaRak">;
export type HapusRakState = DeleteState;
const rakSchema = z.object({
namaRak: z
.string()
.trim()
.min(1, "Nama rak wajib diisi")
.max(30, "Maksimal 30 karakter"),
});
export async function simpanRak(
_prevState: SimpanRakState,
formData: FormData,
): Promise<SimpanRakState> {
const user = await getCurrentUser();
const values = {
namaRak: String(formData.get("namaRak") ?? ""),
};
const parsed = rakSchema.safeParse(values);
if (!parsed.success) {
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const input: RakInput = parsed.data;
const idRaw = String(formData.get("idRak") ?? "");
const id = idRaw ? Number.parseInt(idRaw, 10) : null;
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) {
return { ok: false, error: "ID rak tidak valid", values };
}
try {
if (id !== null) {
const updated = await updateRak(id, input);
if (!updated) {
return { ok: false, error: "Rak tidak ditemukan", values };
}
revalidatePath("/master/rak");
return { ok: true };
}
await insertRak(input);
revalidatePath("/master/rak");
return { ok: true };
} catch (error) {
logActionError(
"simpanRak",
{
userId: user.id,
idRak: id ?? "(baru)",
nama: input.namaRak,
},
error,
);
return {
ok: false,
error: "Gagal menyimpan rak, coba simpan ulang",
values,
};
}
}
export async function hapusRak(
_prevState: HapusRakState,
formData: FormData,
): Promise<HapusRakState> {
const user = await getCurrentUser();
const id = Number.parseInt(String(formData.get("idRak") ?? ""), 10);
if (!Number.isSafeInteger(id) || id <= 0) {
return { ok: false, error: "ID rak tidak ada" };
}
try {
const deleted = await deleteRak(id);
if (!deleted) {
return { ok: false, error: "Rak tidak ditemukan" };
}
revalidatePath("/master/rak");
return { ok: true };
} catch (error) {
logActionError("hapusRak", { userId: user.id, id }, error);
return {
ok: false,
error: "Gagal menghapus rak, coba hapus ulang",
};
}
}

@ -0,0 +1,37 @@
import { Suspense } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master";
import { DaftarRak } from "./_components/daftar-rak";
import { RakForm } from "./_components/rak-form";
export default function RakPage({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
return (
<div className="p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Data Rak</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariMaster basePath="/master/rak" placeholder="Cari rak..." />
</Suspense>
<RakForm
trigger={
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
</div>
</div>
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
<DaftarRak searchParams={searchParams} />
</Suspense>
</div>
);
}

@ -1,82 +1,22 @@
"use client"; import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusSatuanBarang } from "../actions";
import { useActionState, useState } from "react";
import { Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { hapusSatuanBarang, type HapusSatuanBarangState } from "../actions";
type HapusSatuanBarangButtonProps = { type HapusSatuanBarangButtonProps = {
idSatuanBarang: number; idSatuanBarang: number;
nama: string; nama: string;
}; };
const initial: HapusSatuanBarangState = { ok: false };
export function HapusSatuanBarangButton({ export function HapusSatuanBarangButton({
idSatuanBarang, idSatuanBarang,
nama, nama,
}: HapusSatuanBarangButtonProps) { }: HapusSatuanBarangButtonProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: HapusSatuanBarangState, formData: FormData) => {
const result = await hapusSatuanBarang(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
return ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <HapusMasterButton
<AlertDialogTrigger id={idSatuanBarang}
render={ idName="idSatuanBarang"
<Button nama={nama}
variant="ghost" entityLabel="satuan"
size="icon-sm" action={hapusSatuanBarang}
className="text-destructive hover:bg-destructive/10" />
title="Hapus satuan"
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Hapus {nama || `satuan ${idSatuanBarang}`}?
</AlertDialogTitle>
<AlertDialogDescription>
Data satuan {idSatuanBarang} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="idSatuanBarang" value={idSatuanBarang} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
); );
} }

@ -1,96 +1,35 @@
"use client"; import type { ReactElement } from "react";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
Sheet, MasterSheetForm,
SheetContent, type MasterField,
SheetDescription, } from "@/app/(dashboard)/master/_components/master-sheet-form";
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import type { SatuanBarang } from "@/data/satuan-barang"; import type { SatuanBarang } from "@/data/satuan-barang";
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions"; import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
type SatuanBarangFormProps = { type SatuanBarangFormProps = {
satuan?: SatuanBarang; satuan?: SatuanBarang;
trigger: React.ReactElement; trigger: ReactElement;
}; };
const initial: SimpanSatuanBarangState = { ok: false }; type FieldName = keyof NonNullable<SimpanSatuanBarangState["values"]>;
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) { const fields: MasterField<FieldName>[] = [
const [open, setOpen] = useState(false); { name: "namaSatuanBarang", label: "Nama Satuan", required: true },
const [state, formAction, pending] = useActionState( ];
async (prevState: SimpanSatuanBarangState, formData: FormData) => {
const result = await simpanSatuanBarang(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
const errors = state.fieldErrors?.namaSatuanBarang;
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <MasterSheetForm
<SheetTrigger render={trigger} /> action={simpanSatuanBarang}
<SheetContent> fields={fields}
<SheetHeader> defaultValues={satuan && { namaSatuanBarang: satuan.namaSatuanBarang }}
<SheetTitle> hiddenFields={satuan && { idSatuanBarang: satuan.idSatuanBarang }}
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"} title={satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
</SheetTitle> description={
<SheetDescription> satuan ? `ID: ${satuan.idSatuanBarang}` : "Isi nama satuan barang baru"
{satuan }
? `ID: ${satuan.idSatuanBarang}` trigger={trigger}
: "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>
); );
} }

@ -10,16 +10,14 @@ 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 = { export type SimpanSatuanBarangState = FormState<"namaSatuanBarang">;
ok: boolean; export type HapusSatuanBarangState = DeleteState;
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
@ -61,15 +59,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, idSatuanBarang: id }; return { ok: true };
} }
const idBaru = await insertSatuanBarang(input); await insertSatuanBarang(input);
revalidatePath("/master/satuan-barang"); revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: idBaru }; return { ok: true };
} catch (error) { } catch (error) {
console.error( logActionError(
"[simpanSatuanBarang] gagal", "simpanSatuanBarang",
{ {
userId: user.id, userId: user.id,
idSatuanBarang: id ?? "(baru)", idSatuanBarang: id ?? "(baru)",
@ -112,7 +110,7 @@ export async function hapusSatuanBarang(
revalidatePath("/master/satuan-barang"); revalidatePath("/master/satuan-barang");
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error); logActionError("hapusSatuanBarang", { 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 "@/components/cari-master"; import { CariMaster } from "@/app/(dashboard)/master/_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";

@ -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