Add master satuan barang feature

CRUD page for satuan barang following the pelanggan feature pattern:
page, actions, form sheet, delete dialog, and a data layer with LIKE
search and pagination. Delete rejects units still referenced by barang.
main
Deardo Satria 22 hours ago
parent 6f1234aa49
commit bc7619210c

@ -0,0 +1,90 @@
import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/pagination";
import { getCurrentUser } from "@/data/auth";
import { listSatuanBarang } from "@/data/satuan-barang";
import { halamanHref, parsePage } from "@/lib/pagination";
import { HapusSatuanBarangButton } from "./hapus-satuan-barang-button";
import { SatuanBarangForm } from "./satuan-barang-form";
export async function DaftarSatuanBarang({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
await getCurrentUser();
const { search, page: pageParam } = await searchParams;
const page = parsePage(pageParam);
const { rows, hasNext } = await listSatuanBarang(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 Satuan</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.idSatuanBarang}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{s.idSatuanBarang}
</td>
<td className="px-3 py-2 font-medium">
{s.namaSatuanBarang}
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<SatuanBarangForm
satuan={s}
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Edit satuan"
>
<PencilIcon />
<span className="sr-only">Edit</span>
</Button>
}
/>
<HapusSatuanBarangButton
idSatuanBarang={s.idSatuanBarang}
nama={s.namaSatuanBarang ?? ""}
/>
</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 satuan"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<Pagination
page={page}
hasNext={hasNext}
hrefPage={(p) => halamanHref("/master/satuan-barang", p, search)}
/>
</>
);
}

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

@ -0,0 +1,96 @@
"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 { SatuanBarang } from "@/data/satuan-barang";
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
type SatuanBarangFormProps = {
satuan?: SatuanBarang;
trigger: React.ReactElement;
};
const initial: SimpanSatuanBarangState = { ok: false };
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: SimpanSatuanBarangState, formData: FormData) => {
const result = await simpanSatuanBarang(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
const errors = state.fieldErrors?.namaSatuanBarang;
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
</SheetTitle>
<SheetDescription>
{satuan
? `ID: ${satuan.idSatuanBarang}`
: "Isi nama satuan barang baru"}
</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{satuan && (
<input
type="hidden"
name="idSatuanBarang"
value={satuan.idSatuanBarang}
/>
)}
<Field data-invalid={!!errors}>
<FieldLabel htmlFor="namaSatuanBarang">Nama Satuan *</FieldLabel>
<Input
id="namaSatuanBarang"
name="namaSatuanBarang"
defaultValue={
state.values?.namaSatuanBarang ?? satuan?.namaSatuanBarang ?? ""
}
aria-invalid={!!errors}
required
/>
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

@ -0,0 +1,121 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/data/auth";
import {
countPemakaianSatuanBarang,
deleteSatuanBarang,
insertSatuanBarang,
updateSatuanBarang,
type SatuanBarangInput,
} from "@/data/satuan-barang";
export type SimpanSatuanBarangState = {
ok: boolean;
error?: string;
fieldErrors?: { namaSatuanBarang?: string[] };
values?: { namaSatuanBarang?: string };
idSatuanBarang?: number;
};
export type HapusSatuanBarangState = { ok: boolean; error?: string };
const satuanBarangSchema = z.object({
namaSatuanBarang: z
.string()
.trim()
.min(1, "Nama satuan wajib diisi")
.max(30, "Maksimal 30 karakter"),
});
export async function simpanSatuanBarang(
_prevState: SimpanSatuanBarangState,
formData: FormData,
): Promise<SimpanSatuanBarangState> {
const user = await getCurrentUser();
const values = {
namaSatuanBarang: String(formData.get("namaSatuanBarang") ?? ""),
};
const parsed = satuanBarangSchema.safeParse(values);
if (!parsed.success) {
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const input: SatuanBarangInput = parsed.data;
const idRaw = String(formData.get("idSatuanBarang") ?? "");
const id = idRaw ? Number.parseInt(idRaw, 10) : null;
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) {
return { ok: false, error: "ID satuan tidak valid", values };
}
try {
if (id !== null) {
const updated = await updateSatuanBarang(id, input);
if (!updated) {
return { ok: false, error: "Satuan tidak ditemukan", values };
}
revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: id };
}
const idBaru = await insertSatuanBarang(input);
revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: idBaru };
} catch (error) {
console.error(
"[simpanSatuanBarang] gagal",
{
userId: user.id,
idSatuanBarang: id ?? "(baru)",
nama: input.namaSatuanBarang,
},
error,
);
return {
ok: false,
error: "Gagal menyimpan satuan, coba simpan ulang",
values,
};
}
}
export async function hapusSatuanBarang(
_prevState: HapusSatuanBarangState,
formData: FormData,
): Promise<HapusSatuanBarangState> {
const user = await getCurrentUser();
const id = Number.parseInt(String(formData.get("idSatuanBarang") ?? ""), 10);
if (!Number.isSafeInteger(id) || id <= 0) {
return { ok: false, error: "ID satuan tidak ada" };
}
try {
const dipakai = await countPemakaianSatuanBarang(id);
if (dipakai > 0) {
return {
ok: false,
error: `Satuan masih dipakai ${dipakai} barang, tidak bisa dihapus`,
};
}
const deleted = await deleteSatuanBarang(id);
if (!deleted) {
return { ok: false, error: "Satuan tidak ditemukan" };
}
revalidatePath("/master/satuan-barang");
return { ok: true };
} catch (error) {
console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error);
return {
ok: false,
error: "Gagal menghapus satuan, coba hapus ulang",
};
}
}

