feat: Implement searchable category comboboxes and enhance item management
- Added searchable category combobox and multi-category combobox components to improve item selection in the purchase and sales item pages. - Updated the supplier management page to utilize useCallback for item search, enhancing performance and responsiveness. - Implemented real-time search functionality for item selection, ensuring a smoother user experience. - Enhanced the handling of item mappings and prices, allowing for soft deletion of supplier connections while retaining data integrity. These changes aim to improve the overall user experience by providing more intuitive item management and selection processes across multiple company implementations.
This commit is contained in:
@@ -814,14 +814,13 @@ export default function CustomerManagementPage() {
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
const searchItems = async () => {
|
||||
const searchItems = useCallback(async () => {
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
|
||||
const filters: any[] = salesCode
|
||||
? [{ columnName: "division", operator: "contains", value: salesCode }]
|
||||
: [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters },
|
||||
@@ -830,21 +829,41 @@ export default function CustomerManagementPage() {
|
||||
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setItemTotalCount(allItems.length);
|
||||
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
|
||||
const kw = itemSearchKeyword.toLowerCase();
|
||||
const seenNumbers = new Set<string>();
|
||||
const deduped = allItems.filter((item: any) => {
|
||||
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
|
||||
if (item.item_number && seenNumbers.has(item.item_number)) return false;
|
||||
if (item.item_number) seenNumbers.add(item.item_number);
|
||||
if (kw) {
|
||||
const name = (item.item_name || "").toLowerCase();
|
||||
const code = (item.item_number || "").toLowerCase();
|
||||
if (!name.includes(kw) && !code.includes(kw)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setItemSearchResults(deduped);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
}, [itemSearchKeyword, priceItems]);
|
||||
|
||||
// 실시간 검색 (2글자 이상)
|
||||
useEffect(() => {
|
||||
if (!itemSelectOpen) return;
|
||||
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
|
||||
searchItems();
|
||||
}, [itemSearchKeyword, itemSelectOpen]);
|
||||
|
||||
// 품목 선택 완료 → 상세 입력 모달로 전환
|
||||
const goToItemDetail = () => {
|
||||
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
const raw = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
if (raw.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
const seenKeys = new Set<string>();
|
||||
const selected = raw.filter((i) => {
|
||||
const k = i.item_number || i.id;
|
||||
if (seenKeys.has(k)) return false;
|
||||
seenKeys.add(k);
|
||||
return true;
|
||||
});
|
||||
setSelectedItemsForDetail(selected);
|
||||
const mappings: typeof itemMappings = {};
|
||||
const prices: typeof itemPrices = {};
|
||||
@@ -976,6 +995,7 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
mappingRows = allMappings
|
||||
@@ -996,7 +1016,8 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
|
||||
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
||||
priceRows = allPriceData.map((p: any) => ({
|
||||
_id: `p_existing_${p.id}`,
|
||||
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
|
||||
@@ -1034,8 +1055,11 @@ export default function CustomerManagementPage() {
|
||||
const isEditingExisting = !!editItemData;
|
||||
setSaving(true);
|
||||
try {
|
||||
const processedKeys = new Set<string>();
|
||||
for (const item of selectedItemsForDetail) {
|
||||
const itemKey = item.item_number || item.id;
|
||||
if (processedKeys.has(itemKey)) continue;
|
||||
processedKeys.add(itemKey);
|
||||
const mappingRows = itemMappings[itemKey] || [];
|
||||
|
||||
if (isEditingExisting && editItemData?.id) {
|
||||
@@ -1048,6 +1072,7 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
});
|
||||
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
@@ -1097,12 +1122,13 @@ export default function CustomerManagementPage() {
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
|
||||
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 단가 upsert
|
||||
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
const usedPriceIds = new Set<string>();
|
||||
for (let pi = 0; pi < priceRows.length; pi++) {
|
||||
@@ -1172,7 +1198,7 @@ export default function CustomerManagementPage() {
|
||||
}
|
||||
|
||||
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
p.base_price || p.start_date || p.currency_code || p.base_price_type
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
|
||||
@@ -1204,40 +1230,63 @@ export default function CustomerManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 매핑 삭제
|
||||
// 품목 매핑 해제 — 선택한 품목의 모든 매핑 + 단가에서 customer_id를 null 처리
|
||||
const handlePriceItemDelete = async () => {
|
||||
if (priceCheckedIds.length === 0) return;
|
||||
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑을 삭제하시겠습니까?`, {
|
||||
description: "관련된 단가 정보도 함께 삭제됩니다.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결을 해제하시겠습니까?`, {
|
||||
description: "해당 품목의 거래처 연결이 해제됩니다. (데이터는 유지)",
|
||||
variant: "destructive", confirmText: "해제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
for (const mappingId of priceCheckedIds) {
|
||||
const itemIds = priceCheckedIds.map((mid) => {
|
||||
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
|
||||
return group?.master.item_id || group?.master.item_number || "";
|
||||
}).filter(Boolean);
|
||||
|
||||
for (const itemId of itemIds) {
|
||||
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
for (const m of allMappings) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: m.id },
|
||||
updatedData: { customer_id: null },
|
||||
});
|
||||
}
|
||||
|
||||
// 해당 품목의 모든 단가 조회 → customer_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
|
||||
autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
if (prices.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
|
||||
data: prices.map((p: any) => ({ id: p.id })),
|
||||
for (const p of prices) {
|
||||
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
|
||||
originalData: { id: p.id },
|
||||
updatedData: { customer_id: null },
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: priceCheckedIds.map((id) => ({ id })),
|
||||
});
|
||||
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
|
||||
|
||||
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
|
||||
setPriceCheckedIds([]);
|
||||
const cid = selectedCustomerId;
|
||||
setSelectedCustomerId(null);
|
||||
setTimeout(() => setSelectedCustomerId(cid), 50);
|
||||
} catch {
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
toast.error("연결 해제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2393,7 +2442,7 @@ export default function CustomerManagementPage() {
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<div className="overflow-auto h-[350px] border rounded-lg">
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted h-10">
|
||||
|
||||
Reference in New Issue
Block a user