489fa50d11
- 검색 폼 정합성: 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>
296 lines
10 KiB
TypeScript
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 };
|