Implement scrap management functionality in cutting plan

- Added new endpoints for managing scrap items, including storing, listing, using, and discarding scraps.
- Implemented company code filtering to ensure data integrity based on user permissions.
- Enhanced error handling for missing fields and data not found scenarios.
- Updated the cutting plan routes to include new scrap management operations.

(TASK: ERP-109)
This commit is contained in:
kjs
2026-05-26 17:20:03 +09:00
parent 08ff796ff1
commit f2d5cd668d
4 changed files with 528 additions and 4 deletions

View File

@@ -95,3 +95,80 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) {
return res.status(500).json({ success: false, message: e?.message });
}
}
// ─────────────────────────────────────────────────────────
// [TASK:ERP-109] 보관 자투리 풀 (cutting_scrap)
// ─────────────────────────────────────────────────────────
/** POST /cutting-plan/scrap — 보관 등록(단건/배치) */
export async function scrapStore(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId || "system";
const body = req.body || {};
// 단일 item 또는 items 배열 모두 허용
let items: any[] = [];
if (Array.isArray(body.items)) {
items = body.items.map((it: any) => ({
mat_item_id: it.mat_item_id || body.mat_item_id,
...it,
}));
} else if (body.mat_item_id) {
items = [body];
}
if (!items.length) {
return res.status(400).json({ success: false, message: "items 또는 mat_item_id 누락" });
}
const data = await svc.scrapStore(companyCode, userId, items);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자투리 보관 실패", { error: e?.message });
const status = e?.status || 400;
return res.status(status).json({ success: false, message: e?.message || "자투리 보관 실패" });
}
}
/** GET /cutting-plan/scrap?mat_item_id=&status= — 풀 조회 */
export async function scrapList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const mat_item_id = req.query.mat_item_id as string | undefined;
const status = (req.query.status as string) || "keep";
const data = await svc.scrapList(companyCode, { mat_item_id, status });
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자투리 풀 조회 실패", { error: e?.message });
return res.status(500).json({ success: false, message: e?.message });
}
}
/** PATCH /cutting-plan/scrap/:id/use — 사용 처리 */
export async function scrapUse(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId || "system";
const scrapId = req.params.id;
const usedPlanId = req.body?.used_plan_id != null ? Number(req.body.used_plan_id) : null;
const data = await svc.scrapUse(companyCode, userId, scrapId, usedPlanId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자투리 사용 처리 실패", { error: e?.message });
const status = e?.status || 500;
return res.status(status).json({ success: false, message: e?.message });
}
}
/** PATCH /cutting-plan/scrap/:id/discard — 폐기 처리 */
export async function scrapDiscard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId || "system";
const scrapId = req.params.id;
const data = await svc.scrapDiscard(companyCode, userId, scrapId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자투리 폐기 처리 실패", { error: e?.message });
const status = e?.status || 500;
return res.status(status).json({ success: false, message: e?.message });
}
}

View File

@@ -22,4 +22,10 @@ router.post("/plans", ctrl.savePlan);
router.put("/plans/:id", ctrl.savePlan); // id는 body에 담겨오거나 경로로
router.delete("/plans/:id", ctrl.deletePlan);
// [TASK:ERP-109] 보관 자투리 풀 (cutting_scrap)
router.post ("/scrap", ctrl.scrapStore); // 보관 등록(단건/배치)
router.get ("/scrap", ctrl.scrapList); // 풀 조회 (mat_item_id/status 필터)
router.patch ("/scrap/:id/use", ctrl.scrapUse); // 사용 처리
router.patch ("/scrap/:id/discard",ctrl.scrapDiscard); // 폐기 처리
export default router;

View File

@@ -393,3 +393,197 @@ export async function deletePlan(companyCode: string, planId: number) {
);
return r.rows.length > 0;
}
// ─────────────────────────────────────────────────────────
// [TASK:ERP-109] 보관 자투리 원자재 단위 풀 (cutting_scrap)
// - 절단계획·시트와 독립. 회사 격리 필수.
// ─────────────────────────────────────────────────────────
export interface ScrapItemInput {
mat_item_id: string;
mat_name?: string | null;
width?: number | null;
height?: number | null;
length?: number | null;
thickness?: number | null;
qty?: number;
source_plan_id?: number | null;
source_sheet_id?: number | null;
memo?: string | null;
}
/**
* 자투리 보관 등록 (단건 또는 배치).
* @returns 신규 생성된 행 배열
*/
export async function scrapStore(
companyCode: string,
userId: string,
items: ScrapItemInput[]
) {
if (!companyCode) throw new Error("회사코드 누락");
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
const client = await pool.connect();
const created: any[] = [];
try {
await client.query("BEGIN");
for (const it of items) {
if (!it.mat_item_id) {
throw new Error("원자재 품목코드(mat_item_id) 누락");
}
const qty = Number.isFinite(it.qty as number) ? Number(it.qty) : 1;
if (qty <= 0) {
throw new Error("수량은 1 이상이어야 합니다");
}
// 크기 검증 — 면적형(width/height) 또는 길이형(length) 중 하나는 양수여야 함
const w = it.width != null ? Number(it.width) : null;
const h = it.height != null ? Number(it.height) : null;
const l = it.length != null ? Number(it.length) : null;
const t = it.thickness != null ? Number(it.thickness) : null;
const hasArea = (w != null && w > 0) && (h != null && h > 0);
const hasLength = (l != null && l > 0);
if (!hasArea && !hasLength) {
throw new Error("크기(가로/세로 또는 길이)는 1 이상이어야 합니다");
}
if ((w != null && w < 0) || (h != null && h < 0) || (l != null && l < 0) || (t != null && t < 0)) {
throw new Error("크기는 음수일 수 없습니다");
}
const r = await client.query(
`INSERT INTO cutting_scrap
(company_code, mat_item_id, mat_name, width, height, length, thickness,
qty, status, source_plan_id, source_sheet_id, memo, created_by, updated_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'keep',$9,$10,$11,$12,$12)
RETURNING *`,
[
companyCode,
it.mat_item_id,
it.mat_name || null,
w, h, l, t,
qty,
it.source_plan_id || null,
it.source_sheet_id || null,
it.memo || null,
userId,
]
);
created.push(r.rows[0]);
}
await client.query("COMMIT");
return created;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/**
* 자투리 풀 조회.
* mat_item_id 미지정 시 회사 전체.
* status 기본 'keep'.
*/
export async function scrapList(
companyCode: string,
opts?: { mat_item_id?: string; status?: string }
) {
if (!companyCode) throw new Error("회사코드 누락");
const pool = getPool();
const params: any[] = [companyCode];
let where = `company_code = $1`;
const status = opts?.status || "keep";
params.push(status);
where += ` AND status = $${params.length}`;
if (opts?.mat_item_id) {
params.push(opts.mat_item_id);
where += ` AND mat_item_id = $${params.length}`;
}
const q = `
SELECT id, company_code, mat_item_id, mat_name,
width, height, length, thickness,
qty, status, source_plan_id, source_sheet_id, used_plan_id,
memo, created_date, updated_date, created_by, updated_by
FROM cutting_scrap
WHERE ${where}
ORDER BY created_date DESC, id DESC
LIMIT 500
`;
const r = await pool.query(q, params);
return r.rows;
}
/**
* 자투리 사용 처리 (status='used').
* 이미 used/discard 인 행은 재사용 차단.
*/
export async function scrapUse(
companyCode: string,
userId: string,
scrapId: string,
usedPlanId: number | null
) {
if (!companyCode) throw new Error("회사코드 누락");
if (!scrapId) throw new Error("자투리 id 누락");
const pool = getPool();
const cur = await pool.query(
`SELECT id, status FROM cutting_scrap WHERE id=$1 AND company_code=$2`,
[scrapId, companyCode]
);
if (!cur.rows.length) {
const err: any = new Error("자투리를 찾을 수 없습니다");
err.status = 404;
throw err;
}
if (cur.rows[0].status !== "keep") {
const err: any = new Error(`이미 처리된 자투리입니다 (status=${cur.rows[0].status})`);
err.status = 400;
throw err;
}
const r = await pool.query(
`UPDATE cutting_scrap
SET status='used', used_plan_id=$1,
updated_date=NOW(), updated_by=$2
WHERE id=$3 AND company_code=$4
RETURNING *`,
[usedPlanId || null, userId, scrapId, companyCode]
);
return r.rows[0];
}
/**
* 자투리 폐기 처리 (status='discard').
*/
export async function scrapDiscard(
companyCode: string,
userId: string,
scrapId: string
) {
if (!companyCode) throw new Error("회사코드 누락");
if (!scrapId) throw new Error("자투리 id 누락");
const pool = getPool();
const cur = await pool.query(
`SELECT id, status FROM cutting_scrap WHERE id=$1 AND company_code=$2`,
[scrapId, companyCode]
);
if (!cur.rows.length) {
const err: any = new Error("자투리를 찾을 수 없습니다");
err.status = 404;
throw err;
}
if (cur.rows[0].status === "used") {
const err: any = new Error("사용된 자투리는 폐기할 수 없습니다");
err.status = 400;
throw err;
}
const r = await pool.query(
`UPDATE cutting_scrap
SET status='discard',
updated_date=NOW(), updated_by=$1
WHERE id=$2 AND company_code=$3
RETURNING *`,
[userId, scrapId, companyCode]
);
return r.rows[0];
}

View File

@@ -142,6 +142,29 @@ export default function CuttingPlanPage() {
const [currentPlanNo, setCurrentPlanNo] = useState<string>("");
const [saving, setSaving] = useState(false);
// [TASK:ERP-109] 보관 자투리 영속 풀 (원자재 단위)
// - scrapPool: 현재 선택된 mat1Id의 STORED('keep') 자투리 목록
// - allScrapPool: 회사 전체 STORED 자투리 (자투리관리 탭에서 mat 무관하게 표시)
type ScrapPoolRow = {
id: string;
company_code: string;
mat_item_id: string;
mat_name?: string;
width?: number | null;
height?: number | null;
length?: number | null;
thickness?: number | null;
qty: number;
status: "keep" | "used" | "discard";
source_plan_id?: number | null;
source_sheet_id?: number | null;
used_plan_id?: number | null;
created_date?: string;
};
const [scrapPool, setScrapPool] = useState<ScrapPoolRow[]>([]);
const [allScrapPool, setAllScrapPool] = useState<ScrapPoolRow[]>([]);
const [scrapPoolLoading, setScrapPoolLoading] = useState(false);
// ───────────────────────────────────────────────────────
// 데이터 로딩
// ───────────────────────────────────────────────────────
@@ -165,6 +188,55 @@ export default function CuttingPlanPage() {
}
}, [cutType]);
// [TASK:ERP-109] 보관 자투리 풀 조회 헬퍼
// - loadScrapPool(matItemId): 특정 원자재의 STORED 자투리 (후보 노출/사용 처리용)
// - loadAllScrapPool(): 회사 전체 STORED 자투리 (자투리관리 탭 풀 섹션용)
const normalizeScrap = (r: any): ScrapPoolRow => ({
id: String(r.id),
company_code: r.company_code,
mat_item_id: String(r.mat_item_id),
mat_name: r.mat_name || "",
width: r.width != null ? Number(r.width) : null,
height: r.height != null ? Number(r.height) : null,
length: r.length != null ? Number(r.length) : null,
thickness: r.thickness != null ? Number(r.thickness) : null,
qty: Number(r.qty) || 1,
status: (r.status as any) || "keep",
source_plan_id: r.source_plan_id ?? null,
source_sheet_id: r.source_sheet_id ?? null,
used_plan_id: r.used_plan_id ?? null,
created_date: r.created_date,
});
const loadScrapPool = useCallback(async (matItemId: string) => {
if (!matItemId) { setScrapPool([]); return; }
try {
const res = await apiClient.get(`/cutting-plan/scrap`, {
params: { mat_item_id: matItemId, status: "keep" },
});
const rows = (res.data?.data || []).map(normalizeScrap);
setScrapPool(rows);
} catch (e: any) {
// 풀 조회 실패는 토스트 한 번만, 화면은 빈 풀로 동작
toast.error("자투리 풀 조회 실패: " + (e?.response?.data?.message || e?.message || ""));
setScrapPool([]);
}
}, []);
const loadAllScrapPool = useCallback(async () => {
setScrapPoolLoading(true);
try {
const res = await apiClient.get(`/cutting-plan/scrap`, { params: { status: "keep" } });
const rows = (res.data?.data || []).map(normalizeScrap);
setAllScrapPool(rows);
} catch (e: any) {
toast.error("자투리 풀 조회 실패: " + (e?.response?.data?.message || e?.message || ""));
setAllScrapPool([]);
} finally {
setScrapPoolLoading(false);
}
}, []);
const loadOrders = useCallback(async () => {
setLoadingOrders(true);
try {
@@ -401,6 +473,37 @@ export default function CuttingPlanPage() {
else if (leftTab === "ship") loadShipmentPlans();
}, [leftTab, loadProductionPlans, loadShipmentPlans]);
// [TASK:ERP-109] 원자재 변경 시 후보 풀 자동 fetch + 초기 회사 전체 풀 fetch
useEffect(() => { loadScrapPool(mat1Id); }, [mat1Id, loadScrapPool]);
useEffect(() => { loadAllScrapPool(); }, [loadAllScrapPool]);
// [TASK:ERP-109] 풀에서 폐기/사용 처리 핸들러
const discardScrap = useCallback(async (scrapId: string) => {
try {
await apiClient.patch(`/cutting-plan/scrap/${scrapId}/discard`);
toast.success("자투리가 폐기 처리되었습니다");
loadAllScrapPool();
if (mat1Id) loadScrapPool(mat1Id);
} catch (e: any) {
toast.error("폐기 실패: " + (e?.response?.data?.message || e?.message || ""));
}
}, [mat1Id, loadAllScrapPool, loadScrapPool]);
const useScrapAsCandidate = useCallback(async (scrapId: string) => {
// 후보 추가 자체는 UI 상에서 sheet 후보로 prepend, 실제 사용 확정은 저장 시점에 호출.
// 여기서는 "사용 처리"만 단독 호출하는 경로 (수동 풀 → 즉시 사용)
try {
await apiClient.patch(`/cutting-plan/scrap/${scrapId}/use`, {
used_plan_id: currentPlanId,
});
toast.success("자투리가 사용 처리되었습니다");
loadAllScrapPool();
if (mat1Id) loadScrapPool(mat1Id);
} catch (e: any) {
toast.error("사용 처리 실패: " + (e?.response?.data?.message || e?.message || ""));
}
}, [currentPlanId, mat1Id, loadAllScrapPool, loadScrapPool]);
// 절단유형 바뀌면 선택/결과 리셋
useEffect(() => {
setMat1Id("");
@@ -976,17 +1079,52 @@ export default function CuttingPlanPage() {
const res = await apiClient.post("/cutting-plan/plans", { header, items, sheets });
const data = res.data?.data;
const savedPlanId = data?.id || currentPlanId;
if (data?.id) setCurrentPlanId(data.id);
if (data?.plan_no) setCurrentPlanNo(data.plan_no);
// [TASK:ERP-109] 보관(keep) 자투리를 영속 풀(cutting_scrap)에 등록
// 면적형(area)만 대상. mat1 id 필수. 같은 mat·크기·두께는 행으로 분리 보관(출처 추적 위해 별행).
if (cutType === "area" && mat1?.id && batchResult) {
try {
const keepItems: any[] = [];
(batchResult as AreaResult).sheets.forEach((sh) => {
const rems = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf);
rems.forEach((rm) => {
if (rm.status === "keep" && rm.w > 0 && rm.h > 0) {
keepItems.push({
mat_item_id: String(mat1.id),
mat_name: mat1.name || sh.matName || null,
width: rm.w,
height: rm.h,
thickness: null,
qty: 1,
source_plan_id: savedPlanId || null,
source_sheet_id: sh.id || null,
});
}
});
});
if (keepItems.length > 0) {
await apiClient.post("/cutting-plan/scrap", { items: keepItems });
}
} catch (scrapErr: any) {
// 보관 등록 실패는 경고만 (계획 저장 자체는 성공)
toast.error("자투리 보관 등록 실패: " + (scrapErr?.response?.data?.message || scrapErr?.message || ""));
}
}
toast.success(`저장되었습니다 — 배치번호 ${data?.plan_no || currentPlanNo}`);
// 수주 목록 자동 새로고침 → 배치번호 표시
// 수주 목록 + 풀 자동 새로고침
loadOrders();
loadAllScrapPool();
if (mat1?.id) loadScrapPool(String(mat1.id));
} catch (e: any) {
toast.error("저장 실패: " + (e?.response?.data?.message || e?.message || ""));
} finally {
setSaving(false);
}
}, [planItems, currentPlanId, currentPlanNo, dateFrom, dateTo, cutType, calcMode, packMode, mat1, mat2, kerf, margin, minRemnant, minReuse, batchResult]);
}, [planItems, currentPlanId, currentPlanNo, dateFrom, dateTo, cutType, calcMode, packMode, mat1, mat2, kerf, margin, minRemnant, minReuse, batchResult, loadOrders, loadAllScrapPool, loadScrapPool]);
// ───────────────────────────────────────────────────────
// UI Helpers
@@ -1516,6 +1654,19 @@ export default function CuttingPlanPage() {
<Plus className="h-3 w-3 mr-0.5" /> 2
</Button>
)}
{/* [TASK:ERP-109] 선택된 원자재의 보관 자투리 후보 배지 */}
{mat1 && scrapPool.length > 0 && (
<button
type="button"
onClick={() => setRightTab("remnant")}
className="inline-flex items-center gap-1 rounded bg-emerald-50 border border-emerald-300 px-2 py-0.5 text-[11px] text-emerald-800 hover:bg-emerald-100"
title="보관 풀 보기 (자투리 관리 탭으로 이동)"
>
<Package className="h-3 w-3" />
<span className="font-semibold"> {scrapPool.reduce((s, r) => s + (r.qty || 0), 0)}</span>
<span className="text-emerald-600"> </span>
</button>
)}
<div className="ml-auto flex items-center gap-1">
<Label className="text-[11px] text-muted-foreground whitespace-nowrap"></Label>
<Input type="number" value={minRemnant} onChange={(e) => setMinRemnant(+e.target.value)} className="h-7 w-[60px] text-xs px-1.5" />
@@ -1742,6 +1893,10 @@ export default function CuttingPlanPage() {
getSheetRemnants={getSheetRemnants}
onToggleGroupStatus={setGroupRemnantStatus}
onSetAllStatus={setAllRemnantStatus}
scrapPool={allScrapPool}
scrapPoolLoading={scrapPoolLoading}
onDiscardScrap={discardScrap}
onReloadScrapPool={loadAllScrapPool}
/>
</TabsContent>
</Tabs>
@@ -3001,8 +3156,23 @@ function LengthBatchView({ result }: { result: LengthResult }) {
// ─────────────────────────────────────────────────────────
// 자투리 관리 뷰
// ─────────────────────────────────────────────────────────
type ScrapPoolRowView = {
id: string;
mat_item_id: string;
mat_name?: string;
width?: number | null;
height?: number | null;
length?: number | null;
thickness?: number | null;
qty: number;
status: "keep" | "used" | "discard";
source_plan_id?: number | null;
created_date?: string;
};
function RemnantView({
batchResult, cutType, minReuse, setMinReuse, getSheetRemnants, onToggleGroupStatus, onSetAllStatus,
scrapPool, scrapPoolLoading, onDiscardScrap, onReloadScrapPool,
}: {
batchResult: AreaResult | LengthResult | null;
cutType: CutType;
@@ -3011,6 +3181,11 @@ function RemnantView({
getSheetRemnants: (sheet: Sheet) => RemnantItem[];
onToggleGroupStatus: (sheetId: number, remIds: string[], status: "keep" | "discard") => void;
onSetAllStatus: (status: "keep" | "discard") => void;
// [TASK:ERP-109] 보관 자투리 영속 풀 (회사 전체 STORED)
scrapPool: ScrapPoolRowView[];
scrapPoolLoading: boolean;
onDiscardScrap: (scrapId: string) => void;
onReloadScrapPool: () => void;
}) {
const rows = useMemo(() => {
if (!batchResult) return [];
@@ -3111,6 +3286,12 @@ function RemnantView({
};
}, [rows]);
// [TASK:ERP-109] 영속 풀 통계 — 회사 전체 STORED 자투리 수량 합산
const poolSummary = useMemo(() => {
const totalQty = scrapPool.reduce((s, r) => s + (r.qty || 0), 0);
return { rows: scrapPool.length, totalQty };
}, [scrapPool]);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/20 flex-wrap gap-2">
@@ -3131,6 +3312,11 @@ function RemnantView({
</Badge>
</>
)}
{/* [TASK:ERP-109] 영속 풀 배지 — 회사 전체 보관 자투리 (계획 전환에도 영속) */}
<div className="h-4 w-px bg-border" />
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 border-emerald-500/30 text-[10px]" title="원자재 단위 영속 풀">
{poolSummary.totalQty} ({poolSummary.rows})
</Badge>
</div>
<div className="flex items-center gap-2">
{cutType === "area" && rows.length > 0 && (
@@ -3146,10 +3332,71 @@ function RemnantView({
</div>
</div>
<div className="flex-1 overflow-auto">
{/* [TASK:ERP-109] 보관 풀 섹션 — 회사 전체 STORED 자투리 (계획·세션 무관 영속) */}
<div className="border-b bg-emerald-50/30">
<div className="px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
<Package className="h-3.5 w-3.5 text-emerald-700" />
<span className="font-semibold text-emerald-800"> ()</span>
<span className="text-muted-foreground"> ( / )</span>
</div>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onReloadScrapPool}
disabled={scrapPoolLoading} title="풀 새로고침">
<RefreshCw className={cn("h-3 w-3 mr-1", scrapPoolLoading && "animate-spin")} />
</Button>
</div>
{scrapPool.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground"> </div>
) : (
<div className="max-h-[180px] overflow-auto px-3 pb-2">
<table className="w-full text-[11px]">
<thead className="text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 pr-2 font-normal"></th>
<th className="text-left py-1 pr-2 font-normal"></th>
<th className="text-right py-1 pr-2 font-normal"></th>
<th className="text-left py-1 pr-2 font-normal"></th>
<th className="text-center py-1 font-normal"></th>
</tr>
</thead>
<tbody>
{scrapPool.map((s) => {
const sizeStr = s.length && s.length > 0
? `L${s.length}mm`
: `${s.width || 0}×${s.height || 0}${s.thickness ? ` t${s.thickness}` : ""}`;
return (
<tr key={s.id} className="border-b last:border-b-0 hover:bg-emerald-50/40">
<td className="py-1 pr-2 truncate">{s.mat_name || s.mat_item_id}</td>
<td className="py-1 pr-2 font-medium">{sizeStr}</td>
<td className="py-1 pr-2 text-right">{s.qty}</td>
<td className="py-1 pr-2 text-[10px] text-muted-foreground">
{s.source_plan_id ? `#${s.source_plan_id}` : "-"}
</td>
<td className="py-1 text-center">
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[10px] text-amber-700 border-amber-300 hover:bg-amber-50"
onClick={() => onDiscardScrap(s.id)}
title="풀에서 폐기 처리"
>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{rows.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
<Package className="h-10 w-10 opacity-30 mb-2" />
<p className="text-xs"> </p>
<p className="text-xs"> </p>
</div>
) : (
<Table>