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