Enhance receiving and sales management functionality

- Updated the getItems function in receivingController to include a division filter, allowing for more refined item retrieval based on division.
- Added state management for continuous input in EquipmentInfoPage, enabling users to clear forms after saving or keep them open for further entries.
- Implemented deletion functionality for selected customer mappings in SalesItemPage, improving data management capabilities.
- Enhanced ShippingOrderPage to visually indicate selected orders, improving user interaction and experience.

These changes collectively improve the efficiency and usability of the receiving and sales management features.
This commit is contained in:
kjs
2026-03-31 14:34:43 +09:00
parent b1b10f5e27
commit 18f78a6702
12 changed files with 326 additions and 61 deletions

View File

@@ -783,7 +783,7 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page, pageSize } = req.query;
const { keyword, page, pageSize, division } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
@@ -800,6 +800,12 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
paramIdx++;
}
if (division) {
conditions.push(`division ILIKE $${paramIdx}`);
params.push(`%${division}%`);
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();

View File

@@ -105,9 +105,11 @@ export default function EquipmentInfoPage() {
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
@@ -294,7 +296,13 @@ export default function EquipmentInfoPage() {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
toast.success("추가되었습니다.");
if (inspectionContinuous) {
setInspectionForm({});
} else {
setInspectionModalOpen(false);
}
refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
@@ -347,7 +355,13 @@ export default function EquipmentInfoPage() {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
toast.success("추가되었습니다.");
if (consumableContinuous) {
setConsumableForm({});
} else {
setConsumableModalOpen(false);
}
refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
@@ -609,8 +623,16 @@ export default function EquipmentInfoPage() {
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -659,8 +681,16 @@ export default function EquipmentInfoPage() {
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -42,6 +42,7 @@ import {
ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import {
getReceivingList,
createReceiving,
@@ -140,13 +141,23 @@ export default function ReceivingPage() {
const [sourcePageSize, setSourcePageSize] = useState(20);
const [sourceTotalCount, setSourceTotalCount] = useState(0);
// 날짜 초기화
// 구매관리 division 코드 (라벨 기준 조회)
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
// 날짜 초기화 + 구매관리 division 코드 로드
useEffect(() => {
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
// division 카테고리에서 "구매관리" 라벨의 코드 조회
apiClient.get("/table-categories/item_info/division/values").then((res) => {
const vals = res.data?.data || [];
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
if (found) setPurchaseDivisionCode(found.value_code || found.code);
}).catch(() => {});
}, []);
// 목록 조회
@@ -243,7 +254,7 @@ export default function ReceivingPage() {
setSourceTotalCount(res.totalCount || 0);
}
} else {
const res = await getItemSources(params);
const res = await getItemSources({ ...params, division: purchaseDivisionCode || undefined });
if (res.success) {
setItems(res.data);
setSourceTotalCount(res.totalCount || 0);

View File

@@ -95,8 +95,10 @@ export default function CustomerManagementPage() {
// 우측: 품목 단가
const [priceItems, setPriceItems] = useState<any[]>([]);
const [priceLoading, setPriceLoading] = useState(false);
const [priceCheckedIds, setPriceCheckedIds] = useState<string[]>([]);
// 우측: 납품처
const [deliveryItems, setDeliveryItems] = useState<any[]>([]);
const [deliveryCheckedIds, setDeliveryCheckedIds] = useState<string[]>([]);
// 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용)
const [editItemData, setEditItemData] = useState<any>(null);
@@ -254,7 +256,8 @@ export default function CustomerManagementPage() {
const selectedCustomer = customers.find((c) => c.id === selectedCustomerId);
useEffect(() => {
if (!selectedCustomer?.customer_code) { setPriceItems([]); return; }
if (!selectedCustomer?.customer_code) { setPriceItems([]); setPriceCheckedIds([]); return; }
setPriceCheckedIds([]);
const fetchItems = async () => {
setPriceLoading(true);
try {
@@ -345,7 +348,8 @@ export default function CustomerManagementPage() {
// 납품처 조회
useEffect(() => {
if (!selectedCustomer?.customer_code) { setDeliveryItems([]); return; }
if (!selectedCustomer?.customer_code) { setDeliveryItems([]); setDeliveryCheckedIds([]); return; }
setDeliveryCheckedIds([]);
const fetchDelivery = async () => {
setDeliveryLoading(true);
try {
@@ -786,6 +790,69 @@ export default function CustomerManagementPage() {
}
};
// 우측: 품목 매핑 삭제 (관련 단가도 함께 삭제)
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑을 삭제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
// 매핑 삭제 — 관련 단가는 서버에서 cascade 또는 별도 삭제
// 먼저 관련 단가 삭제 시도
for (const mappingId of priceCheckedIds) {
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,
});
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 })),
});
}
} catch { /* skip */ }
}
// 매핑 삭제
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
setPriceCheckedIds([]);
// 우측 새로고침
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch {
toast.error("삭제에 실패했습니다.");
}
};
// 우측: 납품처 삭제
const handleDeliveryDelete = async () => {
if (deliveryCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${deliveryCheckedIds.length}개 납품처를 삭제하시겠습니까?`, {
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DELIVERY_TABLE}/delete`, {
data: deliveryCheckedIds.map((id) => ({ id })),
});
toast.success(`${deliveryCheckedIds.length}개 납품처가 삭제되었습니다.`);
setDeliveryCheckedIds([]);
// 우측 새로고침
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch {
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (customers.length === 0) return;
@@ -967,16 +1034,28 @@ export default function CustomerManagementPage() {
</div>
<div className="flex gap-1.5">
{rightTab === "items" && (
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={priceCheckedIds.length === 0}
onClick={handlePriceItemDelete}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "delivery" && (
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
onClick={() => { setDeliveryForm({}); setDeliveryModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
onClick={() => { setDeliveryForm({}); setDeliveryModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={deliveryCheckedIds.length === 0}
onClick={handleDeliveryDelete}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -993,6 +1072,9 @@ export default function CustomerManagementPage() {
data={priceItems}
loading={priceLoading}
showRowNumber={false}
showCheckbox
checkedIds={priceCheckedIds}
onCheckedChange={setPriceCheckedIds}
tableName={MAPPING_TABLE}
emptyMessage="등록된 품목이 없습니다"
onRowDoubleClick={(row) => openEditItem(row)}
@@ -1012,6 +1094,9 @@ export default function CustomerManagementPage() {
data={deliveryItems}
loading={deliveryLoading}
showRowNumber={false}
showCheckbox
checkedIds={deliveryCheckedIds}
onCheckedChange={setDeliveryCheckedIds}
tableName={DELIVERY_TABLE}
emptyMessage="등록된 납품처가 없습니다"
/>

View File

@@ -209,6 +209,9 @@ export default function SalesOrderPage() {
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// division 기본값: 라벨이 "영업관리"인 코드로 설정
const salesDiv = (optMap["item_division"] || []).find((o: any) => o.label === "영업관리");
if (salesDiv) setItemSearchDivision(salesDiv.code);
};
loadCategories();
}, []);

View File

@@ -79,6 +79,7 @@ export default function SalesItemPage() {
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [customerCheckedIds, setCustomerCheckedIds] = useState<string[]>([]);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string; isDefault?: boolean }[]>>({});
@@ -200,12 +201,13 @@ export default function SalesItemPage() {
// 우측: 거래처 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
if (!selectedItem?.item_number) { setCustomerItems([]); setCustomerCheckedIds([]); return; }
setCustomerCheckedIds([]);
const itemKey = selectedItem.item_number;
const fetchCustomerItems = async () => {
setCustomerLoading(true);
try {
// customer_item_mapping에서 해당 품목의 매핑 조회
// 1. customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
@@ -213,7 +215,7 @@ export default function SalesItemPage() {
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// customer_id → customer_mng 조인 (거래처명)
// 2. customer_id → customer_mng 조인 (거래처명)
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
let custMap: Record<string, any> = {};
if (custIds.length > 0) {
@@ -229,11 +231,54 @@ export default function SalesItemPage() {
} catch { /* skip */ }
}
setCustomerItems(mappings.map((m: any) => ({
...m,
customer_code: m.customer_id,
customer_name: custMap[m.customer_id]?.customer_name || "",
})));
// 3. customer_item_prices 조회 (단가 정보)
let allPrices: any[] = [];
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
autoFilter: true,
});
allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
} catch { /* skip */ }
}
// 4. 거래처별 중복 제거 + 오늘 날짜 기준 단가 매칭
const priceResolve = (col: string, code: string) => {
if (!code) return "";
return priceCategoryOptions[col]?.find((o: any) => o.code === code)?.label || code;
};
const today = new Date().toISOString().split("T")[0];
const seenCustIds = new Set<string>();
// customer_id로 정렬하여 같은 거래처끼리 묶기
const sortedMappings = [...mappings].sort((a: any, b: any) => (a.customer_id || "").localeCompare(b.customer_id || ""));
setCustomerItems(sortedMappings.map((m: any) => {
const custKey = m.customer_id || "";
const isFirstOfGroup = !seenCustIds.has(custKey);
if (custKey) seenCustIds.add(custKey);
// 오늘 날짜에 해당하는 단가
const custPriceList = allPrices.filter((p: any) => p.customer_id === custKey);
const todayPrice = custPriceList.find((p: any) =>
(!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today)
) || custPriceList[0] || {};
return {
...m,
customer_code: isFirstOfGroup ? custKey : "",
customer_name: isFirstOfGroup ? (custMap[custKey]?.customer_name || "") : "",
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_item_name || "",
base_price: todayPrice.base_price || "",
calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "",
currency_code: priceResolve("currency_code", todayPrice.currency_code || ""),
};
}));
} catch (err) {
console.error("거래처 조회 실패:", err);
} finally {
@@ -241,7 +286,7 @@ export default function SalesItemPage() {
}
};
fetchCustomerItems();
}, [selectedItem?.item_number]);
}, [selectedItem?.item_number, priceCategoryOptions]);
// 거래처 검색
const searchCustomers = async () => {
@@ -523,6 +568,46 @@ export default function SalesItemPage() {
}
};
// 우측: 거래처 매핑 삭제
const handleCustomerMappingDelete = async () => {
if (customerCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${customerCheckedIds.length}개 거래처 매핑을 삭제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
// 관련 단가 삭제
for (const mappingId of customerCheckedIds) {
try {
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
}
// 매핑 삭제
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: customerCheckedIds.map((id) => ({ id })),
});
toast.success(`${customerCheckedIds.length}개 거래처 매핑이 삭제되었습니다.`);
setCustomerCheckedIds([]);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch {
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
@@ -598,10 +683,16 @@ export default function SalesItemPage() {
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={customerCheckedIds.length === 0}
onClick={handleCustomerMappingDelete}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
@@ -614,6 +705,9 @@ export default function SalesItemPage() {
data={customerItems}
loading={customerLoading}
showRowNumber={false}
showCheckbox
checkedIds={customerCheckedIds}
onCheckedChange={setCustomerCheckedIds}
emptyMessage="등록된 거래처가 없습니다"
onRowDoubleClick={(row) => openEditCust(row)}
/>

View File

@@ -76,6 +76,7 @@ export default function ShippingOrderPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
@@ -526,7 +527,7 @@ export default function ShippingOrderPage() {
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
if (items.length === 0) {
return (
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableRow key={order.id} className={cn("cursor-pointer hover:bg-muted/50", selectedOrderId === order.id && "bg-primary/5")} onClick={() => setSelectedOrderId(order.id)} onDoubleClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
@@ -551,7 +552,7 @@ export default function ShippingOrderPage() {
);
}
return items.map((item: any, itemIdx: number) => (
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableRow key={`${order.id}-${item.id}`} className={cn("cursor-pointer hover:bg-muted/50", selectedOrderId === order.id && "bg-primary/5")} onClick={() => setSelectedOrderId(order.id)} onDoubleClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);

View File

@@ -175,6 +175,13 @@ export function DynamicSearchFilter({
width: f.width,
}));
setActiveFilters(active);
// allColumns도 동기화하여 설정 모달에서 일관된 상태 표시
setAllColumns((prev) =>
prev.map((col) => {
const ext = externalFilterConfig.find((f) => f.columnName === col.columnName);
return ext ? { ...col, enabled: ext.enabled, filterType: ext.filterType, width: ext.width } : col;
})
);
}, [externalFilterConfig]);
// select 타입 필터의 옵션 로드

View File

@@ -162,6 +162,7 @@ interface SourceParams {
keyword?: string;
page?: number;
pageSize?: number;
division?: string;
}
export async function getPurchaseOrderSources(params?: SourceParams) {

View File

@@ -320,11 +320,18 @@ function TreeNodeRow({
const renderCell = (col: BomColumnConfig) => {
const value = node.data[col.key] ?? "";
// 소스 표시 컬럼 (읽기 전용)
// 소스 표시 컬럼 (읽기 전용) — 카테고리 코드인 경우 라벨로 변환
if (col.isSourceDisplay) {
let displayValue = value;
if (col.inputType === "category" && mainTableName) {
const categoryRef = `${mainTableName}.${col.key}`;
const options = categoryOptionsMap[categoryRef] || [];
const found = options.find((opt) => opt.value === String(value));
if (found) displayValue = found.label;
}
return (
<span className="truncate text-xs" title={String(value)}>
{value || "-"}
<span className="truncate text-xs" title={String(displayValue)}>
{displayValue || "-"}
</span>
);
}
@@ -352,11 +359,18 @@ function TreeNodeRow({
);
}
// 편집 불가능 컬럼
// 편집 불가능 컬럼 — 카테고리 코드인 경우 라벨로 변환
if (col.editable === false) {
let displayValue = value;
if (col.inputType === "category" && mainTableName) {
const categoryRef = `${mainTableName}.${col.key}`;
const options = categoryOptionsMap[categoryRef] || [];
const found = options.find((opt) => opt.value === String(value));
if (found) displayValue = found.label;
}
return (
<span className="text-muted-foreground truncate text-xs">
{value || "-"}
{displayValue || "-"}
</span>
);
}

View File

@@ -43,6 +43,7 @@ interface TreeColumnDef {
visible?: boolean;
hidden?: boolean;
isSourceDisplay?: boolean;
inputType?: string;
}
interface BomTreeComponentProps {
@@ -141,22 +142,27 @@ export function BomTreeComponent({
const showHistory = features.showHistory !== false;
const showVersion = features.showVersion !== false;
// 카테고리 라벨 캐시 (process_type 등)
// 카테고리 라벨 캐시 (inputType === "category"인 모든 컬럼)
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const categoryColumns = displayColumns.filter((c) => c.inputType === "category");
if (categoryColumns.length === 0) return;
const loadLabels = async () => {
try {
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values?includeInactive=true`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
}
} catch { /* 무시 */ }
for (const col of categoryColumns) {
try {
const res = await apiClient.get(`/table-categories/${detailTable}/${col.key}/values?includeInactive=true`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
setCategoryLabels((prev) => ({ ...prev, [col.key]: map }));
}
} catch { /* 무시 */ }
}
};
loadLabels();
}, [detailTable]);
}, [detailTable, displayColumns]);
// ─── 데이터 로드 ───
@@ -518,9 +524,10 @@ export function BomTreeComponent({
);
}
if (col.key === "process_type" && value) {
const label = categoryLabels.process_type?.[String(value)] || String(value);
return <span>{label}</span>;
// 카테고리 타입 컬럼: 코드 → 라벨 변환
if (col.inputType === "category" && categoryLabels[col.key]) {
const label = categoryLabels[col.key][String(value)] || String(value || "");
return <span>{label || "-"}</span>;
}
if (col.key === "loss_rate") {
@@ -538,7 +545,14 @@ export function BomTreeComponent({
}
if (col.key === "unit") {
return <span className="text-muted-foreground">{value || "-"}</span>;
const unitLabel = categoryLabels[col.key]?.[String(value)] || value;
return <span className="text-muted-foreground">{unitLabel || "-"}</span>;
}
// fallback: 카테고리 라벨이 로드된 컬럼이면 라벨로 변환
if (categoryLabels[col.key] && value) {
const label = categoryLabels[col.key][String(value)] || String(value);
return <span className="text-muted-foreground">{label || "-"}</span>;
}
return <span className="text-muted-foreground">{value ?? "-"}</span>;
@@ -577,7 +591,7 @@ export function BomTreeComponent({
};
return (
<div className="flex h-full flex-col bg-white">
<div className="flex h-full flex-col bg-background">
{/* 헤더 (실제 화면과 동일 구조) */}
<div className="border-b px-5 py-3">
<div className="flex items-center gap-2.5">
@@ -770,7 +784,7 @@ export function BomTreeComponent({
// ─── 메인 렌더링 ───
return (
<div className="flex h-full flex-col bg-white">
<div className="flex h-full flex-col bg-background">
{/* 헤더 정보 */}
{features.showHeader !== false && headerInfo && (
<div className="border-b px-5 py-3">
@@ -959,11 +973,11 @@ export function BomTreeComponent({
const displayDepth = isRoot ? 0 : depth;
const lvlDepthBg = isRoot
? "border-border bg-primary/10/50 font-medium hover:bg-primary/10/70"
? "border-border bg-primary/10 font-medium hover:bg-primary/20"
: selectedNodeId === node.id
? "border-border bg-primary/5"
: depth === 1
? "border-border bg-white hover:bg-muted/60"
? "border-border bg-background hover:bg-muted/60"
: depth === 2
? "border-border bg-muted/40 hover:bg-muted/50"
: depth >= 3
@@ -1053,11 +1067,11 @@ export function BomTreeComponent({
const ItemIcon = getItemIcon(itemType);
const depthBg = isRoot
? "border-border bg-primary/10/50 font-medium hover:bg-primary/10/70"
? "border-border bg-primary/10 font-medium hover:bg-primary/20"
: isSelected
? "border-border bg-primary/5"
: depth === 1
? "border-border bg-white hover:bg-muted/60"
? "border-border bg-background hover:bg-muted/60"
: depth === 2
? "border-border bg-muted/40 hover:bg-muted/50"
: depth >= 3

View File

@@ -1418,7 +1418,6 @@
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},