@ -0,0 +1,40 @@
import { Suspense } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CariMaster } from "@/components/cari-master";
import { DaftarSatuanBarang } from "./_components/daftar-satuan-barang";
import { SatuanBarangForm } from "./_components/satuan-barang-form";
export default function SatuanBarangPage({
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 Satuan Barang</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariMaster
basePath="/master/satuan-barang"
placeholder="Cari satuan..."
/>
</Suspense>
<SatuanBarangForm
trigger={
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
</div>
</div>
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
<DaftarSatuanBarang searchParams={searchParams} />
</Suspense>
</div>
);
}

@ -0,0 +1,93 @@
import "server-only";
import { asc, count, eq, like } from "drizzle-orm";
import { amigoDb } from "@/db/amigo";
import { no_barang, satuan_barang } from "@/db/amigo/schema";
import { PER_PAGE } from "@/lib/pagination";
import { escapeLike } from "@/lib/utils";
export type SatuanBarang = {
idSatuanBarang: number;
namaSatuanBarang: string | null;
};
export type SatuanBarangPage = {
rows: SatuanBarang[];
hasNext: boolean;
};
// Kolom DB nullable, tapi isian form wajib string.
export type SatuanBarangInput = {
namaSatuanBarang: NonNullable<SatuanBarang["namaSatuanBarang"]>;
};
export async function insertSatuanBarang(
input: SatuanBarangInput,
): Promise<number> {
const [res] = await amigoDb.insert(satuan_barang).values({
namaSatuanBarang: input.namaSatuanBarang,
});
return res.insertId;
}
export async function updateSatuanBarang(
idSatuanBarang: number,
input: SatuanBarangInput,
): Promise<boolean> {
const [res] = await amigoDb
.update(satuan_barang)
.set({ namaSatuanBarang: input.namaSatuanBarang })
.where(eq(satuan_barang.idSatuanBarang, idSatuanBarang));
return res.affectedRows > 0;
}
// `no_barang.satuan` menyimpan idSatuanBarang sebagai varchar.
export async function countPemakaianSatuanBarang(
idSatuanBarang: number,
): Promise<number> {
const [row] = await amigoDb
.select({ total: count() })
.from(no_barang)
.where(eq(no_barang.satuan, String(idSatuanBarang)));
return row?.total ?? 0;
}
export async function deleteSatuanBarang(
idSatuanBarang: number,
): Promise<boolean> {
const [res] = await amigoDb
.delete(satuan_barang)
.where(eq(satuan_barang.idSatuanBarang, idSatuanBarang));
return res.affectedRows > 0;
}
export async function listSatuanBarang(
search?: string,
page = 1,
): Promise<SatuanBarangPage> {
const q = search?.trim();
const pattern = q ? `%${escapeLike(q)}%` : "";
const rows = await amigoDb
.select({
idSatuanBarang: satuan_barang.idSatuanBarang,
namaSatuanBarang: satuan_barang.namaSatuanBarang,
})
.from(satuan_barang)
.where(q ? like(satuan_barang.namaSatuanBarang, pattern) : undefined)
.orderBy(
asc(satuan_barang.namaSatuanBarang),
asc(satuan_barang.idSatuanBarang),
)
.limit(PER_PAGE + 1)
.offset((page - 1) * PER_PAGE);
return {
rows: rows.slice(0, PER_PAGE),
hasNext: rows.length > PER_PAGE,
};
}
Loading…
Cancel
Save