diff --git a/frontend/app/(main)/COMPANY_10/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_10/sales/customer/page.tsx index 552447cd..33159c85 100644 --- a/frontend/app/(main)/COMPANY_10/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/customer/page.tsx @@ -715,7 +715,7 @@ export default function CustomerManagementPage() { const handleCustomerSave = async () => { if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; } if (!customerForm.status) { toast.error("상태는 필수입니다."); return; } - const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]); + const errors = validateForm(customerForm, ["business_number"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); @@ -1877,35 +1877,6 @@ export default function CustomerManagementPage() { -
- - setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="거래처담당자" - className="h-9" - /> -
-
- - handleFormChange("contact_phone", e.target.value)} - placeholder="010-0000-0000" - className={cn("h-9", formErrors.contact_phone && "border-destructive")} - /> - {formErrors.contact_phone &&

{formErrors.contact_phone}

} -
-
- - handleFormChange("email", e.target.value)} - placeholder="example@email.com" - className={cn("h-9", formErrors.email && "border-destructive")} - /> - {formErrors.email &&

{formErrors.email}

} -
{ - if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } - const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); - const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; - const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { + if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } + const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; try { + // 1. 선택한 디테일 삭제 + await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { + data: checkedIds.map((id) => ({ id })), + }); + + // 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제 + const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); + const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; for (const orderNo of orderNos) { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), + const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || []; + if (remaining.length === 0) { + // 디테일 0건 → 마스터 삭제 + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + if (masters.length > 0) { + await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { + data: masters.map((m: any) => ({ id: m.id })), + }); + } } } toast.success("삭제되었습니다."); @@ -918,7 +933,7 @@ export default function SalesOrderPage() { {/* 데이터 테이블 (플랫 리스트) */}
- +
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() { -
- - setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="거래처담당자" - className="h-9" - /> -
-
- - handleFormChange("contact_phone", e.target.value)} - placeholder="010-0000-0000" - className={cn("h-9", formErrors.contact_phone && "border-destructive")} - /> - {formErrors.contact_phone &&

{formErrors.contact_phone}

} -
-
- - handleFormChange("email", e.target.value)} - placeholder="example@email.com" - className={cn("h-9", formErrors.email && "border-destructive")} - /> - {formErrors.email &&

{formErrors.email}

} -
{ - if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } - const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); - const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; - const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { + if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } + const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; try { + // 1. 선택한 디테일 삭제 + await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { + data: checkedIds.map((id) => ({ id })), + }); + + // 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제 + const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); + const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; for (const orderNo of orderNos) { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), + const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || []; + if (remaining.length === 0) { + // 디테일 0건 → 마스터 삭제 + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + if (masters.length > 0) { + await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { + data: masters.map((m: any) => ({ id: m.id })), + }); + } } } toast.success("삭제되었습니다."); @@ -918,7 +933,7 @@ export default function SalesOrderPage() { {/* 데이터 테이블 (플랫 리스트) */}
-
+
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() { -
- - setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="거래처담당자" - className="h-9" - /> -
-
- - handleFormChange("contact_phone", e.target.value)} - placeholder="010-0000-0000" - className={cn("h-9", formErrors.contact_phone && "border-destructive")} - /> - {formErrors.contact_phone &&

{formErrors.contact_phone}

} -
-
- - handleFormChange("email", e.target.value)} - placeholder="example@email.com" - className={cn("h-9", formErrors.email && "border-destructive")} - /> - {formErrors.email &&

{formErrors.email}

} -
{ - if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } - const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); - const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; - const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { + if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } + const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; try { + // 1. 선택한 디테일 삭제 + await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { + data: checkedIds.map((id) => ({ id })), + }); + + // 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제 + const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); + const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; for (const orderNo of orderNos) { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), + const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || []; + if (remaining.length === 0) { + // 디테일 0건 → 마스터 삭제 + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + if (masters.length > 0) { + await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { + data: masters.map((m: any) => ({ id: m.id })), + }); + } } } toast.success("삭제되었습니다."); @@ -918,7 +933,7 @@ export default function SalesOrderPage() { {/* 데이터 테이블 (플랫 리스트) */}
-
+
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() { -
- - setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="거래처담당자" - className="h-9" - /> -
-
- - handleFormChange("contact_phone", e.target.value)} - placeholder="010-0000-0000" - className={cn("h-9", formErrors.contact_phone && "border-destructive")} - /> - {formErrors.contact_phone &&

{formErrors.contact_phone}

} -
-
- - handleFormChange("email", e.target.value)} - placeholder="example@email.com" - className={cn("h-9", formErrors.email && "border-destructive")} - /> - {formErrors.email &&

{formErrors.email}

} -
{ const num = val.replace(/[^\d.-]/g, ""); if (!num) return ""; @@ -52,161 +42,256 @@ const formatNumber = (val: string) => { }; const parseNumber = (val: string) => val.replace(/,/g, ""); -// 좌측: 수주 마스터 집계 목록 -const LEFT_COLUMNS: DataGridColumn[] = [ - { key: "order_no", label: "수주번호", width: "w-[120px]" }, - { key: "partner_name", label: "거래처", minWidth: "min-w-[100px]" }, - { key: "item_count", label: "품목수", width: "w-[60px]", align: "right" }, - { key: "total_qty", label: "총수량", width: "w-[80px]", formatNumber: true, align: "right" }, - { key: "total_ship_qty", label: "출하량", width: "w-[70px]", formatNumber: true, align: "right" }, - { key: "total_balance", label: "잔량", width: "w-[70px]", formatNumber: true, align: "right" }, - { key: "total_amount", label: "총금액", width: "w-[100px]", formatNumber: true, align: "right" }, - { key: "due_date", label: "납기일", width: "w-[100px]" }, - { key: "status", label: "상태", width: "w-[70px]" }, +// 플랫 테이블 컬럼 정의 (마스터+디테일 통합) +const FLAT_COLUMNS = [ + { key: "order_no", label: "수주번호", source: "master" }, + { key: "partner_id", label: "거래처", source: "master" }, + { key: "order_date", label: "수주일", source: "master" }, + { key: "part_code", label: "품번", source: "detail" }, + { key: "part_name", label: "품명", source: "detail" }, + { key: "spec", label: "규격", source: "detail" }, + { key: "unit", label: "단위", source: "detail" }, + { key: "qty", label: "수량", source: "detail" }, + { key: "ship_qty", label: "출하수량", source: "detail" }, + { key: "balance_qty", label: "잔량", source: "detail" }, + { key: "unit_price", label: "단가", source: "detail" }, + { key: "amount", label: "금액", source: "detail" }, + { key: "due_date", label: "납기일", source: "detail" }, + { key: "memo", label: "메모", source: "master" }, ]; -// 우측: 품목 상세 (가로/세로/두께/면적 포함) -const RIGHT_COLUMNS: DataGridColumn[] = [ - { key: "division", label: "구분", width: "w-[70px]" }, - { key: "part_name", label: "품명", minWidth: "min-w-[120px]" }, - { key: "spec", label: "규격", width: "w-[100px]" }, - { key: "width", label: "가로", width: "w-[65px]", formatNumber: true, align: "right" }, - { key: "height", label: "세로", width: "w-[65px]", formatNumber: true, align: "right" }, - { key: "thickness", label: "두께", width: "w-[60px]", align: "right" }, - { key: "area", label: "면적", width: "w-[70px]", align: "right" }, - { key: "unit", label: "단위", width: "w-[50px]" }, - { key: "qty", label: "수량", width: "w-[70px]", formatNumber: true, align: "right" }, - { key: "ship_qty", label: "출하", width: "w-[60px]", formatNumber: true, align: "right" }, - { key: "balance_qty", label: "잔량", width: "w-[60px]", formatNumber: true, align: "right" }, - { key: "unit_price", label: "단가", width: "w-[85px]", formatNumber: true, align: "right" }, - { key: "amount", label: "금액", width: "w-[95px]", formatNumber: true, align: "right" }, - { key: "due_date", label: "납기일", width: "w-[100px]" }, - { key: "memo", label: "비고", width: "w-[80px]" }, -]; +const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail"); -export default function JeilGlassOrderPage() { +// 필터용 전체 키 +const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label })); + +// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15 +const TOTAL_COLS = 15; + +// 헤더 필터 Popover +function HeaderFilterPopover({ + colKey, colLabel, uniqueValues, filterValues, onToggle, onClear, +}: { + colKey: string; + colLabel: string; + uniqueValues: string[]; + filterValues: Set; + onToggle: (colKey: string, value: string) => void; + onClear: (colKey: string) => void; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const hasFilter = filterValues.size > 0; + const filteredValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) + ); + + return ( + + + + + e.stopPropagation()}> +
+
+ 필터: {colLabel} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredValues.slice(0, 100).map((val) => { + const isSelected = filterValues.has(val); + return ( +
onToggle(colKey, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredValues.length > 100 && ( +
+ ...외 {filteredValues.length - 100}개 +
+ )} +
+
+
+
+ ); +} + +export default function SalesOrderPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - - // 좌측: 수주 목록 - const [masterOrders, setMasterOrders] = useState([]); - const [allDetails, setAllDetails] = useState([]); + const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(false); const [totalCount, setTotalCount] = useState(0); - const [searchFilters, setSearchFilters] = useState([]); - const [selectedOrderNo, setSelectedOrderNo] = useState(null); - const [checkedIds, setCheckedIds] = useState([]); - // 우측: 디테일 - const [detailItems, setDetailItems] = useState([]); - const [detailLoading, setDetailLoading] = useState(false); + // 검색 필터 (DynamicSearchFilter에서 관리) + const [searchFilters, setSearchFilters] = useState([]); // 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [saving, setSaving] = useState(false); const [masterForm, setMasterForm] = useState>({}); - const [modalDetailRows, setModalDetailRows] = useState([]); + const [detailRows, setDetailRows] = useState([]); + const [allowPriceEdit, setAllowPriceEdit] = useState(true); // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemSearchDivision, setItemSearchDivision] = useState("all"); + const [itemPage, setItemPage] = useState(1); + const [itemPageSize, setItemPageSize] = useState(20); + const [itemTotalPages, setItemTotalPages] = useState(0); + const [itemTotal, setItemTotal] = useState(0); + const [itemPageInput, setItemPageInput] = useState("1"); - // 기타 + // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); - const [tableSettingsOpen, setTableSettingsOpen] = useState(false); - const [filterConfig, setFilterConfig] = useState(); + + // 출하계획 모달 + const [shippingPlanOpen, setShippingPlanOpen] = useState(false); + + // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); - // 채번 - const [numberingRuleId, setNumberingRuleId] = useState(null); + // 체크된 행 (다중선택) + const [checkedIds, setCheckedIds] = useState([]); + + // 납품처 목록 + const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]); // 테이블 설정 - const applyTableSettings = (settings: TableSettings) => { - if (settings.filters) setFilterConfig(settings.filters); - }; - useEffect(() => { - const saved = loadTableSettings(MASTER_TABLE, "jeilglass-order"); - if (saved?.filters) setFilterConfig(saved.filters); - }, []); + const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); - // 채번규칙 로드 - useEffect(() => { - const loadRule = async () => { - try { - const res = await apiClient.get(`/numbering-rules/by-column/${MASTER_TABLE}/order_no`); - const rule = res.data?.data; - if (rule?.ruleId || rule?.rule_id) { - setNumberingRuleId(rule.ruleId || rule.rule_id); - } - } catch { /* 채번규칙 없음 — fallback 사용 */ } - }; - loadRule(); - }, []); + // 헤더 필터 & 정렬 + const [headerFilters, setHeaderFilters] = useState>>({}); + const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); // 카테고리 로드 useEffect(() => { const loadCategories = async () => { + const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"]; const optMap: Record = {}; const flatten = (vals: any[]): { code: string; label: string }[] => { const result: { code: string; label: string }[] = []; for (const v of vals) { - result.push({ code: v.valueCode || v.code, label: v.valueLabel || v.label || v.valueCode }); + result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); } return result; }; - // 마스터 카테고리 - for (const col of ["sell_mode", "input_mode", "price_mode"]) { - try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); - optMap[col] = flatten(res.data?.data || []); - } catch { /* skip */ } - } - // 거래처 + const LABEL_REPLACE: Record = { + "공급업체 우선": "거래처 우선", + "공급업체우선": "거래처 우선", + }; + const dedup = (items: { code: string; label: string }[]) => { + const seen = new Set(); + return items + .map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label })) + .filter((item) => { + const key = item.label.replace(/\s/g, ""); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + await Promise.all( + catColumns.map(async (col) => { + try { + const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + optMap[col] = dedup(flatten(res.data.data)); + } + } catch { /* skip */ } + }) + ); + // 거래처 목록 try { - const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true }); - const custs = res.data?.data?.data || res.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ - code: c.customer_code, - label: c.customer_name, - })); + const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 500, autoFilter: true, + }); + const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; + optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); } catch { /* skip */ } - // 담당자 + // 사용자 목록 try { - const res = await apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 200, autoFilter: true }); - const users = res.data?.data?.data || res.data?.data?.rows || []; + const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { + page: 1, size: 500, autoFilter: true, + }); + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; optMap["manager_id"] = users.map((u: any) => ({ code: u.user_id || u.id, - label: `${u.user_name || ""}${u.position_name ? ` (${u.position_name})` : ""}`, + label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, })); } catch { /* skip */ } - // 품목 카테고리 (단위, 구분, 재질, 유형) - for (const col of ["unit", "division", "material", "type"]) { + // item_info 카테고리 + for (const col of ["unit", "material", "division", "type"]) { try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } } setCategoryOptions(optMap); + // division 기본값 + const divs = optMap["item_division"] || []; + const salesDiv = divs.find((o: any) => o.label === "영업관리") + || divs.find((o: any) => o.label === "제품") + || divs.find((o: any) => o.label === "판매품"); + if (salesDiv) setItemSearchDivision(salesDiv.code); }; loadCategories(); }, []); - // 수주 목록 조회 (디테일 전체 → order_no 그룹핑) - const fetchMasterOrders = useCallback(async () => { + // 데이터 조회 + const fetchOrders = useCallback(async () => { setLoading(true); try { - const filters: any[] = searchFilters.map((f) => ({ + const filters = searchFilters.map(f => ({ columnName: f.columnName, operator: f.operator, value: f.value, })); + const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -214,116 +299,199 @@ export default function JeilGlassOrderPage() { sort: { columnName: "order_no", order: "desc" }, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; - setAllDetails(rows); - // 마스터 조회 (거래처 정보 확보) + // order_no → sales_order_mng 조인 const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; let masterMap: Record = {}; if (orderNos.length > 0) { try { - const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: orderNos.length + 10, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, autoFilter: true, }); - const masters = mRes.data?.data?.data || mRes.data?.data?.rows || []; + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; for (const m of masters) masterMap[m.order_no] = m; } catch { /* skip */ } } - // 거래처 코드 → 이름 변환 - const resolvePartner = (code: string) => { - if (!code) return ""; - return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code; - }; - - // order_no 기준 집계 - const grouped: Record = {}; - for (const row of rows) { - const no = row.order_no; - if (!no) continue; - if (!grouped[no]) { - const master = masterMap[no] || {}; - grouped[no] = { - id: `master_${no}`, - order_no: no, - partner_name: resolvePartner(master.partner_id), - item_count: 0, - total_qty: 0, - total_ship_qty: 0, - total_balance: 0, - total_amount: 0, - due_date: row.due_date || "", - status: master.status || "", - }; - } - const g = grouped[no]; - g.item_count += 1; - g.total_qty += parseFloat(row.qty) || 0; - g.total_ship_qty += parseFloat(row.ship_qty) || 0; - g.total_balance += parseFloat(row.balance_qty) || 0; - g.total_amount += parseFloat(row.amount) || 0; - if (row.due_date && (!g.due_date || row.due_date > g.due_date)) g.due_date = row.due_date; + // part_code → item_info 조인 + const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; + let itemMap: Record = {}; + if (partCodes.length > 0) { + try { + const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: partCodes.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] }, + autoFilter: true, + }); + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + for (const item of items) itemMap[item.item_number] = item; + } catch { /* skip */ } } - const list = Object.values(grouped); - setMasterOrders(list); - setTotalCount(list.length); + + // 조인 적용 + 카테고리 코드→라벨 변환 + const resolveLabel = (key: string, code: string) => { + if (!code) return ""; + const opts = categoryOptions[key]; + if (!opts) return code; + return opts.find((o) => o.code === code)?.label || code; + }; + const data = rows.map((row: any) => { + const item = itemMap[row.part_code]; + const master = masterMap[row.order_no]; + const rawUnit = row.unit || item?.unit || ""; + return { + ...row, + part_name: row.part_name || item?.item_name || "", + spec: row.spec || item?.size || "", + material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""), + unit: resolveLabel("item_unit", rawUnit) || rawUnit, + memo: row.memo || master?.memo || "", + _master: master || {}, + }; + }); + + setOrders(data); + setTotalCount(res.data?.data?.total || data.length); } catch (err) { - console.error("수주 조회 실패:", err); toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } }, [searchFilters, categoryOptions]); - useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]); + useEffect(() => { fetchOrders(); }, [fetchOrders]); - // 통계 - const stats = useMemo(() => { - let totalAmount = 0, totalQty = 0; - for (const m of masterOrders) { - totalAmount += m.total_amount || 0; - totalQty += m.total_qty || 0; + // 카테고리 코드→라벨 변환 + const resolveLabel = useCallback((key: string, code: string) => { + if (!code) return ""; + if (key === "partner_id" || key === "manager_id" || key === "price_mode") { + return categoryOptions[key]?.find((o) => o.code === code)?.label || code; } - return { totalAmount, totalQty }; - }, [masterOrders]); + return code; + }, [categoryOptions]); - // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환) - useEffect(() => { - if (!selectedOrderNo) { setDetailItems([]); return; } - const items = allDetails - .filter((d) => d.order_no === selectedOrderNo) - .map((d) => ({ - ...d, - division: categoryOptions["item_division"]?.find((o) => o.code === d.division)?.label || d.division || "", - })); - setDetailItems(items); - }, [selectedOrderNo, allDetails, categoryOptions]); + // 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합) + const flatRows = useMemo(() => { + return orders.map((row) => { + const master = row._master || {}; + return { + ...row, + partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""), + order_date: master.order_date || row.order_date || "", + memo: row.memo || master.memo || "", + }; + }); + }, [orders, resolveLabel]); - // 좌측 행 클릭 - const handleMasterRowClick = (row: any) => { - setSelectedOrderNo(row.order_no); + // 컬럼별 고유값 (헤더 필터용) + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + for (const col of FLAT_COLUMNS) { + const values = new Set(); + flatRows.forEach((row) => { + const val = row[col.key]; + if (val !== null && val !== undefined && val !== "") values.add(String(val)); + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [flatRows]); + + // 필터 + 정렬 적용된 플랫 데이터 + const filteredFlatRows = useMemo(() => { + let rows = [...flatRows]; + + // 1차: 헤더 필터 적용 + for (const [colKey, values] of Object.entries(headerFilters)) { + if (values.size === 0) continue; + rows = rows.filter((row) => { + const cellVal = row[colKey] != null ? String(row[colKey]) : ""; + return values.has(cellVal); + }); + } + + // 2차: 정렬 + if (sortState) { + const { key, direction } = sortState; + rows.sort((a, b) => { + const av = a[key] ?? ""; + const bv = b[key] ?? ""; + const na = Number(av); const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; + return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + }); + } + + return rows; + }, [flatRows, headerFilters, sortState]); + + // 헤더 필터 토글/초기화 + const toggleHeaderFilter = (colKey: string, value: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + const set = new Set(next[colKey] || []); + if (set.has(value)) set.delete(value); else set.add(value); + if (set.size === 0) delete next[colKey]; else next[colKey] = set; + return next; + }); }; - // 등록 모달 - const openRegisterModal = async () => { - let previewOrderNo = ""; - if (numberingRuleId) { - const res = await previewNumberingCode(numberingRuleId); - if (res.success && res.data?.generatedCode) { - previewOrderNo = res.data.generatedCode; - } - } - if (!previewOrderNo) { - previewOrderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; - } - setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "" }); - setModalDetailRows([]); + const clearHeaderFilter = (colKey: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + delete next[colKey]; + return next; + }); + }; + + const handleSort = (key: string) => { + setSortState((prev) => + prev?.key === key + ? prev.direction === "asc" ? { key, direction: "desc" } : null + : { key, direction: "asc" } + ); + }; + + const getCategoryLabel = (col: string, code: string) => { + if (!code) return ""; + const found = categoryOptions[col]?.find((o) => o.code === code); + return found?.label || code; + }; + + const loadDeliveryOptions = async (customerCode: string) => { + if (!customerCode) { setDeliveryOptions([]); return; } + try { + const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setDeliveryOptions(rows.map((r: any) => ({ + code: r.destination_code || r.id, + label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`, + }))); + } catch { setDeliveryOptions([]); } + }; + + // 등록 모달 열기 + const openRegisterModal = () => { + const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; + const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; + const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; + setMasterForm({ + input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, + manager_id: user?.userId || "", order_date: new Date().toISOString().split("T")[0], + }); + setDetailRows([]); + setDeliveryOptions([]); setIsEditMode(false); setIsModalOpen(true); }; - // 수정 모달 + // 수정 모달 열기 const openEditModal = async (orderNo: string) => { try { const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { @@ -341,25 +509,19 @@ export default function JeilGlassOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setModalDetailRows(detailData.map((d: any, i: number) => ({ - ...d, - _id: d.id || `row_${i}`, - _fromItemInfo: !!d.part_code, - _divisionLabel: categoryOptions["item_division"]?.find((o: any) => o.code === d.division)?.label || d.division || "", - }))); + setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); setIsEditMode(true); setIsModalOpen(true); } catch (err) { - console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); } }; - // 삭제 + // 삭제 (마스터 + 디테일) const handleDelete = async () => { if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } - const selectedItems = masterOrders.filter((o) => checkedIds.includes(o.id)); - const orderNos = selectedItems.map((o) => o.order_no); + const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); + const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", @@ -370,7 +532,7 @@ export default function JeilGlassOrderPage() { for (const orderNo of orderNos) { // 디테일 삭제 const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 100, + page: 1, size: 9999, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); @@ -395,78 +557,25 @@ export default function JeilGlassOrderPage() { } toast.success("삭제되었습니다."); setCheckedIds([]); - setSelectedOrderNo(null); - fetchMasterOrders(); + fetchOrders(); } catch (err) { - console.error("삭제 실패:", err); toast.error("삭제에 실패했습니다."); } }; - // 품목 자동 등록 (item_info에 없으면 등록) - const autoRegisterItems = async (rows: any[]) => { - for (const row of rows) { - if (row.part_code || !row.part_name) continue; - try { - // item_info에서 품명으로 검색 - const searchRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] }, - autoFilter: true, - }); - const found = (searchRes.data?.data?.data || searchRes.data?.data?.rows || [])[0]; - if (found) { - row.part_code = found.item_number; - continue; - } - // 없으면 자동 등록 - await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { - item_name: row.part_name, - size: row.spec || "", - unit: row.unit || "", - }); - // 등록 후 재조회하여 item_number 획득 - const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] }, - autoFilter: true, - sort: { columnName: "created_date", order: "desc" }, - }); - const newItem = (reSearch.data?.data?.data || reSearch.data?.data?.rows || [])[0]; - if (newItem) row.part_code = newItem.item_number; - } catch (err) { - console.warn("품목 자동 등록 실패:", row.part_name, err); - } - } - }; - - // 저장 + // 저장 (마스터 + 디테일) const handleSave = async () => { - if (modalDetailRows.length === 0) { + if (!masterForm.order_no && !isEditMode) { + toast.error("수주번호는 필수입니다."); + return; + } + if (detailRows.length === 0) { toast.error("품목을 1개 이상 추가해주세요."); return; } - setSaving(true); try { - // 품목 자동 등록 - await autoRegisterItems(modalDetailRows); - - // 신규 등록 시 채번 할당 - if (!isEditMode) { - if (numberingRuleId) { - const allocRes = await allocateNumberingCode(numberingRuleId, masterForm.order_no); - if (allocRes.success && allocRes.data?.generatedCode) { - masterForm.order_no = allocRes.data.generatedCode; - } - } - if (!masterForm.order_no) { - masterForm.order_no = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; - } - } - const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm; - if (isEditMode && id) { await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, { originalData: { id }, @@ -486,22 +595,18 @@ export default function JeilGlassOrderPage() { } else { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } - - for (let i = 0; i < modalDetailRows.length; i++) { - const row = modalDetailRows[i]; - const { _id, _fromItemInfo, _divisionLabel, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + for (const row of detailRows) { + const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, + id: crypto.randomUUID(), order_no: masterForm.order_no, - seq_no: String(i + 1), }); } - toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다."); setIsModalOpen(false); - fetchMasterOrders(); + fetchOrders(); } catch (err: any) { - console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); @@ -509,91 +614,221 @@ export default function JeilGlassOrderPage() { }; // 품목 검색 - const searchItems = async () => { + const searchItems = async (page?: number, size?: number) => { + const p = page ?? itemPage; + const s = size ?? itemPageSize; setItemSearchLoading(true); try { const filters: any[] = []; - if (itemSearchKeyword) { - filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + + // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + let customerItemIds: Set | null = null; + + if (isCustomerPrice && partnerId) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + } catch { /* skip */ } } - const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { - page: 1, size: 50, + + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); - } catch { setItemSearchResults([]); } - finally { setItemSearchLoading(false); } + const resData = res.data?.data; + let allRows = resData?.data || resData?.rows || []; + + // 거래처우선일 때 연결된 품목만 표시 + if (customerItemIds) { + allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); + } + + // 관리품목 필터 (코드/라벨 혼재 대응) + if (itemSearchDivision !== "all") { + const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; + allRows = allRows.filter((item: any) => { + const div = item.division || ""; + return div.includes(itemSearchDivision) || (divLabel && div.includes(divLabel)); + }); + } + const total = allRows.length; + const start = (p - 1) * s; + setItemSearchResults(allRows.slice(start, start + s)); + setItemTotal(total); + setItemTotalPages(Math.max(1, Math.ceil(total / s))); + } catch { /* skip */ } finally { + setItemSearchLoading(false); + } }; - // 품목 선택 → 리피터에 추가 - const addSelectedItemsToDetail = () => { - const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id)); - const resolveUnit = (code: string) => { - if (!code) return ""; - return categoryOptions["item_unit"]?.find((o) => o.code === code)?.label || code; - }; - const resolveDivision = (code: string) => { - if (!code) return ""; - return categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code; - }; - const newRows = selected.map((item) => ({ - _id: `new_${Date.now()}_${Math.random()}`, - _fromItemInfo: true, - part_code: item.item_number || "", - part_name: item.item_name || "", - spec: item.size || "", - division: item.division || "", - _divisionLabel: resolveDivision(item.division), - unit: resolveUnit(item.unit) || "", - width: "", height: "", thickness: "", area: "", - qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "", - due_date: "", memo: "", - })); - setModalDetailRows((prev) => [...prev, ...newRows]); + const handleItemPageChange = (newPage: number) => { + if (newPage < 1 || newPage > itemTotalPages) return; + setItemPage(newPage); + setItemPageInput(String(newPage)); + searchItems(newPage); + }; + + const commitItemPageInput = () => { + const parsed = parseInt(itemPageInput, 10); + if (isNaN(parsed) || itemPageInput.trim() === "") { setItemPageInput(String(itemPage)); return; } + const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); + if (clamped !== itemPage) handleItemPageChange(clamped); + setItemPageInput(String(clamped)); + }; + + const triggerNewSearch = () => { + setItemPage(1); + setItemPageInput("1"); + searchItems(1); + }; + + const addSelectedItemsToDetail = async () => { + const selected = Array.from(itemSelectedMap.values()); + if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } + + const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ"; + const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const partnerId = masterForm.partner_id; + + let customerPriceMap: Record = {}; + if (isCustomerPrice && partnerId) { + try { + const itemIds = selected.map((item) => item.item_number || item.id); + const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 500, + dataFilter: { + enabled: true, + filters: [ + { columnName: "customer_id", operator: "equals", value: partnerId }, + { columnName: "item_id", operator: "in", value: itemIds }, + ], + }, + autoFilter: true, + }); + const prices = res.data?.data?.data || res.data?.data?.rows || []; + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; + for (const p of prices) { + const start = p.start_date || ""; + const end = p.end_date || ""; + if (start && start > today) continue; + if (end && end < today) continue; + const price = p.calculated_price || p.base_price || p.unit_price || ""; + if (price && Number(price) > 0) { + const existing = customerPriceMap[p.item_id]; + if (!existing || Number(price) > 0) customerPriceMap[p.item_id] = String(price); + } + } + } catch { /* skip */ } + } + + const newRows = selected.map((item) => { + const itemCode = item.item_number || item.id; + let unitPrice = ""; + if (isStandardPrice) { + unitPrice = item.standard_price || item.selling_price || ""; + } else if (isCustomerPrice && partnerId) { + unitPrice = customerPriceMap[itemCode] || ""; + } + return { + _id: `new_${Date.now()}_${Math.random()}`, + part_code: itemCode, + part_name: item.item_name, + spec: item.size || "", + material: getCategoryLabel("item_material", item.material) || item.material || "", + packing_material: "", + unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", + qty: "1", + pack_qty: "0", + unit_price: unitPrice, + amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", + due_date: "", + }; + }); + + setDetailRows((prev) => [...prev, ...newRows]); + toast.success(`${selected.length}개 품목이 추가되었습니다.`); + setItemSelectedMap(new Map()); setItemSelectOpen(false); - setItemCheckedIds(new Set()); }; - // 빈 행 추가 (품명 직접 입력용) - const addEmptyRow = () => { - setModalDetailRows((prev) => [...prev, { - _id: `new_${Date.now()}_${Math.random()}`, - _fromItemInfo: false, - part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡", - width: "", height: "", thickness: "", area: "", - qty: "", unit_price: "", amount: "", - due_date: "", memo: "", - }]); - }; + // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 + const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => { + if (detailRows.length === 0) return; + const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"]; + const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"]; + const isStandard = STANDARD_CODES.includes(priceMode); + const isCustomer = CUSTOMER_CODES.includes(priceMode); - // 구분(division) 라벨로 면적 계산 제수 결정 - const getAreaDivisor = (divisionCode: string) => { - const label = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || ""; - // 원판, 원자재 → 92,094 / 그 외(제품 등) → 91,808 - if (label.includes("원판") || label.includes("원자재")) return 92094; - return 91808; - }; + if (isStandard) { + // 품목 기준단가 조회 + const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean); + if (itemCodes.length === 0) return; + try { + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, + autoFilter: true, + }); + const items = res.data?.data?.data || res.data?.data?.rows || []; + const priceMap: Record = {}; + for (const item of items) { + const price = item.standard_price || item.selling_price || ""; + if (price) priceMap[item.item_number] = String(price); + } + setDetailRows((prev) => prev.map((row) => { + const up = priceMap[row.part_code] || ""; + const qty = parseFloat(row.qty) || 0; + const price = parseFloat(up) || 0; + return { ...row, unit_price: up, amount: (qty * price).toString() }; + })); + } catch { /* skip */ } + } else if (isCustomer && partnerId) { + // 거래처별 단가 조회 + const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean); + if (itemCodes.length === 0) return; + try { + const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: partnerId }, + { columnName: "item_id", operator: "in", value: itemCodes }, + ]}, + autoFilter: true, + }); + const prices = res.data?.data?.data || res.data?.data?.rows || []; + const _n = new Date(); + const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; + const priceMap: Record = {}; + for (const p of prices) { + if (p.start_date && p.start_date > today) continue; + if (p.end_date && p.end_date < today) continue; + const price = p.calculated_price || p.base_price || p.unit_price || ""; + if (price && Number(price) > 0) priceMap[p.item_id] = String(price); + } + setDetailRows((prev) => prev.map((row) => { + const up = priceMap[row.part_code] || ""; + const qty = parseFloat(row.qty) || 0; + const price = parseFloat(up) || 0; + return { ...row, unit_price: up, amount: (qty * price).toString() }; + })); + } catch { /* skip */ } + } + }, [detailRows]); - // 면적 계산 (구분에 따른 제수 적용) - const calcArea = (row: any) => { - const w = parseFloat(row.width) || 0; - const h = parseFloat(row.height) || 0; - if (w <= 0 || h <= 0) return ""; - const divisor = getAreaDivisor(row.division); - return (w * h / divisor).toFixed(4); - }; - - // 리피터 행 값 변경 + 면적/금액 자동 계산 const updateDetailRow = (idx: number, field: string, value: string) => { - setModalDetailRows((prev) => { + setDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; - // 면적 자동 계산 (구분/가로/세로 변경 시) - if (field === "width" || field === "height" || field === "division") { - next[idx].area = calcArea(next[idx]); - } - // 금액 자동 계산 if (field === "qty" || field === "unit_price") { const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; @@ -604,546 +839,801 @@ export default function JeilGlassOrderPage() { }; const removeDetailRow = (idx: number) => { - setModalDetailRows((prev) => prev.filter((_, i) => i !== idx)); + setDetailRows((prev) => prev.filter((_, i) => i !== idx)); }; - // 엑셀 다운로드 (마스터+디테일 통합) + // 조건부 레이어 판단 + const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W"; + const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO"; + const handleExcelDownload = async () => { - if (allDetails.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } - // 마스터 정보 매핑 - const masterMap: Record = {}; - for (const m of masterOrders) masterMap[m.order_no] = m; - - const resolveDiv = (code: string) => - categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code || ""; - - const data = allDetails.map((o) => { - const master = masterMap[o.order_no] || {}; - return { - "수주번호": o.order_no || "", - "거래처": master.partner_name || "", - "상태": master.status || "", - "구분": resolveDiv(o.division), - "품명": o.part_name || "", - "규격": o.spec || "", - "가로": o.width || "", - "세로": o.height || "", - "두께": o.thickness || "", - "면적": o.area || "", - "단위": o.unit || "", - "수량": o.qty || "", - "출하": o.ship_qty || "", - "잔량": o.balance_qty || "", - "단가": o.unit_price || "", - "금액": o.amount || "", - "납기일": o.due_date || "", - "비고": o.memo || "", - }; + if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } + const cols = ["order_no","part_code","part_name","spec","unit","qty","ship_qty","balance_qty","unit_price","amount","currency_code","due_date","memo"]; + const labels: Record = { + order_no:"수주번호",part_code:"품번",part_name:"품명",spec:"규격",unit:"단위", + qty:"수량",ship_qty:"출하수량",balance_qty:"잔량",unit_price:"단가", + amount:"금액",currency_code:"통화",due_date:"납기일",memo:"메모", + }; + const data = orders.map((o) => { + const row: Record = {}; + for (const col of cols) row[labels[col]] = o[col] || ""; + return row; }); - await exportToExcel(data, "제일그라스_수주관리.xlsx", "수주목록"); + await exportToExcel(data, "수주관리.xlsx", "수주목록"); toast.success("다운로드 완료"); }; - // 엑셀 업로드 후처리: order_no가 비어있는 디테일에 마스터 자동 생성 - const handleExcelUploadSuccess = async () => { - try { - // 마스터 없는 디테일(order_no 비어있는) 조회 - const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, - sort: { columnName: "created_date", order: "desc" }, - }); - const allRows = res.data?.data?.data || res.data?.data?.rows || []; - - // order_no가 비어있는 행들 수집 - const noOrderRows = allRows.filter((r: any) => !r.order_no); - if (noOrderRows.length > 0) { - // 채번 후 마스터 생성 + 디테일에 order_no 설정 - let orderNo = ""; - if (numberingRuleId) { - const allocRes = await allocateNumberingCode(numberingRuleId); - if (allocRes.success && allocRes.data?.generatedCode) orderNo = allocRes.data.generatedCode; - } - if (!orderNo) { - orderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; - } - - // 마스터 생성 - await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { - order_no: orderNo, - status: "수주", - manager_id: user?.userId || "", - order_date: new Date().toISOString().slice(0, 10), - }); - - // 디테일에 order_no + 면적/금액 계산하여 업데이트 - for (let i = 0; i < noOrderRows.length; i++) { - const row = noOrderRows[i]; - const w = parseFloat(row.width) || 0; - const h = parseFloat(row.height) || 0; - const qty = parseFloat(row.qty) || 0; - const price = parseFloat(row.unit_price) || 0; - const area = w > 0 && h > 0 ? (w * h / 91808).toFixed(4) : ""; - const amount = (qty * price).toString(); - - await apiClient.put(`/table-management/tables/${DETAIL_TABLE}/edit`, { - originalData: { id: row.id }, - updatedData: { - order_no: orderNo, - seq_no: String(i + 1), - area: area || row.area || "", - amount: amount || row.amount || "", - }, - }); - } - toast.success(`${noOrderRows.length}건의 품목에 수주번호 ${orderNo} 할당 완료`); - } - } catch (err) { - console.error("엑셀 업로드 후처리 실패:", err); - } - fetchMasterOrders(); - }; - return (
- {/* 검색 필터 */} + {/* 브레드크럼 */} + + + {/* 검색 필터 (DynamicSearchFilter) */} - {/* 통계 바 */} -
-
- 총 금액 - {stats.totalAmount.toLocaleString()}원 + {/* 액션 바 */} +
+
+

수주 목록

+ + {totalCount}건 +
-
- 총 수량 - {stats.totalQty.toLocaleString()}개 +
+ + + +
+ + + +
+
- {/* 좌우 분할 */} -
- - {/* 좌측: 수주 목록 */} - -
-
-
- 수주 목록 - {totalCount}건 + {/* 데이터 테이블 (플랫 리스트) */} +
+
+
+ + + + + + + + + + + + + + + + + + + + { + const allFilteredIds = filteredFlatRows.map((r) => r.id); + const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allFilteredIds); + }} + > + { + const allFilteredIds = filteredFlatRows.map((r) => r.id); + return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + })()} + onCheckedChange={() => {}} + /> + + {FLAT_COLUMNS.map((col) => { + const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key); + return ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> + )} +
+
+ ); + })} +
+
+ + {loading ? ( + + + + + + ) : filteredFlatRows.length === 0 ? ( + + +
+ + 등록된 수주가 없어요 +
+
+
+ ) : ( + filteredFlatRows.map((row) => { + const isChecked = checkedIds.includes(row.id); + return ( + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.order_no)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} /> + + {row.order_no} + {row.partner_id || ""} + {row.order_date || ""} + {row.part_code} + {row.part_name} + {row.spec} + {row.unit} + {row.qty ? Number(row.qty).toLocaleString() : ""} + {row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""} + {row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + {row.amount ? Number(row.amount).toLocaleString() : ""} + {row.due_date || ""} + {row.memo || ""} + + ); + }) + )} +
+
+
+
+ + {/* 수주 등록/수정 모달 */} + + + + + {isEditMode ? "수주 수정" : "수주 등록"} + + + {isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."} + + + +
+ {/* 기본 정보 섹션 */} +
+
+ 기본 정보 +
+
+
+
+ + setMasterForm((p) => ({ ...p, order_no: e.target.value }))} + placeholder="수주번호" className="h-9" disabled={isEditMode} + />
-
- -
- openEditModal(row.order_no)} - tableName={MASTER_TABLE} - emptyMessage="등록된 수주가 없습니다" - />
- - - - {/* 우측: 품목 상세 */} - -
-
-
- 품목 상세 - {selectedOrderNo && ( - {selectedOrderNo} - )} - {detailItems.length > 0 && ( - ({detailItems.length}건) - )} + {/* 거래처 우선 조건부 레이어 */} + {isSupplierFirst && ( +
+
+ 거래처 정보
-
- - - +
+
+ + +
+
+ + +
+
+ + {deliveryOptions.length > 0 ? ( + + ) : ( + setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} + placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택해주세요"} + className="h-9" disabled={!masterForm.partner_id} + /> + )} +
+
+ + setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))} + placeholder="납품장소" className="h-9" + /> +
- {!selectedOrderNo ? ( -
- 좌측에서 수주를 선택하세요 + )} + + {/* 해외판매 조건부 레이어 */} + {isOverseas && ( +
+
+ 해외판매 추가 정보 +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))} className="h-9" /> +
+
+ + setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))} className="h-9" /> +
+
+ + setMasterForm((p) => ({ ...p, hs_code: e.target.value }))} className="h-9 font-mono text-xs" /> +
+
+
+ )} + + {/* 품목 내역 리피터 */} +
+
+
+ 품목 내역 + + {detailRows.length} + +
+ +
+ {detailRows.length === 0 ? ( +
+ + 아직 추가된 품목이 없어요. 위 버튼으로 품목을 추가해주세요.
) : ( - +
+ + + + No + 품번 + 품명 + 규격 + 재질 + 포장재 + 단위 + 수량 + 포장수량 + 단가 + 금액 + 납기일 + 분할/삭제 + + + + {detailRows.map((row, idx) => ( + + {idx + 1} + + {row.part_code} + + + {row.part_name} + + {row.spec} + {row.material} + + updateDetailRow(idx, "packing_material", e.target.value)} + placeholder="포장재" + className="h-8 text-xs" + /> + + + + + + updateDetailRow(idx, "qty", e.target.value)} + className="h-8 text-xs text-right font-mono w-16" + /> + + + updateDetailRow(idx, "pack_qty", e.target.value)} + className="h-8 text-xs text-right font-mono w-16" + /> + + + updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} + readOnly={!allowPriceEdit} + className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")} + /> + + + {row.amount ? Number(row.amount).toLocaleString() : "0"} + + + updateDetailRow(idx, "due_date", e.target.value)} + className="h-8 text-xs" + /> + + +
+ + +
+
+
+ ))} +
+
+
)}
- - -
- {/* 등록/수정 모달 */} - - - - - } - > -
- {/* 기본 정보 */} -
-
- - +
+ 비고 +
+
+