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
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…
Reference in New Issue