diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index b88e7126..27f30522 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -35,46 +35,15 @@ export async function getOrderSummary( const whereClause = conditions.join(" AND "); - // item_info에 lead_time 컬럼이 존재하는지 확인 - const leadTimeColCheck = await pool.query(` - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'item_info' AND column_name = 'lead_time' - ) AS has_lead_time - `); - const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true; - - const itemLeadTimeCte = hasLeadTime - ? `item_lead_time AS ( - SELECT DISTINCT ON (item_number) - item_number, - id AS item_id, - COALESCE(lead_time::int, 0) AS lead_time - FROM item_info - WHERE company_code = $1 - ORDER BY item_number, created_date DESC - ),` - : `item_lead_time AS ( - SELECT DISTINCT ON (item_number) - item_number, - id AS item_id, - 0 AS lead_time - FROM item_info - WHERE company_code = $1 - ORDER BY item_number, created_date DESC - ),`; - + // 단일 쿼리로 요약 + 상세 + 재고 + 계획 통합 조회 const query = ` WITH all_orders AS ( - -- 레거시: sales_order_mng에 part_code가 직접 있는 경우 SELECT - so.part_code, - so.part_name, - so.company_code, + so.id::text, so.order_no, so.part_code, so.part_name, so.company_code, COALESCE(so.order_qty::numeric, 0) AS order_qty, COALESCE(so.ship_qty::numeric, 0) AS ship_qty, COALESCE(so.balance_qty::numeric, 0) AS balance_qty, - so.due_date + so.due_date, so.status, so.partner_id, so.manager_name FROM sales_order_mng so WHERE ${whereClause} AND so.part_code IS NOT NULL AND so.part_code != '' @@ -85,52 +54,47 @@ export async function getOrderSummary( UNION ALL - -- 마스터-디테일: sales_order_detail에 품목이 있는 경우 SELECT - sd.part_code, - sd.part_name, - sd.company_code, + sd.id::text, sd.order_no, sd.part_code, sd.part_name, sd.company_code, COALESCE(sd.qty::numeric, 0) AS order_qty, COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, COALESCE(sd.balance_qty::numeric, sd.qty::numeric - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty, - sd.due_date::date + sd.due_date::date, so.status, so.partner_id, so.manager_name FROM sales_order_detail sd INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code WHERE sd.company_code = $1 AND sd.part_code IS NOT NULL AND sd.part_code != '' ), - distinct_item AS ( - SELECT DISTINCT ON (item_number, company_code) - item_number, item_name, company_code + item_info_dedup AS ( + SELECT DISTINCT ON (item_number) + item_number, item_name, id AS item_id, + COALESCE(lead_time::int, 0) AS lead_time FROM item_info - ORDER BY item_number, company_code, created_date DESC + WHERE company_code = $1 + ORDER BY item_number, created_date DESC ), order_summary AS ( SELECT ao.part_code AS item_code, - COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code) AS item_name, + COALESCE(NULLIF(MAX(ao.part_name), ''), MAX(ii.item_name), ao.part_code) AS item_name, SUM(ao.order_qty) AS total_order_qty, SUM(ao.ship_qty) AS total_ship_qty, SUM(ao.balance_qty) AS total_balance_qty, COUNT(*) AS order_count, MIN(ao.due_date) AS earliest_due_date FROM all_orders ao - LEFT JOIN distinct_item ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code - GROUP BY ao.part_code, COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code) + LEFT JOIN item_info_dedup ii ON ao.part_code = ii.item_number + GROUP BY ao.part_code ), - ${itemLeadTimeCte} stock_info AS ( - SELECT - item_code, + SELECT item_code, SUM(COALESCE(current_qty::numeric, 0)) AS current_stock, MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock - FROM inventory_stock - WHERE company_code = $1 + FROM inventory_stock WHERE company_code = $1 GROUP BY item_code ), plan_info AS ( - SELECT - item_code, + SELECT item_code, SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty, SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty FROM production_plan_mng @@ -140,13 +104,9 @@ export async function getOrderSummary( GROUP BY item_code ) SELECT - os.item_code, - os.item_name, - os.total_order_qty, - os.total_ship_qty, - os.total_balance_qty, - os.order_count, - os.earliest_due_date, + os.item_code, os.item_name, + os.total_order_qty, os.total_ship_qty, os.total_balance_qty, + os.order_count, os.earliest_due_date, COALESCE(si.current_stock, 0) AS current_stock, COALESCE(si.safety_stock, 0) AS safety_stock, COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty, @@ -160,15 +120,14 @@ export async function getOrderSummary( FROM order_summary os LEFT JOIN stock_info si ON os.item_code = si.item_code LEFT JOIN plan_info pi ON os.item_code = pi.item_code - LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id) + LEFT JOIN item_info_dedup ilt ON os.item_code = ilt.item_number ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} ORDER BY os.item_code; `; const result = await pool.query(query, params); - // 그룹별 상세 수주 데이터도 함께 조회 (레거시 + 디테일 UNION) - const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND "); + // 상세 데이터: all_orders CTE와 동일 로직 (쿼리 재사용 위해 별도 실행) const detailQuery = ` SELECT id::text, order_no, part_code, part_name, COALESCE(order_qty::numeric, 0) AS order_qty, @@ -176,15 +135,13 @@ export async function getOrderSummary( COALESCE(balance_qty::numeric, 0) AS balance_qty, due_date, status, partner_id, manager_name FROM sales_order_mng - WHERE ${detailWhere} + WHERE ${conditions.map(c => c.replace(/so\./g, "")).join(" AND ")} AND part_code IS NOT NULL AND part_code != '' AND NOT EXISTS ( SELECT 1 FROM sales_order_detail sd WHERE sd.order_no = sales_order_mng.order_no AND sd.company_code = sales_order_mng.company_code ) - UNION ALL - SELECT sd.id::text, sd.order_no, sd.part_code, sd.part_name, COALESCE(sd.qty::numeric, 0) AS order_qty, COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, @@ -194,7 +151,6 @@ export async function getOrderSummary( INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code WHERE sd.company_code = $1 AND sd.part_code IS NOT NULL AND sd.part_code != '' - ORDER BY part_code, due_date; `; const detailResult = await pool.query(detailQuery, params); diff --git a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx index 515ad922..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx @@ -164,7 +164,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_10"); + const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx index 4d006b77..f90fcdd6 100644 --- a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; @@ -175,6 +176,10 @@ export default function SalesOrderPage() { const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); + // 수주번호 자동 채번 + const [orderNoRuleId, setOrderNoRuleId] = useState(null); + const [orderNoPreview, setOrderNoPreview] = useState(null); + // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -196,6 +201,7 @@ export default function SalesOrderPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); @@ -213,82 +219,89 @@ export default function SalesOrderPage() { // 카테고리 로드 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - 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; + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + 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 custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 500, autoFilter: true, }); - }; - await Promise.all( - catColumns.map(async (col) => { + 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // item_info 카테고리 + for (const col of ["unit", "material", "division", "type"]) { try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); + optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } - }) - ); - // 거래처 목록 - try { - 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - // item_info 카테고리 - for (const col of ["unit", "material", "division", "type"]) { - try { - 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); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - 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(); }, []); // 데이터 조회 const fetchOrders = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map(f => ({ @@ -364,7 +377,7 @@ export default function SalesOrderPage() { } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchOrders(); }, [fetchOrders]); @@ -513,7 +526,7 @@ export default function SalesOrderPage() { }; // 등록 모달 열기 - const openRegisterModal = () => { + const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; @@ -524,7 +537,23 @@ export default function SalesOrderPage() { setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); + setOrderNoRuleId(null); + setOrderNoPreview(null); setIsModalOpen(true); + + // 수주번호 자동 채번 조회 + try { + const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no"); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + const ruleId = ruleRes.data.data.ruleId; + setOrderNoRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setOrderNoPreview(previewRes.data.generatedCode); + setMasterForm((prev) => ({ ...prev, order_no: previewRes.data.generatedCode })); + } + } + } catch { /* 채번 규칙 없으면 수동 입력 */ } }; // 수정 모달 열기 @@ -603,6 +632,22 @@ export default function SalesOrderPage() { // 저장 (마스터 + 디테일) const handleSave = async () => { + // 채번 규칙이 있으면 allocate, 없으면 수동 입력 필수 + if (!isEditMode && orderNoRuleId) { + try { + const allocRes = await allocateNumberingCode(orderNoRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + setMasterForm((prev) => ({ ...prev, order_no: allocRes.data.generatedCode })); + masterForm.order_no = allocRes.data.generatedCode; + } else { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } catch { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } if (!masterForm.order_no && !isEditMode) { toast.error("수주번호는 필수입니다."); return; @@ -1216,8 +1261,10 @@ export default function SalesOrderPage() { setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - placeholder="수주번호" className="h-9" disabled={isEditMode} + onChange={(e) => !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))} + readOnly={!!orderNoRuleId || isEditMode} + placeholder={orderNoRuleId ? "자동 채번" : "수주번호"} + className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")} />
diff --git a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + + diff --git a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx index c357cc9f..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index 4d006b77..f90fcdd6 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; @@ -175,6 +176,10 @@ export default function SalesOrderPage() { const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); + // 수주번호 자동 채번 + const [orderNoRuleId, setOrderNoRuleId] = useState(null); + const [orderNoPreview, setOrderNoPreview] = useState(null); + // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -196,6 +201,7 @@ export default function SalesOrderPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); @@ -213,82 +219,89 @@ export default function SalesOrderPage() { // 카테고리 로드 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - 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; + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + 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 custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 500, autoFilter: true, }); - }; - await Promise.all( - catColumns.map(async (col) => { + 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // item_info 카테고리 + for (const col of ["unit", "material", "division", "type"]) { try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); + optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } - }) - ); - // 거래처 목록 - try { - 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - // item_info 카테고리 - for (const col of ["unit", "material", "division", "type"]) { - try { - 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); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - 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(); }, []); // 데이터 조회 const fetchOrders = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map(f => ({ @@ -364,7 +377,7 @@ export default function SalesOrderPage() { } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchOrders(); }, [fetchOrders]); @@ -513,7 +526,7 @@ export default function SalesOrderPage() { }; // 등록 모달 열기 - const openRegisterModal = () => { + const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; @@ -524,7 +537,23 @@ export default function SalesOrderPage() { setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); + setOrderNoRuleId(null); + setOrderNoPreview(null); setIsModalOpen(true); + + // 수주번호 자동 채번 조회 + try { + const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no"); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + const ruleId = ruleRes.data.data.ruleId; + setOrderNoRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setOrderNoPreview(previewRes.data.generatedCode); + setMasterForm((prev) => ({ ...prev, order_no: previewRes.data.generatedCode })); + } + } + } catch { /* 채번 규칙 없으면 수동 입력 */ } }; // 수정 모달 열기 @@ -603,6 +632,22 @@ export default function SalesOrderPage() { // 저장 (마스터 + 디테일) const handleSave = async () => { + // 채번 규칙이 있으면 allocate, 없으면 수동 입력 필수 + if (!isEditMode && orderNoRuleId) { + try { + const allocRes = await allocateNumberingCode(orderNoRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + setMasterForm((prev) => ({ ...prev, order_no: allocRes.data.generatedCode })); + masterForm.order_no = allocRes.data.generatedCode; + } else { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } catch { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } if (!masterForm.order_no && !isEditMode) { toast.error("수주번호는 필수입니다."); return; @@ -1216,8 +1261,10 @@ export default function SalesOrderPage() { setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - placeholder="수주번호" className="h-9" disabled={isEditMode} + onChange={(e) => !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))} + readOnly={!!orderNoRuleId || isEditMode} + placeholder={orderNoRuleId ? "자동 채번" : "수주번호"} + className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")} />
diff --git a/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + + diff --git a/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx index 5479a779..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx @@ -164,7 +164,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_29"); + const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index 4d006b77..f90fcdd6 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; @@ -175,6 +176,10 @@ export default function SalesOrderPage() { const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); + // 수주번호 자동 채번 + const [orderNoRuleId, setOrderNoRuleId] = useState(null); + const [orderNoPreview, setOrderNoPreview] = useState(null); + // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -196,6 +201,7 @@ export default function SalesOrderPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); @@ -213,82 +219,89 @@ export default function SalesOrderPage() { // 카테고리 로드 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - 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; + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + 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 custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 500, autoFilter: true, }); - }; - await Promise.all( - catColumns.map(async (col) => { + 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // item_info 카테고리 + for (const col of ["unit", "material", "division", "type"]) { try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); + optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } - }) - ); - // 거래처 목록 - try { - 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - // item_info 카테고리 - for (const col of ["unit", "material", "division", "type"]) { - try { - 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); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - 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(); }, []); // 데이터 조회 const fetchOrders = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map(f => ({ @@ -364,7 +377,7 @@ export default function SalesOrderPage() { } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchOrders(); }, [fetchOrders]); @@ -513,7 +526,7 @@ export default function SalesOrderPage() { }; // 등록 모달 열기 - const openRegisterModal = () => { + const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; @@ -524,7 +537,23 @@ export default function SalesOrderPage() { setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); + setOrderNoRuleId(null); + setOrderNoPreview(null); setIsModalOpen(true); + + // 수주번호 자동 채번 조회 + try { + const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no"); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + const ruleId = ruleRes.data.data.ruleId; + setOrderNoRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setOrderNoPreview(previewRes.data.generatedCode); + setMasterForm((prev) => ({ ...prev, order_no: previewRes.data.generatedCode })); + } + } + } catch { /* 채번 규칙 없으면 수동 입력 */ } }; // 수정 모달 열기 @@ -603,6 +632,22 @@ export default function SalesOrderPage() { // 저장 (마스터 + 디테일) const handleSave = async () => { + // 채번 규칙이 있으면 allocate, 없으면 수동 입력 필수 + if (!isEditMode && orderNoRuleId) { + try { + const allocRes = await allocateNumberingCode(orderNoRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + setMasterForm((prev) => ({ ...prev, order_no: allocRes.data.generatedCode })); + masterForm.order_no = allocRes.data.generatedCode; + } else { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } catch { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } if (!masterForm.order_no && !isEditMode) { toast.error("수주번호는 필수입니다."); return; @@ -1216,8 +1261,10 @@ export default function SalesOrderPage() { setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - placeholder="수주번호" className="h-9" disabled={isEditMode} + onChange={(e) => !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))} + readOnly={!!orderNoRuleId || isEditMode} + placeholder={orderNoRuleId ? "자동 채번" : "수주번호"} + className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")} />
diff --git a/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + + diff --git a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx index 83e304fd..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx @@ -164,7 +164,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_30"); + const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + + diff --git a/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx index dc5158c6..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx @@ -164,7 +164,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_7"); + const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx index 96b3d47e..106469fd 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx @@ -166,44 +166,51 @@ export default function WarehouseManagementPage() { const [locationCategoryOptions, setLocationCategoryOptions] = useState< Record >({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 카테고리 로드 useEffect(() => { const load = async () => { - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; + try { + const flatten = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; - const whOpts: Record = {}; - for (const col of ["warehouse_type", "status"]) { - try { - const res = await apiClient.get( - `/table-categories/${WAREHOUSE_TABLE}/${col}/values` - ); - if (res.data?.success) whOpts[col] = flatten(res.data.data || []); - } catch { - /* skip */ + const whOpts: Record = {}; + for (const col of ["warehouse_type", "status"]) { + try { + const res = await apiClient.get( + `/table-categories/${WAREHOUSE_TABLE}/${col}/values` + ); + if (res.data?.success) whOpts[col] = flatten(res.data.data || []); + } catch { + /* skip */ + } } - } - setCategoryOptions(whOpts); + setCategoryOptions(whOpts); - const locOpts: Record = {}; - for (const col of ["location_type", "status", "floor", "zone"]) { - try { - const res = await apiClient.get( - `/table-categories/${LOCATION_TABLE}/${col}/values` - ); - if (res.data?.success) locOpts[col] = flatten(res.data.data || []); - } catch { - /* skip */ + const locOpts: Record = {}; + for (const col of ["location_type", "status", "floor", "zone"]) { + try { + const res = await apiClient.get( + `/table-categories/${LOCATION_TABLE}/${col}/values` + ); + if (res.data?.success) locOpts[col] = flatten(res.data.data || []); + } catch { + /* skip */ + } } + setLocationCategoryOptions(locOpts); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - setLocationCategoryOptions(locOpts); }; load(); }, []); @@ -223,6 +230,7 @@ export default function WarehouseManagementPage() { // 창고 목록 조회 const fetchWarehouses = useCallback(async () => { + if (!isCategoriesLoaded) return; setWarehouseLoading(true); try { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); @@ -249,7 +257,7 @@ export default function WarehouseManagementPage() { } finally { setWarehouseLoading(false); } - }, [categoryOptions, resolveCategory, searchFilters]); + }, [categoryOptions, resolveCategory, searchFilters, isCategoriesLoaded]); useEffect(() => { fetchWarehouses(); @@ -264,6 +272,7 @@ export default function WarehouseManagementPage() { setLocations([]); return; } + if (!isCategoriesLoaded) return; setLocationLoading(true); try { const res = await apiClient.post( @@ -297,7 +306,7 @@ export default function WarehouseManagementPage() { } finally { setLocationLoading(false); } - }, [selectedWarehouse?.warehouse_code, locationCategoryOptions, resolveCategory]); + }, [selectedWarehouse?.warehouse_code, locationCategoryOptions, resolveCategory, isCategoriesLoaded]); useEffect(() => { setLocationCheckedIds([]); diff --git a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx index ad55d2b2..1c45f5f2 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx @@ -205,6 +205,7 @@ export default function ItemInfoPage() { // 카테고리 옵션 (API에서 로드) const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 선택된 행 const [selectedId, setSelectedId] = useState(null); @@ -351,8 +352,10 @@ export default function ItemInfoPage() { }) ); setCategoryOptions(optMap); + setIsCategoriesLoaded(true); } catch (err) { console.error("카테고리 로드 실패:", err); + setIsCategoriesLoaded(true); } }; loadCategories(); @@ -360,6 +363,7 @@ export default function ItemInfoPage() { // 데이터 조회 const fetchItems = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); @@ -398,7 +402,7 @@ export default function ItemInfoPage() { } finally { setLoading(false); } - }, [categoryOptions, searchFilters]); + }, [categoryOptions, searchFilters, isCategoriesLoaded]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx index 833b16d5..611b8b57 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx @@ -67,6 +67,7 @@ export default function SubcontractorItemPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 외주업체 추가 모달 const [subSelectOpen, setSubSelectOpen] = useState(false); @@ -89,27 +90,33 @@ export default function SubcontractorItemPage() { // 카테고리 로드 useEffect(() => { const load = async () => { - 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { - try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); - if (res.data?.success) optMap[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - // 외주업체 거래유형 (subcontractor_mng.division) try { - const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); - if (res.data?.success) optMap["subcontractor_division"] = flatten(res.data.data || []); - } catch { /* skip */ } - setCategoryOptions(optMap); + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + // 외주업체 거래유형 (subcontractor_mng.division) + try { + const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); + if (res.data?.success) optMap["subcontractor_division"] = flatten(res.data.data || []); + } catch { /* skip */ } + setCategoryOptions(optMap); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); + } }; load(); }, []); @@ -143,6 +150,7 @@ export default function SubcontractorItemPage() { )?.code; const fetchItems = useCallback(async () => { + if (!isCategoriesLoaded) return; setItemLoading(true); try { const filters: any[] = []; @@ -174,7 +182,7 @@ export default function SubcontractorItemPage() { } finally { setItemLoading(false); } - }, [searchKeyword, categoryOptions, outsourcingDivisionCode]); + }, [searchKeyword, categoryOptions, outsourcingDivisionCode, isCategoriesLoaded]); useEffect(() => { fetchItems(); }, [fetchItems]); diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx index 191608a8..fc45165d 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx @@ -122,42 +122,49 @@ export default function SubcontractorManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 카테고리 로드 useEffect(() => { const load = async () => { - 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + for (const col of ["division", "status"]) { + try { + const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } } - return result; - }; - for (const col of ["division", "status"]) { - try { - const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/${col}/values`); - if (res.data?.success) optMap[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - // item_info의 division/unit/material 카테고리도 로드 (품목 검색 시 외주관리 코드 조회용) - for (const col of ["division", "unit", "material"]) { - try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); - if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); - } catch { /* skip */ } - } - setCategoryOptions(optMap); + // item_info의 division/unit/material 카테고리도 로드 (품목 검색 시 외주관리 코드 조회용) + for (const col of ["division", "unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setCategoryOptions(optMap); - const priceOpts: Record = {}; - for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { - try { - const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); - if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); - } catch { /* skip */ } + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - setPriceCategoryOptions(priceOpts); }; load(); }, []); @@ -190,6 +197,7 @@ export default function SubcontractorManagementPage() { // 외주업체 목록 조회 const fetchSubcontractors = useCallback(async () => { + if (!isCategoriesLoaded) return; setSubcontractorLoading(true); try { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); @@ -216,7 +224,7 @@ export default function SubcontractorManagementPage() { } finally { setSubcontractorLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchSubcontractors(); }, [fetchSubcontractors]); diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index f361f442..7d90b72c 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -309,6 +309,7 @@ export default function BomManagementPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 사용자 맵 (userId → userName) const [userMap, setUserMap] = useState>({}); @@ -433,7 +434,11 @@ export default function BomManagementPage() { } setCategoryOptions(results); - } catch {} + } catch { + // skip + } finally { + setIsCategoriesLoaded(true); + } }; loadCategories(); // 사용자 목록 로드 @@ -450,6 +455,7 @@ export default function BomManagementPage() { // ─── BOM 상세 로드 ──────────────────────────── const fetchBomDetail = useCallback(async (bomId: string) => { + if (!isCategoriesLoaded) return; setDetailLoading(true); try { // 헤더 조회 @@ -535,7 +541,7 @@ export default function BomManagementPage() { } finally { setDetailLoading(false); } - }, [categoryOptions]); + }, [categoryOptions, isCategoriesLoaded]); // 버전 목록 로드 const fetchVersions = useCallback(async (bomId: string) => { diff --git a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx index f712d3ed..89dd8afc 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx @@ -161,6 +161,7 @@ export default function PurchaseOrderPage() { const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); const [checkedIds, setCheckedIds] = useState([]); // 테이블 설정 @@ -206,69 +207,75 @@ export default function PurchaseOrderPage() { // 카테고리 로드 useEffect(() => { const loadCategories = async () => { - const catColumns = ["input_mode", "price_mode"]; - 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - const dedup = (items: { code: string; label: string }[]) => { - const seen = new Set(); - return items.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 catColumns = ["input_mode", "price_mode"]; + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + const dedup = (items: { code: string; label: string }[]) => { + const seen = new Set(); + return items.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 suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, { + page: 1, size: 5000, autoFilter: true, + }); + const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || []; + optMap["supplier_code"] = supps.map((s: any) => ({ + code: s.supplier_code, + label: `${s.supplier_name} (${s.supplier_code})`, + })); + } catch { /* skip */ } + try { + const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { + page: 1, size: 5000, autoFilter: true, + }); + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; + optMap["manager"] = users.map((u: any) => ({ + code: u.user_id || u.id, + label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + for (const col of ["unit", "material", "division"]) { try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); + optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } - }) - ); - try { - const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, { - page: 1, size: 5000, autoFilter: true, - }); - const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || []; - optMap["supplier_code"] = supps.map((s: any) => ({ - code: s.supplier_code, - label: `${s.supplier_name} (${s.supplier_code})`, - })); - } catch { /* skip */ } - try { - const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, size: 5000, autoFilter: true, - }); - const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; - optMap["manager"] = users.map((u: any) => ({ - code: u.user_id || u.id, - label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - for (const col of ["unit", "material", "division"]) { - try { - 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); + const divs = optMap["item_division"] || []; + const purchaseDiv = divs.find((o) => o.label === "구매관리") + || divs.find((o) => o.label === "원자재") + || divs.find((o) => o.label === "부자재"); + if (purchaseDiv) setItemSearchDivision(purchaseDiv.code); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - setCategoryOptions(optMap); - const divs = optMap["item_division"] || []; - const purchaseDiv = divs.find((o) => o.label === "구매관리") - || divs.find((o) => o.label === "원자재") - || divs.find((o) => o.label === "부자재"); - if (purchaseDiv) setItemSearchDivision(purchaseDiv.code); }; loadCategories(); }, []); @@ -278,6 +285,7 @@ export default function PurchaseOrderPage() { // 데이터 조회 const fetchOrders = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { // searchFilters를 detail / master로 분리 @@ -366,7 +374,7 @@ export default function PurchaseOrderPage() { } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchOrders(); }, [fetchOrders]); diff --git a/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx index 21f16d5e..97878920 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx @@ -236,6 +236,7 @@ export default function PurchaseItemPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); // 공급업체 추가 모달 @@ -269,39 +270,45 @@ export default function PurchaseItemPage() { // 카테고리 로드 useEffect(() => { const load = async () => { - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { - const result: { code: string; label: string; isDefault?: boolean }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - await Promise.all( - CATEGORY_COLUMNS.map(async (col) => { - try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) optMap[col] = flatten(res.data.data); - } catch { /* skip */ } - }) - ); - // 공급업체 거래유형 (supplier_mng.division) try { - const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/division/values`); - if (res.data?.success) optMap["supplier_division"] = flatten(res.data.data || []); - } catch { /* skip */ } - setCategoryOptions(optMap); - - // 단가 카테고리 - const priceOpts: Record = {}; - for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { + const result: { code: string; label: string; isDefault?: boolean }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + await Promise.all( + CATEGORY_COLUMNS.map(async (col) => { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) optMap[col] = flatten(res.data.data); + } catch { /* skip */ } + }) + ); + // 공급업체 거래유형 (supplier_mng.division) try { - const res = await apiClient.get(`/table-categories/supplier_item_prices/${col}/values`); - if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/division/values`); + if (res.data?.success) optMap["supplier_division"] = flatten(res.data.data || []); } catch { /* skip */ } + setCategoryOptions(optMap); + + // 단가 카테고리 + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/supplier_item_prices/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - setPriceCategoryOptions(priceOpts); }; load(); }, []); @@ -313,6 +320,7 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + if (!isCategoriesLoaded) return; setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; @@ -350,7 +358,7 @@ export default function PurchaseItemPage() { } finally { setItemLoading(false); } - }, [searchFilters, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchFilters, categoryOptions, isCategoriesLoaded]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { fetchItems(); }, [fetchItems]); diff --git a/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx index 1945d82b..932f9065 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx @@ -174,6 +174,7 @@ export default function SupplierManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]); @@ -188,35 +189,41 @@ export default function SupplierManagementPage() { return result; }; const load = async () => { - const optMap: Record = {}; - for (const col of ["division", "status"]) { - try { - const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/${col}/values`); - if (res.data?.success) optMap[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - for (const col of ["division", "unit", "material"]) { - try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); - if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); - } catch { /* skip */ } - } - setCategoryOptions(optMap); - - const priceOpts: Record = {}; - for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { - try { - const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); - if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - setPriceCategoryOptions(priceOpts); - - // 세금유형 카테고리 try { - const taxRes = await apiClient.get(`/table-categories/supplier_tax_type/tax_type_name/values`); - if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || [])); - } catch { /* skip */ } + const optMap: Record = {}; + for (const col of ["division", "status"]) { + try { + const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + for (const col of ["division", "unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setCategoryOptions(optMap); + + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + + // 세금유형 카테고리 + try { + const taxRes = await apiClient.get(`/table-categories/supplier_tax_type/tax_type_name/values`); + if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || [])); + } catch { /* skip */ } + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); + } }; load(); apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) @@ -230,6 +237,7 @@ export default function SupplierManagementPage() { // 공급업체 목록 조회 const fetchSuppliers = useCallback(async () => { + if (!isCategoriesLoaded) return; setSupplierLoading(true); try { const filters = searchFilters.map(f => ({ @@ -276,7 +284,7 @@ export default function SupplierManagementPage() { } finally { setSupplierLoading(false); } - }, [searchFilters, categoryOptions, employeeOptions, mainContactMap]); + }, [searchFilters, categoryOptions, employeeOptions, mainContactMap, isCategoriesLoaded]); useEffect(() => { fetchSuppliers(); }, [fetchSuppliers]); diff --git a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx index a2946e89..0496e6da 100644 --- a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx @@ -174,6 +174,7 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]); @@ -188,35 +189,41 @@ export default function CustomerManagementPage() { return result; }; const load = async () => { - const optMap: Record = {}; - for (const col of ["division", "status"]) { - try { - const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values`); - if (res.data?.success) optMap[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - for (const col of ["division", "unit", "material"]) { - try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); - if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); - } catch { /* skip */ } - } - setCategoryOptions(optMap); - - const priceOpts: Record = {}; - for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { - try { - const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); - if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - setPriceCategoryOptions(priceOpts); - - // 세금유형 카테고리 try { - const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values`); - if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || [])); - } catch { /* skip */ } + const optMap: Record = {}; + for (const col of ["division", "status"]) { + try { + const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + for (const col of ["division", "unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setCategoryOptions(optMap); + + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + + // 세금유형 카테고리 + try { + const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values`); + if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || [])); + } catch { /* skip */ } + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); + } }; load(); apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) @@ -230,6 +237,7 @@ export default function CustomerManagementPage() { // 거래처 목록 조회 const fetchCustomers = useCallback(async () => { + if (!isCategoriesLoaded) return; setCustomerLoading(true); try { const filters = searchFilters.map(f => ({ @@ -276,7 +284,7 @@ export default function CustomerManagementPage() { } finally { setCustomerLoading(false); } - }, [searchFilters, categoryOptions, employeeOptions, mainContactMap]); + }, [searchFilters, categoryOptions, employeeOptions, mainContactMap, isCategoriesLoaded]); useEffect(() => { fetchCustomers(); }, [fetchCustomers]); diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index 4d006b77..f90fcdd6 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; @@ -175,6 +176,10 @@ export default function SalesOrderPage() { const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); + // 수주번호 자동 채번 + const [orderNoRuleId, setOrderNoRuleId] = useState(null); + const [orderNoPreview, setOrderNoPreview] = useState(null); + // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -196,6 +201,7 @@ export default function SalesOrderPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); @@ -213,82 +219,89 @@ export default function SalesOrderPage() { // 카테고리 로드 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - 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; + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + 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 custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 500, autoFilter: true, }); - }; - await Promise.all( - catColumns.map(async (col) => { + 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // item_info 카테고리 + for (const col of ["unit", "material", "division", "type"]) { try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); + optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } - }) - ); - // 거래처 목록 - try { - 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - // item_info 카테고리 - for (const col of ["unit", "material", "division", "type"]) { - try { - 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); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - 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(); }, []); // 데이터 조회 const fetchOrders = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map(f => ({ @@ -364,7 +377,7 @@ export default function SalesOrderPage() { } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchOrders(); }, [fetchOrders]); @@ -513,7 +526,7 @@ export default function SalesOrderPage() { }; // 등록 모달 열기 - const openRegisterModal = () => { + const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; @@ -524,7 +537,23 @@ export default function SalesOrderPage() { setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); + setOrderNoRuleId(null); + setOrderNoPreview(null); setIsModalOpen(true); + + // 수주번호 자동 채번 조회 + try { + const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no"); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + const ruleId = ruleRes.data.data.ruleId; + setOrderNoRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setOrderNoPreview(previewRes.data.generatedCode); + setMasterForm((prev) => ({ ...prev, order_no: previewRes.data.generatedCode })); + } + } + } catch { /* 채번 규칙 없으면 수동 입력 */ } }; // 수정 모달 열기 @@ -603,6 +632,22 @@ export default function SalesOrderPage() { // 저장 (마스터 + 디테일) const handleSave = async () => { + // 채번 규칙이 있으면 allocate, 없으면 수동 입력 필수 + if (!isEditMode && orderNoRuleId) { + try { + const allocRes = await allocateNumberingCode(orderNoRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + setMasterForm((prev) => ({ ...prev, order_no: allocRes.data.generatedCode })); + masterForm.order_no = allocRes.data.generatedCode; + } else { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } catch { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } if (!masterForm.order_no && !isEditMode) { toast.error("수주번호는 필수입니다."); return; @@ -1216,8 +1261,10 @@ export default function SalesOrderPage() { setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - placeholder="수주번호" className="h-9" disabled={isEditMode} + onChange={(e) => !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))} + readOnly={!!orderNoRuleId || isEditMode} + placeholder={orderNoRuleId ? "자동 채번" : "수주번호"} + className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")} />
diff --git a/frontend/app/(main)/COMPANY_7/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_7/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_7/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + + diff --git a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx index f11e622b..506a8306 100644 --- a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx @@ -225,6 +225,7 @@ export default function SalesItemPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); // 거래처 추가 모달 @@ -270,37 +271,43 @@ export default function SalesItemPage() { // 카테고리 로드 useEffect(() => { const load = async () => { - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { - const result: { code: string; label: string; isDefault?: boolean }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { - try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); - if (res.data?.success) optMap[col] = flatten(res.data.data || []); - } catch { /* skip */ } - } - // 거래처 거래유형 (customer_mng.division) try { - const res = await apiClient.get(`/table-categories/customer_mng/division/values`); - if (res.data?.success) optMap["customer_division"] = flatten(res.data.data || []); - } catch { /* skip */ } - setCategoryOptions(optMap); - - // 단가 카테고리 - const priceOpts: Record = {}; - for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { + const result: { code: string; label: string; isDefault?: boolean }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + // 거래처 거래유형 (customer_mng.division) try { - const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`); - if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + const res = await apiClient.get(`/table-categories/customer_mng/division/values`); + if (res.data?.success) optMap["customer_division"] = flatten(res.data.data || []); } catch { /* skip */ } + setCategoryOptions(optMap); + + // 단가 카테고리 + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - setPriceCategoryOptions(priceOpts); }; load(); }, []); @@ -312,6 +319,7 @@ export default function SalesItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + if (!isCategoriesLoaded) return; setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; @@ -350,7 +358,7 @@ export default function SalesItemPage() { } finally { setItemLoading(false); } - }, [searchFilters, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchFilters, categoryOptions, isCategoriesLoaded]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { fetchItems(); }, [fetchItems]); diff --git a/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx index 7a0ddb57..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx @@ -164,7 +164,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_8"); + const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx index 4d006b77..f90fcdd6 100644 --- a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; @@ -175,6 +176,10 @@ export default function SalesOrderPage() { const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); + // 수주번호 자동 채번 + const [orderNoRuleId, setOrderNoRuleId] = useState(null); + const [orderNoPreview, setOrderNoPreview] = useState(null); + // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -196,6 +201,7 @@ export default function SalesOrderPage() { // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState>({}); + const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); @@ -213,82 +219,89 @@ export default function SalesOrderPage() { // 카테고리 로드 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, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - 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; + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + 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 custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 500, autoFilter: true, }); - }; - await Promise.all( - catColumns.map(async (col) => { + 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // item_info 카테고리 + for (const col of ["unit", "material", "division", "type"]) { try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); + optMap[`item_${col}`] = flatten(res.data.data); } } catch { /* skip */ } - }) - ); - // 거래처 목록 - try { - 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 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.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - // item_info 카테고리 - for (const col of ["unit", "material", "division", "type"]) { - try { - 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); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } finally { + setIsCategoriesLoaded(true); } - 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(); }, []); // 데이터 조회 const fetchOrders = useCallback(async () => { + if (!isCategoriesLoaded) return; setLoading(true); try { const filters = searchFilters.map(f => ({ @@ -364,7 +377,7 @@ export default function SalesOrderPage() { } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, isCategoriesLoaded]); useEffect(() => { fetchOrders(); }, [fetchOrders]); @@ -513,7 +526,7 @@ export default function SalesOrderPage() { }; // 등록 모달 열기 - const openRegisterModal = () => { + const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; @@ -524,7 +537,23 @@ export default function SalesOrderPage() { setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); + setOrderNoRuleId(null); + setOrderNoPreview(null); setIsModalOpen(true); + + // 수주번호 자동 채번 조회 + try { + const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no"); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + const ruleId = ruleRes.data.data.ruleId; + setOrderNoRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setOrderNoPreview(previewRes.data.generatedCode); + setMasterForm((prev) => ({ ...prev, order_no: previewRes.data.generatedCode })); + } + } + } catch { /* 채번 규칙 없으면 수동 입력 */ } }; // 수정 모달 열기 @@ -603,6 +632,22 @@ export default function SalesOrderPage() { // 저장 (마스터 + 디테일) const handleSave = async () => { + // 채번 규칙이 있으면 allocate, 없으면 수동 입력 필수 + if (!isEditMode && orderNoRuleId) { + try { + const allocRes = await allocateNumberingCode(orderNoRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + setMasterForm((prev) => ({ ...prev, order_no: allocRes.data.generatedCode })); + masterForm.order_no = allocRes.data.generatedCode; + } else { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } catch { + toast.error("수주번호 채번에 실패했습니다."); + return; + } + } if (!masterForm.order_no && !isEditMode) { toast.error("수주번호는 필수입니다."); return; @@ -1216,8 +1261,10 @@ export default function SalesOrderPage() { setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - placeholder="수주번호" className="h-9" disabled={isEditMode} + onChange={(e) => !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))} + readOnly={!!orderNoRuleId || isEditMode} + placeholder={orderNoRuleId ? "자동 채번" : "수주번호"} + className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")} />
diff --git a/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + + diff --git a/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx index 53bec490..0d2a7ae9 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx @@ -164,7 +164,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_9"); + const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -304,7 +304,7 @@ export default function InventoryStatusPage() { warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", - transaction_date: new Date().toISOString(), + transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), diff --git a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx index 534582f9..c1ee134d 100644 --- a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx @@ -54,6 +54,9 @@ import { Minimize2, Merge, Settings2, + ChevronsLeft, + ChevronLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -136,6 +139,10 @@ export default function ProductionPlanManagementPage() { // 데이터 상태 const [orderItems, setOrderItems] = useState([]); + // 좌측 수주목록 페이지네이션 + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(20); + const [orderPageSizeInput, setOrderPageSizeInput] = useState("20"); const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); @@ -212,6 +219,37 @@ export default function ProductionPlanManagementPage() { } }, [filterUnplannedOrdersOnly, searchItemCode]); + // 수주목록 페이지네이션 계산 + const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize)); + const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages); + const paginatedOrderItems = useMemo(() => { + const start = (orderSafePage - 1) * orderPageSize; + return orderItems.slice(start, start + orderPageSize); + }, [orderItems, orderSafePage, orderPageSize]); + + const applyOrderPageSize = () => { + const n = parseInt(orderPageSizeInput, 10); + if (!isNaN(n) && n >= 1) { setOrderPageSize(n); setOrderPage(1); } + else setOrderPageSizeInput(String(orderPageSize)); + }; + + const getOrderPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (orderTotalPages <= 7) { + for (let i = 1; i <= orderTotalPages; i++) pages.push(i); + } else { + pages.push(1); + if (orderSafePage > 3) pages.push("..."); + for (let i = Math.max(2, orderSafePage - 1); i <= Math.min(orderTotalPages - 1, orderSafePage + 1); i++) pages.push(i); + if (orderSafePage < orderTotalPages - 2) pages.push("..."); + pages.push(orderTotalPages); + } + return pages; + }; + + // orderItems 변경 시 1페이지로 리셋 + useEffect(() => { setOrderPage(1); }, [orderItems.length]); + const fetchStockShortage = useCallback(async () => { setLoadingStock(true); try { @@ -977,7 +1015,7 @@ export default function ProductionPlanManagementPage() { {/* 수주데이터 탭 */} -
+
수주 목록
-
+
{loadingOrders ? (
@@ -1019,7 +1057,7 @@ export default function ProductionPlanManagementPage() {

수주 데이터가 없습니다

) : ( -
+
{(() => { // 디테일 행에서 개별 값을 표시하는 컬럼 매핑 const DETAIL_VALUE_MAP: Record = { @@ -1069,7 +1107,7 @@ export default function ProductionPlanManagementPage() { - {ts.groupData(orderItems).map((item, rowIdx) => { + {ts.groupData(paginatedOrderItems).map((item, rowIdx) => { if (item._isGroupSummary) { return ( @@ -1145,6 +1183,56 @@ export default function ProductionPlanManagementPage() {
)}
+ {/* 수주목록 페이지네이션 */} + {orderItems.length > 0 && ( +
+
+
+ 전체 + {orderItems.length.toLocaleString()} + +
+
+ setOrderPageSizeInput(e.target.value)} + onBlur={applyOrderPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyOrderPageSize(); } }} + className="h-7 w-16 text-center text-xs" /> + 건씩 보기 +
+
+
+ + + {getOrderPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ {orderSafePage} / {orderTotalPages} 페이지 +
+ )} {/* 안전재고 부족분 탭 */} diff --git a/frontend/app/(main)/COMPANY_9/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_9/sales/quote/page.tsx index 18e106c9..f5d3f4f7 100644 --- a/frontend/app/(main)/COMPANY_9/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/quote/page.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -92,6 +94,30 @@ export default function QuoteManagementPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemTotal, setItemTotal] = useState(0); + const itemPageSize = 20; + const itemTotalPages = Math.max(1, Math.ceil(itemTotal / itemPageSize)); + + // 품목 카테고리 코드→라벨 (단위 등) + const [itemCatMap, setItemCatMap] = useState>>({}); + useEffect(() => { + (async () => { + const map: Record> = {}; + for (const col of ["unit", "material"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + map[col] = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[col][v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(map); + })(); + }, []); + const resolveItemCat = (col: string, code: string) => itemCatMap[col]?.[code] || code; // 거래처 검색 const [custSearchOpen, setCustSearchOpen] = useState(false); @@ -481,7 +507,8 @@ export default function QuoteManagementPage() { // ── 품목 검색 ── - const searchItemInfo = async () => { + const searchItemInfo = async (page?: number) => { + const p = page ?? itemPage; setItemSearchLoading(true); try { const filters: any[] = []; @@ -489,12 +516,13 @@ export default function QuoteManagementPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post("/table-management/tables/item_info/data", { - page: 1, size: 50, + page: p, size: itemPageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const resData = res.data?.data; setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || resData?.totalCount || 0); } catch { toast.error("품목 조회 실패"); } finally { setItemSearchLoading(false); } }; @@ -514,9 +542,9 @@ export default function QuoteManagementPage() { const newItems = selected.map((item) => calcItem({ item_code: item.item_number || item.item_code || "", item_name: item.item_name || "", - spec: item.spec || item.standard || "", + spec: item.size || item.spec || item.standard || "", qty: "1", - unit: item.unit || "EA", + unit: resolveItemCat("unit", item.unit) || "EA", request_length: "", unit_price: String(item.selling_price || item.standard_price || 0), supply_amount: "0", @@ -715,7 +743,7 @@ export default function QuoteManagementPage() {
-
- + @@ -967,8 +995,8 @@ export default function QuoteManagementPage() { - - + + ); @@ -976,14 +1004,51 @@ export default function QuoteManagementPage() {
선택 품목코드 {row.item_number || row.item_code || "-"} {row.item_name || "-"}{row.spec || row.standard || "-"}{row.unit || "EA"}{row.size || row.spec || row.standard || "-"}{resolveItemCat("unit", row.unit) || "EA"} {fmt(String(row.selling_price || row.standard_price || 0))}
- - 선택: {itemSelectedMap.size}건 -
- - + {/* 하단 페이지네이션 고정 (EDataTable 스타일) */} +
+
+
+ 전체 + {itemTotal.toLocaleString()} + 건 · 선택 {itemSelectedMap.size}건 +
+
+ + + {Array.from({ length: Math.min(5, itemTotalPages) }, (_, i) => { + const start = Math.max(1, Math.min(itemPage - 2, itemTotalPages - 4)); + const p = start + i; + if (p > itemTotalPages) return null; + return ( + + ); + })} + + +
+
+ + +