From 3002e55bc4e95bce3998a374fcac6ca03645748f Mon Sep 17 00:00:00 2001 From: Deardo Satria Date: Tue, 3 Nov 2026 10:27:00 +0700 Subject: [PATCH] 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 --- app/(dashboard)/_components/nav-items.ts | 2 + .../_components/daftar-group-product.tsx | 100 +++++++++ .../_components/group-product-form.tsx | 56 +++++ .../hapus-group-product-button.tsx | 22 ++ .../tambah-group-product-button.tsx | 19 ++ .../master/group-product/actions.ts | 165 ++++++++++++++ app/(dashboard)/master/group-product/page.tsx | 31 +++ .../_components/daftar-sub-group-product.tsx | 98 +++++++++ .../hapus-sub-group-product-button.tsx | 22 ++ .../_components/sub-group-product-form.tsx | 173 +++++++++++++++ .../tambah-sub-group-product-button.tsx | 25 +++ .../master/sub-group-product/actions.ts | 148 +++++++++++++ .../master/sub-group-product/page.tsx | 42 ++++ data/group-product.ts | 204 ++++++++++++++++++ data/sub-group-product.ts | 170 +++++++++++++++ 15 files changed, 1277 insertions(+) create mode 100644 app/(dashboard)/master/group-product/_components/daftar-group-product.tsx create mode 100644 app/(dashboard)/master/group-product/_components/group-product-form.tsx create mode 100644 app/(dashboard)/master/group-product/_components/hapus-group-product-button.tsx create mode 100644 app/(dashboard)/master/group-product/_components/tambah-group-product-button.tsx create mode 100644 app/(dashboard)/master/group-product/actions.ts create mode 100644 app/(dashboard)/master/group-product/page.tsx create mode 100644 app/(dashboard)/master/sub-group-product/_components/daftar-sub-group-product.tsx create mode 100644 app/(dashboard)/master/sub-group-product/_components/hapus-sub-group-product-button.tsx create mode 100644 app/(dashboard)/master/sub-group-product/_components/sub-group-product-form.tsx create mode 100644 app/(dashboard)/master/sub-group-product/_components/tambah-sub-group-product-button.tsx create mode 100644 app/(dashboard)/master/sub-group-product/actions.ts create mode 100644 app/(dashboard)/master/sub-group-product/page.tsx create mode 100644 data/group-product.ts create mode 100644 data/sub-group-product.ts diff --git a/app/(dashboard)/_components/nav-items.ts b/app/(dashboard)/_components/nav-items.ts index 682dbca..0b6ad49 100644 --- a/app/(dashboard)/_components/nav-items.ts +++ b/app/(dashboard)/_components/nav-items.ts @@ -23,6 +23,8 @@ export const navItems: NavItem[] = [ { title: "Satuan Barang", href: "/master/satuan-barang" }, { title: "Data Pelanggan", href: "/master/pelanggan" }, { title: "Rak", href: "/master/rak" }, + { title: "Group Product", href: "/master/group-product" }, + { title: "Sub Group Product", href: "/master/sub-group-product" }, ], }, ]; diff --git a/app/(dashboard)/master/group-product/_components/daftar-group-product.tsx b/app/(dashboard)/master/group-product/_components/daftar-group-product.tsx new file mode 100644 index 0000000..85247b0 --- /dev/null +++ b/app/(dashboard)/master/group-product/_components/daftar-group-product.tsx @@ -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 ( + <> +
+
+ + + + + + + + + + + + {rows.map((g) => ( + + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
KodeNamaKategori + Persen Thiwul + Aksi
+ {g.kdStrip} + {g.namaStrip} + {g.kelJns || "-"} + + {g.persenTiwul} + +
+ + + Edit + + } + /> + +
+
+ {page > 1 + ? "Halaman ini kosong" + : "Tidak ada group product"} +
+
+
+ halamanHref("/master/group-product", p, search)} + /> + + ); +} diff --git a/app/(dashboard)/master/group-product/_components/group-product-form.tsx b/app/(dashboard)/master/group-product/_components/group-product-form.tsx new file mode 100644 index 0000000..58c5a66 --- /dev/null +++ b/app/(dashboard)/master/group-product/_components/group-product-form.tsx @@ -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; + +const fields: MasterField[] = [ + { 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 ( + + ); +} diff --git a/app/(dashboard)/master/group-product/_components/hapus-group-product-button.tsx b/app/(dashboard)/master/group-product/_components/hapus-group-product-button.tsx new file mode 100644 index 0000000..53774f5 --- /dev/null +++ b/app/(dashboard)/master/group-product/_components/hapus-group-product-button.tsx @@ -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 ( + + ); +} diff --git a/app/(dashboard)/master/group-product/_components/tambah-group-product-button.tsx b/app/(dashboard)/master/group-product/_components/tambah-group-product-button.tsx new file mode 100644 index 0000000..70e0511 --- /dev/null +++ b/app/(dashboard)/master/group-product/_components/tambah-group-product-button.tsx @@ -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 ( + + Tambah + + + } + /> + ); +} diff --git a/app/(dashboard)/master/group-product/actions.ts b/app/(dashboard)/master/group-product/actions.ts new file mode 100644 index 0000000..d93c507 --- /dev/null +++ b/app/(dashboard)/master/group-product/actions.ts @@ -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 & { + 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 { + 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; + + const values: Record = { + ...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 { + 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", + }; + } +} diff --git a/app/(dashboard)/master/group-product/page.tsx b/app/(dashboard)/master/group-product/page.tsx new file mode 100644 index 0000000..5484282 --- /dev/null +++ b/app/(dashboard)/master/group-product/page.tsx @@ -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 ( +
+
+

Data Group Product

+
+ + + + +
+
+ Memuat...

}> + +
+
+ ); +} diff --git a/app/(dashboard)/master/sub-group-product/_components/daftar-sub-group-product.tsx b/app/(dashboard)/master/sub-group-product/_components/daftar-sub-group-product.tsx new file mode 100644 index 0000000..6b26a88 --- /dev/null +++ b/app/(dashboard)/master/sub-group-product/_components/daftar-sub-group-product.tsx @@ -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 ( + <> +
+
+ + + + + + + + + + + {rows.map((s) => ( + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
KodeNamaGroup ProductAksi
+ {s.kodeKedProduk} + {s.kedProduk} + {s.namaStrip + ? `${s.kodeStrip} - ${s.namaStrip}` + : (s.kodeStrip ?? "-")} + +
+ + + Edit + + } + /> + +
+
+ {page > 1 + ? "Halaman ini kosong" + : "Tidak ada sub group product"} +
+
+
+ halamanHref("/master/sub-group-product", p, search)} + /> + + ); +} diff --git a/app/(dashboard)/master/sub-group-product/_components/hapus-sub-group-product-button.tsx b/app/(dashboard)/master/sub-group-product/_components/hapus-sub-group-product-button.tsx new file mode 100644 index 0000000..8ef54d0 --- /dev/null +++ b/app/(dashboard)/master/sub-group-product/_components/hapus-sub-group-product-button.tsx @@ -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 ( + + ); +} diff --git a/app/(dashboard)/master/sub-group-product/_components/sub-group-product-form.tsx b/app/(dashboard)/master/sub-group-product/_components/sub-group-product-form.tsx new file mode 100644 index 0000000..25dc609 --- /dev/null +++ b/app/(dashboard)/master/sub-group-product/_components/sub-group-product-form.tsx @@ -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 => { + const result = await simpanSubGroupProduct(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + { ok: false }, + ); + + return ( + + + + + + {subGroupProduct + ? `Edit ${subGroupProduct.kedProduk}` + : "Tambah Sub Group Product"} + + + {subGroupProduct + ? "Kode dan group product tidak bisa diubah, hanya nama" + : "Isi detail sub group product baru"} + + + +
+ {subGroupProduct && ( + + )} + + + + Group Product * + + + ({ + message, + }))} + /> + + + + Kode * + + ({ + message, + }))} + /> + + + + Nama * + + ({ + message, + }))} + /> + + + {state.error && ( +

{state.error}

+ )} + + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/sub-group-product/_components/tambah-sub-group-product-button.tsx b/app/(dashboard)/master/sub-group-product/_components/tambah-sub-group-product-button.tsx new file mode 100644 index 0000000..3180cb4 --- /dev/null +++ b/app/(dashboard)/master/sub-group-product/_components/tambah-sub-group-product-button.tsx @@ -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 ( + + Tambah + + + } + /> + ); +} diff --git a/app/(dashboard)/master/sub-group-product/actions.ts b/app/(dashboard)/master/sub-group-product/actions.ts new file mode 100644 index 0000000..68d3fe2 --- /dev/null +++ b/app/(dashboard)/master/sub-group-product/actions.ts @@ -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 & { + 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 { + 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; + + 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 { + 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", + }; + } +} diff --git a/app/(dashboard)/master/sub-group-product/page.tsx b/app/(dashboard)/master/sub-group-product/page.tsx new file mode 100644 index 0000000..ea55edc --- /dev/null +++ b/app/(dashboard)/master/sub-group-product/page.tsx @@ -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 ( +
+
+

Data Sub Group Product

+
+ + + + + + +
+
+ Memuat...

}> + +
+
+ ); +} + +async function TambahSubGroupProduct() { + await getCurrentUser(); + const groupProducts = await listGroupProductOptions(); + + return ; +} diff --git a/data/group-product.ts b/data/group-product.ts new file mode 100644 index 0000000..f84ffd0 --- /dev/null +++ b/data/group-product.ts @@ -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; + +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 { + 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 { + // 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 { + 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 { + 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 { + 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 => + 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 { + 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, + }; +} diff --git a/data/sub-group-product.ts b/data/sub-group-product.ts new file mode 100644 index 0000000..c952d44 --- /dev/null +++ b/data/sub-group-product.ts @@ -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; + +// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +}