Files
wace_rps/frontend/components/common/ItemSearchDialog.tsx
T
hjjeong 489fa50d11 영업관리 4개 메뉴 검색폼 wace 일치 + 공통 UX(초기화·date input) 정비
- 검색 폼 정합성: wace JSP `<!-- 주석처리된 검색필터 -->` 블록까지 잘못 이식했던 부분 정정
  - 견적: 11→7개 (제품구분/국내해외/유무상/요청납기 제거)
  - 주문: 13→9개 (제품구분/국내해외/유무상/견적환종 제거)
  - 매출: 10→11개 (출하지시상태 제거 + 제품구분·국내/해외 추가, JSP 순서로 재배치)
  - 판매: 변경 없음 (원본 그대로 일치)
- 매출 백엔드: SaleListFilter에 productType/nation 추가, getRevenueList에 partObjId/serialNo/orderDate/productType/nation 5개 필터 처리
- 공통 UX
  - 초기화 버튼을 4개 메뉴 동일하게 통일 (variant=ghost, 버튼 영역 끝)
  - <Input type="date">는 빈 값 placeholder 숨김 + 캘린더 아이콘 숨김 + 영역 클릭으로 picker 자동(showPicker)
- 신규 공통 컴포넌트: CommCodeSelect/CustomerSelect/CustomerSearchDialog/PartSelect/ItemSearchDialog + backend salesCommonRoutes
- 문서: 01/02/04 검색 폼 표를 활성/비활성 분리 형식으로 정정, README에 8. 공통 UX 규칙 섹션 신설

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:42:16 +09:00

296 lines
10 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Loader2, Plus, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
export interface ItemRow {
objid?: string | number;
id?: string | number;
item_number?: string;
item_code?: string;
item_name?: string;
size?: string;
spec?: string;
standard?: string;
unit?: string;
unit_label?: string;
selling_price?: number | string;
standard_price?: number | string;
[key: string]: any;
}
interface ItemSearchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (items: ItemRow[]) => void;
multiSelect?: boolean;
title?: string;
description?: string;
}
const PAGE_SIZE = 20;
const fmt = (val: string | number) => {
const num = String(val).replace(/[^\d.-]/g, "");
if (!num) return "0";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
export function ItemSearchDialog({
open, onOpenChange, onSelect,
multiSelect = true,
title = "품목 검색",
description = "추가할 품목을 검색하여 선택하세요.",
}: ItemSearchDialogProps) {
const [keyword, setKeyword] = useState("");
const [results, setResults] = useState<ItemRow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMap, setSelectedMap] = useState<Map<string, ItemRow>>(new Map());
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const [unitMap, setUnitMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get(`/table-categories/item_info/unit/values`);
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (arr: any[]) => {
for (const v of arr) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setUnitMap(map);
}
} catch { /* skip */ }
})();
}, []);
const resolveUnit = (code: string) => unitMap[code] || code || "EA";
useEffect(() => {
if (open) {
setKeyword("");
setSelectedMap(new Map());
setPage(1);
void search(1, "");
}
}, [open]);
const search = async (p: number, kw?: string) => {
const k = kw ?? keyword;
setLoading(true);
try {
const filters: any[] = [];
if (k) filters.push({ columnName: "item_name", operator: "contains", value: k });
const res = await apiClient.post("/table-management/tables/item_info/data", {
page: p, size: PAGE_SIZE,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
setResults(resData?.data || resData?.rows || []);
setTotal(resData?.total || resData?.totalCount || 0);
} catch {
toast.error("품목 조회 실패");
} finally {
setLoading(false);
}
};
const rowKey = (row: ItemRow) =>
String(row.item_number || row.objid || row.id || "");
const enrich = (row: ItemRow): ItemRow => ({
...row,
unit_label: resolveUnit(row.unit ?? ""),
});
const toggleSelect = (row: ItemRow) => {
const key = rowKey(row);
if (!multiSelect) {
onSelect([enrich(row)]);
onOpenChange(false);
return;
}
setSelectedMap((prev) => {
const next = new Map(prev);
if (next.has(key)) next.delete(key); else next.set(key, row);
return next;
});
};
const handleApply = () => {
const arr = Array.from(selectedMap.values()).map(enrich);
if (arr.length === 0) {
toast.info("품목을 선택하세요.");
return;
}
onSelect(arr);
onOpenChange(false);
};
const goPage = (p: number) => {
if (p < 1 || p > totalPages) return;
setPage(p);
void search(p);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-3xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
placeholder="품명/품목코드 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setPage(1); void search(1); } }}
className="text-sm"
/>
<Button
onClick={() => { setPage(1); void search(1); }}
disabled={loading}
className="shrink-0 gap-1"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
</Button>
</div>
<div className="flex-1 overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{multiSelect && <th className="w-10 px-2 py-2 text-center"></th>}
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="w-16 px-2 py-2 text-center"></th>
<th className="w-24 px-2 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{results.length === 0 ? (
<tr>
<td colSpan={multiSelect ? 6 : 5} className="px-4 py-8 text-center text-gray-400">
{loading ? "검색 중..." : "검색 결과가 없습니다."}
</td>
</tr>
) : results.map((row, i) => {
const key = rowKey(row) || String(i);
const checked = selectedMap.has(key);
return (
<tr
key={key}
className={cn(
"cursor-pointer border-t",
checked ? "bg-blue-50" : "hover:bg-gray-50",
)}
onClick={() => toggleSelect(row)}
>
{multiSelect && (
<td className="px-2 py-1.5 text-center">
<input type="checkbox" checked={checked} readOnly className="accent-blue-500" />
</td>
)}
<td className="px-2 py-1.5">{row.item_number || row.item_code || "-"}</td>
<td className="px-2 py-1.5">{row.item_name || "-"}</td>
<td className="px-2 py-1.5">{row.size || row.spec || row.standard || "-"}</td>
<td className="px-2 py-1.5 text-center">{resolveUnit(row.unit ?? "")}</td>
<td className="px-2 py-1.5 text-right">
{fmt(String(row.selling_price ?? row.standard_price ?? 0))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{total.toLocaleString()}</span>
<span>{multiSelect ? ` · 선택 ${selectedMap.size}` : ""}</span>
</div>
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => goPage(1)}
disabled={page <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronsLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => goPage(page - 1)}
disabled={page <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const p = start + i;
if (p > totalPages) return null;
return (
<button
key={p}
onClick={() => goPage(p)}
className={cn(
"h-7 w-7 flex items-center justify-center rounded text-xs",
p === page ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted",
)}
>
{p}
</button>
);
})}
<button
onClick={() => goPage(page + 1)}
disabled={page >= totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
onClick={() => goPage(totalPages)}
disabled={page >= totalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronsRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
<DialogFooter className="shrink-0 flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
{multiSelect && (
<Button onClick={handleApply} disabled={selectedMap.size === 0} className="gap-1">
<Plus className="h-4 w-4" />
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { fmt as fmtItemPrice };