From ac5292f9b06e1cc6ef498cbc214793e76c577658 Mon Sep 17 00:00:00 2001 From: kjs Date: Sun, 29 Mar 2026 20:04:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20COMPANY=5F29=20=EB=B6=80=EC=84=9C?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부서 등록 시 자동 생성 부서코드 기능 추가 - 사용자 관리에서 부서 필수 입력 검증 추가 - 품목 정보 페이지에서 입력 필드에 placeholder 추가 - 고객 관리 페이지에서 원본 카테고리 코드 보관 및 빈 문자열을 null로 변환하는 로직 추가 - 판매 주문 페이지에서 품목 검색 필터에 관리품목 선택 기능 추가 이 커밋은 부서 및 사용자 관리 기능을 개선하고, 사용자 경험을 향상시키기 위한 여러 변경 사항을 포함합니다. --- .../src/controllers/authController.ts | 5 +- .../master-data/department/page.tsx | 46 +++++++++++++---- .../COMPANY_29/master-data/item-info/page.tsx | 8 +-- .../production/work-instruction/page.tsx | 32 ++++++------ .../(main)/COMPANY_29/sales/customer/page.tsx | 35 ++++++++++--- .../(main)/COMPANY_29/sales/order/page.tsx | 25 +++++++++- .../COMPANY_7/master-data/department/page.tsx | 46 +++++++++++++---- .../COMPANY_7/master-data/item-info/page.tsx | 8 +-- .../production/work-instruction/page.tsx | 32 ++++++------ .../(main)/COMPANY_7/sales/customer/page.tsx | 35 ++++++++++--- .../app/(main)/COMPANY_7/sales/order/page.tsx | 25 +++++++++- frontend/components/admin/CompanySwitcher.tsx | 10 ++-- .../components/layout/AdminPageRenderer.tsx | 49 +++++++++++++++++++ frontend/components/layout/AppLayout.tsx | 3 +- frontend/hooks/useLogin.ts | 5 ++ .../v2-table-list/SingleTableWithSticky.tsx | 2 +- .../v2-table-list/TableListComponent.tsx | 16 ++++-- 17 files changed, 301 insertions(+), 81 deletions(-) diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 809513b6..402665f5 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -62,6 +62,7 @@ export class AuthController { // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; + let firstMenuName: string | null = null; try { const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); @@ -74,7 +75,8 @@ export class AuthController { if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; - logger.debug(`첫 번째 메뉴: ${firstMenuPath}`); + firstMenuName = firstMenu.menu_name_kor || firstMenu.translated_name || firstMenu.menu_name || null; + logger.debug(`첫 번째 메뉴: ${firstMenuPath} (${firstMenuName})`); } else { logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } @@ -112,6 +114,7 @@ export class AuthController { userInfo, token: loginResult.token, firstMenuPath, + firstMenuName, popLandingPath, }, }); diff --git a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx index 4e943810..a898fa2d 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx @@ -144,10 +144,35 @@ export default function DepartmentPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); // 부서 등록 - const openDeptRegister = () => { - setDeptForm({}); + const openDeptRegister = async () => { + setDeptForm({ dept_code: "불러오는 중..." }); setDeptEditMode(false); setDeptModalOpen(true); + // DB에서 마지막 부서코드 조회 → 순번+1로 다음 코드 생성 + try { + const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, { + page: 1, size: 500, autoFilter: true, + sort: { columnName: "dept_code", order: "desc" }, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + // 기존 부서코드에서 패턴 추출 (예: DEPT-001, DEPT-002 → 접두사 "DEPT", 구분자 "-", 순번 3자리) + const codes = rows.map((r: any) => r.dept_code).filter(Boolean).sort(); + let nextCode = "(자동 생성)"; + if (codes.length > 0) { + const lastCode = codes[codes.length - 1] as string; + // 마지막 숫자 부분 추출 (DEPT-003 → prefix="DEPT-", num=3, pad=3) + const match = lastCode.match(/^(.*?)(\d+)$/); + if (match) { + const prefix = match[1]; // "DEPT-" + const num = parseInt(match[2], 10) + 1; + const pad = match[2].length; // 3 + nextCode = `${prefix}${String(num).padStart(pad, "0")}`; + } + } + setDeptForm((prev) => ({ ...prev, dept_code: nextCode })); + } catch { + setDeptForm((prev) => ({ ...prev, dept_code: "(자동 생성)" })); + } }; const openDeptEdit = () => { @@ -169,7 +194,7 @@ export default function DepartmentPage() { toast.success("수정되었습니다."); } else { await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: deptForm.dept_code || "", + dept_code: "", dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null, }); @@ -225,6 +250,7 @@ export default function DepartmentPage() { const handleUserSave = async () => { if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; } const errors = validateForm(userForm, ["cell_phone", "email"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } @@ -240,10 +266,10 @@ export default function DepartmentPage() { user_name: userForm.user_name, user_name_eng: userForm.user_name_eng || undefined, user_password: password || undefined, - email: userForm.email || undefined, + email: userEditMode ? (userForm.email || null) : (userForm.email || undefined), tel: userForm.tel || undefined, - cell_phone: userForm.cell_phone || undefined, - sabun: userForm.sabun || undefined, + cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined), + sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined), position_name: userForm.position_name || undefined, dept_code: userForm.dept_code || undefined, dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, @@ -374,7 +400,7 @@ export default function DepartmentPage() {
setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} - placeholder="부서코드" className="h-9" disabled={deptEditMode} /> + placeholder={deptEditMode ? "부서코드" : "자동 생성"} className="h-9" disabled />
@@ -424,12 +450,12 @@ export default function DepartmentPage() {
setUserForm((p) => ({ ...p, sabun: e.target.value }))} - placeholder="사번" className="h-9" /> + placeholder="사번" className="h-9" autoComplete="off" />
setUserForm((p) => ({ ...p, user_password: e.target.value }))} - placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" /> + placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
@@ -437,7 +463,7 @@ export default function DepartmentPage() { placeholder="직급" className="h-9" />
- + setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.disabled ? field.placeholder : field.label} + placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} disabled={field.disabled && !isEditMode} className="h-9" /> diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx index 0986e3eb..369eac6a 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx @@ -81,6 +81,10 @@ export default function WorkInstructionPage() { const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 등록 확인 모달 — 인라인 추가 폼 + const [confirmAddQty, setConfirmAddQty] = useState(""); + const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false); + // 수정 모달 const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editOrder, setEditOrder] = useState(null); @@ -217,6 +221,18 @@ export default function WorkInstructionPage() { setIsRegModalOpen(false); setIsConfirmModalOpen(true); }; + // 등록 확인 모달 — 인라인 품목 추가 + const addConfirmItem = () => { + if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = confirmItems[0]; + setConfirmItems(prev => [...prev, { + itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", + qty: Number(confirmAddQty), remark: "", + sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + }]); + setConfirmAddQty(""); + }; + // ─── 2단계 최종 적용 ─── const finalizeRegistration = async () => { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } @@ -629,7 +645,7 @@ export default function WorkInstructionPage() {
- 순번품목코드품목명규격수량비고 + 순번품목코드품목명규격수량비고 {confirmItems.map((item, idx) => ( @@ -640,6 +656,7 @@ export default function WorkInstructionPage() { {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + ))} @@ -711,19 +728,6 @@ export default function WorkInstructionPage() { - {/* 인라인 추가 폼 */} -
-
-
setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" />
-
-
-
-
-
- -
-
- {/* 품목 테이블 */}
diff --git a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx index 461df230..f909d7dd 100644 --- a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx @@ -12,7 +12,7 @@ * - 납품처 등록 (delivery_destination) */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -56,6 +56,7 @@ const LEFT_COLUMNS: DataGridColumn[] = [ { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, { key: "business_number", label: "사업자번호", width: "w-[110px]" }, { key: "email", label: "이메일", width: "w-[130px]" }, + { key: "address", label: "주소", minWidth: "min-w-[150px]" }, { key: "status", label: "상태", width: "w-[60px]" }, ]; @@ -79,6 +80,7 @@ export default function CustomerManagementPage() { // 좌측: 거래처 목록 const [customers, setCustomers] = useState([]); + const [rawCustomers, setRawCustomers] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); @@ -96,6 +98,7 @@ export default function CustomerManagementPage() { // 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용) const [editItemData, setEditItemData] = useState(null); + const savingRef = useRef(false); const [deliveryLoading, setDeliveryLoading] = useState(false); // 모달 @@ -192,6 +195,8 @@ export default function CustomerManagementPage() { autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; + // raw 데이터 보관 (수정 시 원본 카테고리 코드 사용) + setRawCustomers(raw); // 카테고리 코드→라벨 변환 const resolve = (col: string, code: string) => { if (!code) return ""; @@ -334,7 +339,9 @@ export default function CustomerManagementPage() { const openCustomerEdit = () => { if (!selectedCustomer) return; - setCustomerForm({ ...selectedCustomer }); + // raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터) + const rawData = rawCustomers.find((c) => c.id === selectedCustomerId); + setCustomerForm({ ...(rawData || selectedCustomer) }); setFormErrors({}); setCustomerEditMode(true); setCustomerModalOpen(true); @@ -365,13 +372,18 @@ export default function CustomerManagementPage() { setSaving(true); try { const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm; + // 빈 문자열을 null로 변환 (DB 타입 호환) + const cleanFields: Record = {}; + for (const [key, value] of Object.entries(fields)) { + cleanFields[key] = value === "" ? null : value; + } if (customerEditMode && id) { await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, { - originalData: { id }, updatedData: fields, + originalData: { id }, updatedData: cleanFields, }); toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, fields); + await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields); toast.success("등록되었습니다."); } setCustomerModalOpen(false); @@ -569,6 +581,8 @@ export default function CustomerManagementPage() { const handleItemDetailSave = async () => { if (!selectedCustomer) return; + if (savingRef.current) return; + savingRef.current = true; const isEditingExisting = !!editItemData; setSaving(true); try { @@ -641,9 +655,16 @@ export default function CustomerManagementPage() { }); } - const priceRows = (itemPrices[itemKey] || []).filter((p) => + const allPriceRows = itemPrices[itemKey] || []; + const priceRows = allPriceRows.filter((p) => (p.base_price && Number(p.base_price) > 0) || p.start_date ); + if (allPriceRows.length > 0 && priceRows.length === 0) { + toast.error("단가 정보를 입력해주세요. (기준가 또는 적용시작일 필수)"); + setSaving(false); + savingRef.current = false; + return; + } for (const price of priceRows) { await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { mapping_id: mappingId || "", customer_id: selectedCustomer.customer_code, item_id: itemKey, @@ -669,6 +690,7 @@ export default function CustomerManagementPage() { toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); + savingRef.current = false; } }; @@ -773,9 +795,10 @@ export default function CustomerManagementPage() { // 셀렉트 렌더링 const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => ( - onChange(v === "__none__" ? "" : v)}> + 선택 안 함 {(categoryOptions[field] || []).map((o) => {o.label})} diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index 283f4362..57e7df87 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -86,6 +86,7 @@ export default function SalesOrderPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSearchDivision, setItemSearchDivision] = useState("all"); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -448,6 +449,17 @@ export default function SalesOrderPage() { if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } + if (itemSearchDivision !== "all") { + filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision }); + } else { + // 기본: 영업관련 division만 (판매품, 제품, 영업관리 등) + const salesDivCodes = (categoryOptions["item_division"] || []) + .filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label))) + .map((o) => o.code); + if (salesDivCodes.length > 0) { + filters.push({ columnName: "division", operator: "in", value: salesDivCodes }); + } + } const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 50, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -853,6 +865,15 @@ export default function SalesOrderPage() { onChange={(e) => setItemSearchKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && searchItems()} className="h-9 flex-1" /> + @@ -892,8 +913,8 @@ export default function SalesOrderPage() { {item.item_number} {item.item_name} {item.size} - {item.material} - {item.unit} + {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} + {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit} ))} diff --git a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx index 4e943810..a898fa2d 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx @@ -144,10 +144,35 @@ export default function DepartmentPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); // 부서 등록 - const openDeptRegister = () => { - setDeptForm({}); + const openDeptRegister = async () => { + setDeptForm({ dept_code: "불러오는 중..." }); setDeptEditMode(false); setDeptModalOpen(true); + // DB에서 마지막 부서코드 조회 → 순번+1로 다음 코드 생성 + try { + const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, { + page: 1, size: 500, autoFilter: true, + sort: { columnName: "dept_code", order: "desc" }, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + // 기존 부서코드에서 패턴 추출 (예: DEPT-001, DEPT-002 → 접두사 "DEPT", 구분자 "-", 순번 3자리) + const codes = rows.map((r: any) => r.dept_code).filter(Boolean).sort(); + let nextCode = "(자동 생성)"; + if (codes.length > 0) { + const lastCode = codes[codes.length - 1] as string; + // 마지막 숫자 부분 추출 (DEPT-003 → prefix="DEPT-", num=3, pad=3) + const match = lastCode.match(/^(.*?)(\d+)$/); + if (match) { + const prefix = match[1]; // "DEPT-" + const num = parseInt(match[2], 10) + 1; + const pad = match[2].length; // 3 + nextCode = `${prefix}${String(num).padStart(pad, "0")}`; + } + } + setDeptForm((prev) => ({ ...prev, dept_code: nextCode })); + } catch { + setDeptForm((prev) => ({ ...prev, dept_code: "(자동 생성)" })); + } }; const openDeptEdit = () => { @@ -169,7 +194,7 @@ export default function DepartmentPage() { toast.success("수정되었습니다."); } else { await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: deptForm.dept_code || "", + dept_code: "", dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null, }); @@ -225,6 +250,7 @@ export default function DepartmentPage() { const handleUserSave = async () => { if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; } const errors = validateForm(userForm, ["cell_phone", "email"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } @@ -240,10 +266,10 @@ export default function DepartmentPage() { user_name: userForm.user_name, user_name_eng: userForm.user_name_eng || undefined, user_password: password || undefined, - email: userForm.email || undefined, + email: userEditMode ? (userForm.email || null) : (userForm.email || undefined), tel: userForm.tel || undefined, - cell_phone: userForm.cell_phone || undefined, - sabun: userForm.sabun || undefined, + cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined), + sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined), position_name: userForm.position_name || undefined, dept_code: userForm.dept_code || undefined, dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, @@ -374,7 +400,7 @@ export default function DepartmentPage() {
setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} - placeholder="부서코드" className="h-9" disabled={deptEditMode} /> + placeholder={deptEditMode ? "부서코드" : "자동 생성"} className="h-9" disabled />
@@ -424,12 +450,12 @@ export default function DepartmentPage() {
setUserForm((p) => ({ ...p, sabun: e.target.value }))} - placeholder="사번" className="h-9" /> + placeholder="사번" className="h-9" autoComplete="off" />
setUserForm((p) => ({ ...p, user_password: e.target.value }))} - placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" /> + placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
@@ -437,7 +463,7 @@ export default function DepartmentPage() { placeholder="직급" className="h-9" />
- + setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.disabled ? field.placeholder : field.label} + placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} disabled={field.disabled && !isEditMode} className="h-9" /> diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index 0986e3eb..369eac6a 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -81,6 +81,10 @@ export default function WorkInstructionPage() { const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 등록 확인 모달 — 인라인 추가 폼 + const [confirmAddQty, setConfirmAddQty] = useState(""); + const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false); + // 수정 모달 const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editOrder, setEditOrder] = useState(null); @@ -217,6 +221,18 @@ export default function WorkInstructionPage() { setIsRegModalOpen(false); setIsConfirmModalOpen(true); }; + // 등록 확인 모달 — 인라인 품목 추가 + const addConfirmItem = () => { + if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = confirmItems[0]; + setConfirmItems(prev => [...prev, { + itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", + qty: Number(confirmAddQty), remark: "", + sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + }]); + setConfirmAddQty(""); + }; + // ─── 2단계 최종 적용 ─── const finalizeRegistration = async () => { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } @@ -629,7 +645,7 @@ export default function WorkInstructionPage() {
- 순번품목코드품목명규격수량비고 + 순번품목코드품목명규격수량비고 {confirmItems.map((item, idx) => ( @@ -640,6 +656,7 @@ export default function WorkInstructionPage() { {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + ))} @@ -711,19 +728,6 @@ export default function WorkInstructionPage() { - {/* 인라인 추가 폼 */} -
-
-
setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" />
-
-
-
-
-
- -
-
- {/* 품목 테이블 */}
diff --git a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx index 461df230..f909d7dd 100644 --- a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx @@ -12,7 +12,7 @@ * - 납품처 등록 (delivery_destination) */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -56,6 +56,7 @@ const LEFT_COLUMNS: DataGridColumn[] = [ { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, { key: "business_number", label: "사업자번호", width: "w-[110px]" }, { key: "email", label: "이메일", width: "w-[130px]" }, + { key: "address", label: "주소", minWidth: "min-w-[150px]" }, { key: "status", label: "상태", width: "w-[60px]" }, ]; @@ -79,6 +80,7 @@ export default function CustomerManagementPage() { // 좌측: 거래처 목록 const [customers, setCustomers] = useState([]); + const [rawCustomers, setRawCustomers] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); @@ -96,6 +98,7 @@ export default function CustomerManagementPage() { // 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용) const [editItemData, setEditItemData] = useState(null); + const savingRef = useRef(false); const [deliveryLoading, setDeliveryLoading] = useState(false); // 모달 @@ -192,6 +195,8 @@ export default function CustomerManagementPage() { autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; + // raw 데이터 보관 (수정 시 원본 카테고리 코드 사용) + setRawCustomers(raw); // 카테고리 코드→라벨 변환 const resolve = (col: string, code: string) => { if (!code) return ""; @@ -334,7 +339,9 @@ export default function CustomerManagementPage() { const openCustomerEdit = () => { if (!selectedCustomer) return; - setCustomerForm({ ...selectedCustomer }); + // raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터) + const rawData = rawCustomers.find((c) => c.id === selectedCustomerId); + setCustomerForm({ ...(rawData || selectedCustomer) }); setFormErrors({}); setCustomerEditMode(true); setCustomerModalOpen(true); @@ -365,13 +372,18 @@ export default function CustomerManagementPage() { setSaving(true); try { const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm; + // 빈 문자열을 null로 변환 (DB 타입 호환) + const cleanFields: Record = {}; + for (const [key, value] of Object.entries(fields)) { + cleanFields[key] = value === "" ? null : value; + } if (customerEditMode && id) { await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, { - originalData: { id }, updatedData: fields, + originalData: { id }, updatedData: cleanFields, }); toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, fields); + await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields); toast.success("등록되었습니다."); } setCustomerModalOpen(false); @@ -569,6 +581,8 @@ export default function CustomerManagementPage() { const handleItemDetailSave = async () => { if (!selectedCustomer) return; + if (savingRef.current) return; + savingRef.current = true; const isEditingExisting = !!editItemData; setSaving(true); try { @@ -641,9 +655,16 @@ export default function CustomerManagementPage() { }); } - const priceRows = (itemPrices[itemKey] || []).filter((p) => + const allPriceRows = itemPrices[itemKey] || []; + const priceRows = allPriceRows.filter((p) => (p.base_price && Number(p.base_price) > 0) || p.start_date ); + if (allPriceRows.length > 0 && priceRows.length === 0) { + toast.error("단가 정보를 입력해주세요. (기준가 또는 적용시작일 필수)"); + setSaving(false); + savingRef.current = false; + return; + } for (const price of priceRows) { await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { mapping_id: mappingId || "", customer_id: selectedCustomer.customer_code, item_id: itemKey, @@ -669,6 +690,7 @@ export default function CustomerManagementPage() { toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); + savingRef.current = false; } }; @@ -773,9 +795,10 @@ export default function CustomerManagementPage() { // 셀렉트 렌더링 const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => ( - onChange(v === "__none__" ? "" : v)}> + 선택 안 함 {(categoryOptions[field] || []).map((o) => {o.label})} diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index 283f4362..57e7df87 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -86,6 +86,7 @@ export default function SalesOrderPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSearchDivision, setItemSearchDivision] = useState("all"); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -448,6 +449,17 @@ export default function SalesOrderPage() { if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } + if (itemSearchDivision !== "all") { + filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision }); + } else { + // 기본: 영업관련 division만 (판매품, 제품, 영업관리 등) + const salesDivCodes = (categoryOptions["item_division"] || []) + .filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label))) + .map((o) => o.code); + if (salesDivCodes.length > 0) { + filters.push({ columnName: "division", operator: "in", value: salesDivCodes }); + } + } const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 50, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -853,6 +865,15 @@ export default function SalesOrderPage() { onChange={(e) => setItemSearchKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && searchItems()} className="h-9 flex-1" /> + @@ -892,8 +913,8 @@ export default function SalesOrderPage() { {item.item_number} {item.item_name} {item.size} - {item.material} - {item.unit} + {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} + {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit} ))} diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx index 23445780..89843898 100644 --- a/frontend/components/admin/CompanySwitcher.tsx +++ b/frontend/components/admin/CompanySwitcher.tsx @@ -114,9 +114,13 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp } logger.info("회사 전환 성공", { companyCode }); - - // 즉시 페이지 새로고침 (토큰이 이미 저장됨) - window.location.reload(); + + // 탭 스토어 초기화 + 메뉴명 캐시 제거 + const { useTabStore } = await import("@/stores/tabStore"); + useTabStore.getState().closeAllTabs(); + localStorage.removeItem("currentMenuName"); + // 메인 페이지로 이동 (이전 회사의 stale URL 방지) + window.location.href = "/"; } catch (error: any) { logger.error("회사 전환 실패", error); alert(error.message || "회사 전환 중 오류가 발생했습니다."); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index fdda4fb3..1b379b5d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -119,6 +119,31 @@ const ADMIN_PAGE_REGISTRY: Record> = { // === COMPANY_9 (제일그라스) === "/COMPANY_9/sales/order": dynamic(() => import("@/app/(main)/COMPANY_9/sales/order/page"), { ssr: false, loading: LoadingFallback }), + // === COMPANY_29 === + "/COMPANY_29/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/department/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_29/sales/customer/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_29/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_29/sales/claim/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_29/production/process-info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_29/production/plan-management/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/project": dynamic(() => import("@/app/(main)/COMPANY_29/design/project/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/change-management/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_29/design/my-work/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_29/design/design-request/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/task-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), @@ -197,6 +222,30 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/COMPANY_7/design/task-management": () => import("@/app/(main)/COMPANY_7/design/task-management/page"), // COMPANY_9 (제일그라스) "/COMPANY_9/sales/order": () => import("@/app/(main)/COMPANY_9/sales/order/page"), + // COMPANY_29 + "/COMPANY_29/master-data/item-info": () => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), + "/COMPANY_29/master-data/department": () => import("@/app/(main)/COMPANY_29/master-data/department/page"), + "/COMPANY_29/sales/order": () => import("@/app/(main)/COMPANY_29/sales/order/page"), + "/COMPANY_29/sales/customer": () => import("@/app/(main)/COMPANY_29/sales/customer/page"), + "/COMPANY_29/sales/sales-item": () => import("@/app/(main)/COMPANY_29/sales/sales-item/page"), + "/COMPANY_29/sales/shipping-order": () => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"), + "/COMPANY_29/sales/shipping-plan": () => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"), + "/COMPANY_29/sales/claim": () => import("@/app/(main)/COMPANY_29/sales/claim/page"), + "/COMPANY_29/production/process-info": () => import("@/app/(main)/COMPANY_29/production/process-info/page"), + "/COMPANY_29/production/work-instruction": () => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), + "/COMPANY_29/production/plan-management": () => import("@/app/(main)/COMPANY_29/production/plan-management/page"), + "/COMPANY_29/equipment/info": () => import("@/app/(main)/COMPANY_29/equipment/info/page"), + "/COMPANY_29/logistics/material-status": () => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), + "/COMPANY_29/logistics/outbound": () => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), + "/COMPANY_29/logistics/receiving": () => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), + "/COMPANY_29/logistics/packaging": () => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), + "/COMPANY_29/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"), + "/COMPANY_29/outsourcing/subcontractor-item": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"), + "/COMPANY_29/design/project": () => import("@/app/(main)/COMPANY_29/design/project/page"), + "/COMPANY_29/design/change-management": () => import("@/app/(main)/COMPANY_29/design/change-management/page"), + "/COMPANY_29/design/my-work": () => import("@/app/(main)/COMPANY_29/design/my-work/page"), + "/COMPANY_29/design/design-request": () => import("@/app/(main)/COMPANY_29/design/design-request/page"), + "/COMPANY_29/design/task-management": () => import("@/app/(main)/COMPANY_29/design/task-management/page"), }; const DYNAMIC_ADMIN_PATTERNS: Array<{ diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index e7663e85..d82d44f0 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -250,7 +250,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (screenMatch) { const screenId = parseInt(screenMatch[1]); const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid }); + const savedMenuName = typeof window !== "undefined" ? localStorage.getItem("currentMenuName") : null; + store.openTab({ type: "screen", title: savedMenuName || `화면 ${screenId}`, screenId, menuObjid }); return; } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 01231441..0c8ac7d5 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -115,6 +115,7 @@ export const useLogin = () => { const result = await apiCall<{ token?: string; firstMenuPath?: string; + firstMenuName?: string; popLandingPath?: string; }>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, { userId: formData.userId, @@ -139,6 +140,10 @@ export const useLogin = () => { } } else { const firstMenuPath = result.data?.firstMenuPath; + const firstMenuName = result.data?.firstMenuName; + if (firstMenuName) { + localStorage.setItem("currentMenuName", firstMenuName); + } if (firstMenuPath) { router.push(firstMenuPath); } else { diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index 3a7b4dad..c34a0e57 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -38,7 +38,7 @@ interface SingleTableWithStickyProps { onEditSave?: () => void; editInputRef?: React.RefObject; // 인라인 편집 타입별 옵션 (select/category/code, number, date 지원) - columnMeta?: Record; + columnMeta?: Record; categoryMappings?: Record>; // 검색 하이라이트 관련 props searchHighlights?: Set; diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 9da12c3c..4ba81bb5 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -873,7 +873,7 @@ export const TableListComponent: React.FC = ({ }); const [displayColumns, setDisplayColumns] = useState([]); const [columnMeta, setColumnMeta] = useState< - Record + Record >({}); // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) const [joinedColumnMeta, setJoinedColumnMeta] = useState< @@ -1460,16 +1460,20 @@ export const TableListComponent: React.FC = ({ const cached = tableColumnCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; const inputTypeMap: Record = {}; const categoryRefMap: Record = {}; + const detailSettingsMap: Record = {}; if (cached.inputTypes) { cached.inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; if (col.categoryRef) { categoryRefMap[col.columnName] = col.categoryRef; } + if (col.detailSettings && typeof col.detailSettings === "object") { + detailSettingsMap[col.columnName] = col.detailSettings; + } }); } @@ -1480,6 +1484,7 @@ export const TableListComponent: React.FC = ({ codeCategory: col.codeCategory, inputType: inputTypeMap[col.columnName], categoryRef: categoryRefMap[col.columnName], + detailSettings: detailSettingsMap[col.columnName], }; }); @@ -1493,11 +1498,15 @@ export const TableListComponent: React.FC = ({ const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); const inputTypeMap: Record = {}; const categoryRefMap: Record = {}; + const detailSettingsMap: Record = {}; inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; if (col.categoryRef) { categoryRefMap[col.columnName] = col.categoryRef; } + if (col.detailSettings && typeof col.detailSettings === "object") { + detailSettingsMap[col.columnName] = col.detailSettings; + } }); tableColumnCache.set(cacheKey, { @@ -1507,7 +1516,7 @@ export const TableListComponent: React.FC = ({ }); const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; @@ -1516,6 +1525,7 @@ export const TableListComponent: React.FC = ({ codeCategory: col.codeCategory, inputType: inputTypeMap[col.columnName], categoryRef: categoryRefMap[col.columnName], + detailSettings: detailSettingsMap[col.columnName], }; }); -- 2.43.0 From 08a095a8e52e0cdecbcd09e64c57096b6ca77514 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 30 Mar 2026 11:51:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20COMPANY=5F29=20=EB=B0=8F=20COMPANY?= =?UTF-8?q?=5F7=20=EA=B3=A0=EA=B0=9D=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=80=EC=84=9C=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부서 등록 시 부서코드 자동 생성 로직 수정 - 고객 관리 페이지에서 거래처 담당자 및 사내 담당자 컬럼 추가 - 고객 관리 페이지에서 사원 목록 로드 기능 추가 - 다중 선택 기능을 위한 포털 구현 및 외부 클릭 시 저장 기능 추가 - 테이블 컴포넌트에서 다중 선택 컬럼 자동 감지 기능 추가 이 커밋은 부서 및 고객 관리 기능을 개선하고, 사용자 경험을 향상시키기 위한 여러 변경 사항을 포함합니다. --- .../src/services/tableManagementService.ts | 6 +- .../master-data/department/page.tsx | 2 +- .../(main)/COMPANY_29/sales/customer/page.tsx | 92 ++++++++++--- .../COMPANY_7/master-data/department/page.tsx | 2 +- .../(main)/COMPANY_7/sales/customer/page.tsx | 92 ++++++++++--- .../v2-table-list/SingleTableWithSticky.tsx | 47 +++++++ .../v2-table-list/TableListComponent.tsx | 123 +++++++++++++++++- 7 files changed, 328 insertions(+), 36 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7f5c5f2e..9d63a366 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2833,17 +2833,19 @@ export class TableManagementService { .join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", "); + const hasIdColumn = columnTypeMap.has("id"); + const returningClause = hasIdColumn ? "RETURNING id" : "RETURNING *"; const insertQuery = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) - RETURNING id + ${returningClause} `; logger.info(`실행할 쿼리: ${insertQuery}`); logger.info(`쿼리 파라미터:`, values); const insertResult = await query(insertQuery, values) as any[]; - const insertedId = insertResult?.[0]?.id ?? null; + const insertedId = insertResult?.[0]?.id ?? insertResult?.[0]?.[columns[0]] ?? null; logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`); diff --git a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx index a898fa2d..89001e84 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx @@ -194,7 +194,7 @@ export default function DepartmentPage() { toast.success("수정되었습니다."); } else { await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: "", + dept_code: deptForm.dept_code && !deptForm.dept_code.startsWith("(") ? deptForm.dept_code : "", dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null, }); diff --git a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx index f909d7dd..c2d521f3 100644 --- a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx @@ -52,7 +52,8 @@ const LEFT_COLUMNS: DataGridColumn[] = [ { key: "customer_code", label: "거래처코드", width: "w-[110px]" }, { key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" }, { key: "division", label: "거래유형", width: "w-[80px]" }, - { key: "contact_person", label: "담당자", width: "w-[80px]" }, + { key: "contact_person", label: "거래처담당자", width: "w-[90px]" }, + { key: "internal_manager", label: "사내담당자", width: "w-[90px]" }, { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, { key: "business_number", label: "사업자번호", width: "w-[110px]" }, { key: "email", label: "이메일", width: "w-[130px]" }, @@ -85,6 +86,7 @@ export default function CustomerManagementPage() { const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(LEFT_COLUMNS); const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); @@ -141,6 +143,8 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사원 목록 (사내담당자 선택용) + const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]); // 카테고리 로드 useEffect(() => { @@ -173,9 +177,33 @@ export default function CustomerManagementPage() { setPriceCategoryOptions(priceOpts); }; load(); + // 사원 목록 로드 + apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) + .then((res) => { + const users = res.data?.data?.data || res.data?.data?.rows || []; + setEmployeeOptions(users.map((u: any) => ({ + user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name, + }))); + }).catch(() => {}); }, []); const applyTableSettings = useCallback((settings: TableSettings) => { + // 컬럼 표시/숨김/순서/너비 + const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c])); + const applied: DataGridColumn[] = []; + for (const cs of settings.columns) { + if (!cs.visible) continue; + const orig = colMap.get(cs.columnName); + if (orig) { + applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined }); + } + } + const settingKeys = new Set(settings.columns.map((c) => c.columnName)); + for (const col of LEFT_COLUMNS) { + if (!settingKeys.has(col.key)) applied.push(col); + } + setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS); + // 필터 설정 setFilterConfig(settings.filters); }, []); @@ -206,6 +234,9 @@ export default function CustomerManagementPage() { ...r, division: resolve("division", r.division), status: resolve("status", r.status), + internal_manager: r.internal_manager + ? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager) + : "", })); setCustomers(data); setCustomerCount(res.data?.data?.total || raw.length); @@ -215,7 +246,7 @@ export default function CustomerManagementPage() { } finally { setCustomerLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, employeeOptions]); useEffect(() => { fetchCustomers(); }, [fetchCustomers]); @@ -632,13 +663,28 @@ export default function CustomerManagementPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, }); } } else { - // 신규 등록 모드 + // 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크 + if (!mappingRows.length || !mappingRows[0]?.customer_item_code) { + const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) { + toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`); + continue; + } + } + let mappingId: string | null = null; const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { customer_id: selectedCustomer.customer_code, item_id: itemKey, @@ -655,22 +701,16 @@ export default function CustomerManagementPage() { }); } - const allPriceRows = itemPrices[itemKey] || []; - const priceRows = allPriceRows.filter((p) => + const priceRows = (itemPrices[itemKey] || []).filter((p) => (p.base_price && Number(p.base_price) > 0) || p.start_date ); - if (allPriceRows.length > 0 && priceRows.length === 0) { - toast.error("단가 정보를 입력해주세요. (기준가 또는 적용시작일 필수)"); - setSaving(false); - savingRef.current = false; - return; - } for (const price of priceRows) { await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { mapping_id: mappingId || "", customer_id: selectedCustomer.customer_code, item_id: itemKey, start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, @@ -859,7 +899,7 @@ export default function CustomerManagementPage() {
setCustomerForm((p) => ({ ...p, status: v })), "상태")}
- + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="담당자" className="h-9" /> + placeholder="거래처담당자" className="h-9" /> +
+
+ +
@@ -1129,7 +1183,14 @@ export default function CustomerManagementPage() {
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없습니다
- ) : mappingRows.map((mRow, mIdx) => ( + ) : (<> +
+ + 거래처 품번 + 거래처 품명 + +
+ {mappingRows.map((mRow, mIdx) => (
{mIdx + 1}
))} + )}
diff --git a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx index a898fa2d..89001e84 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx @@ -194,7 +194,7 @@ export default function DepartmentPage() { toast.success("수정되었습니다."); } else { await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: "", + dept_code: deptForm.dept_code && !deptForm.dept_code.startsWith("(") ? deptForm.dept_code : "", dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null, }); diff --git a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx index f909d7dd..c2d521f3 100644 --- a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx @@ -52,7 +52,8 @@ const LEFT_COLUMNS: DataGridColumn[] = [ { key: "customer_code", label: "거래처코드", width: "w-[110px]" }, { key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" }, { key: "division", label: "거래유형", width: "w-[80px]" }, - { key: "contact_person", label: "담당자", width: "w-[80px]" }, + { key: "contact_person", label: "거래처담당자", width: "w-[90px]" }, + { key: "internal_manager", label: "사내담당자", width: "w-[90px]" }, { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, { key: "business_number", label: "사업자번호", width: "w-[110px]" }, { key: "email", label: "이메일", width: "w-[130px]" }, @@ -85,6 +86,7 @@ export default function CustomerManagementPage() { const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(LEFT_COLUMNS); const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); @@ -141,6 +143,8 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사원 목록 (사내담당자 선택용) + const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]); // 카테고리 로드 useEffect(() => { @@ -173,9 +177,33 @@ export default function CustomerManagementPage() { setPriceCategoryOptions(priceOpts); }; load(); + // 사원 목록 로드 + apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) + .then((res) => { + const users = res.data?.data?.data || res.data?.data?.rows || []; + setEmployeeOptions(users.map((u: any) => ({ + user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name, + }))); + }).catch(() => {}); }, []); const applyTableSettings = useCallback((settings: TableSettings) => { + // 컬럼 표시/숨김/순서/너비 + const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c])); + const applied: DataGridColumn[] = []; + for (const cs of settings.columns) { + if (!cs.visible) continue; + const orig = colMap.get(cs.columnName); + if (orig) { + applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined }); + } + } + const settingKeys = new Set(settings.columns.map((c) => c.columnName)); + for (const col of LEFT_COLUMNS) { + if (!settingKeys.has(col.key)) applied.push(col); + } + setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS); + // 필터 설정 setFilterConfig(settings.filters); }, []); @@ -206,6 +234,9 @@ export default function CustomerManagementPage() { ...r, division: resolve("division", r.division), status: resolve("status", r.status), + internal_manager: r.internal_manager + ? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager) + : "", })); setCustomers(data); setCustomerCount(res.data?.data?.total || raw.length); @@ -215,7 +246,7 @@ export default function CustomerManagementPage() { } finally { setCustomerLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, employeeOptions]); useEffect(() => { fetchCustomers(); }, [fetchCustomers]); @@ -632,13 +663,28 @@ export default function CustomerManagementPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, }); } } else { - // 신규 등록 모드 + // 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크 + if (!mappingRows.length || !mappingRows[0]?.customer_item_code) { + const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) { + toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`); + continue; + } + } + let mappingId: string | null = null; const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { customer_id: selectedCustomer.customer_code, item_id: itemKey, @@ -655,22 +701,16 @@ export default function CustomerManagementPage() { }); } - const allPriceRows = itemPrices[itemKey] || []; - const priceRows = allPriceRows.filter((p) => + const priceRows = (itemPrices[itemKey] || []).filter((p) => (p.base_price && Number(p.base_price) > 0) || p.start_date ); - if (allPriceRows.length > 0 && priceRows.length === 0) { - toast.error("단가 정보를 입력해주세요. (기준가 또는 적용시작일 필수)"); - setSaving(false); - savingRef.current = false; - return; - } for (const price of priceRows) { await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { mapping_id: mappingId || "", customer_id: selectedCustomer.customer_code, item_id: itemKey, start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, @@ -859,7 +899,7 @@ export default function CustomerManagementPage() { setCustomerForm((p) => ({ ...p, status: v })), "상태")}
- + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="담당자" className="h-9" /> + placeholder="거래처담당자" className="h-9" /> +
+
+ +
@@ -1129,7 +1183,14 @@ export default function CustomerManagementPage() {
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없습니다
- ) : mappingRows.map((mRow, mIdx) => ( + ) : (<> +
+ + 거래처 품번 + 거래처 품명 + +
+ {mappingRows.map((mRow, mIdx) => (
{mIdx + 1}
))} + )}
diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index c34a0e57..77abd41a 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import ReactDOM from "react-dom"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react"; @@ -40,6 +41,7 @@ interface SingleTableWithStickyProps { // 인라인 편집 타입별 옵션 (select/category/code, number, date 지원) columnMeta?: Record; categoryMappings?: Record>; + multiSelectColumns?: Set; // 검색 하이라이트 관련 props searchHighlights?: Set; currentSearchIndex?: number; @@ -77,6 +79,7 @@ export const SingleTableWithSticky: React.FC = ({ editInputRef, columnMeta, categoryMappings, + multiSelectColumns, // 검색 하이라이트 관련 props searchHighlights, currentSearchIndex = 0, @@ -331,6 +334,8 @@ export const SingleTableWithSticky: React.FC = ({ = ({ value, label: info.label, })); + + // 다중선택 판별: 화면 모달 설정에서 multiple: true인 컬럼 + const isMultiSelect = multiSelectColumns?.has(column.columnName) || false; + + if (isMultiSelect) { + const selectedValues = (editingValue ?? "").split(",").filter(Boolean); + const toggleValue = (val: string) => { + const next = selectedValues.includes(val) + ? selectedValues.filter((v: string) => v !== val) + : [...selectedValues, val]; + onEditingValueChange?.(next.join(",")); + }; + // Portal로 body에 직접 렌더링 (overflow:hidden 우회) + const cellEl = document.querySelector( + `[data-row="${index}"][data-col="${colIndex}"]` + ) as HTMLElement | null; + const rect = cellEl?.getBoundingClientRect(); + const portalContent = rect ? ReactDOM.createPortal( +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape" && onEditKeyDown) onEditKeyDown(e as any); + if (e.key === "Enter") handleBlurSave(); + }} + onMouseDown={(e) => e.stopPropagation()} + > + {selectOptions.map((opt) => ( + + ))} +
, + document.body + ) : null; + return <>{portalContent}{selectedValues.length}개 선택됨; + } + return ( toggleValue(opt.value)} className="h-3 w-3" /> + {opt.label} + + ))} + , + document.body + ) : null; + return <>{portalContent}{selectedValues.length}개 선택됨; + } + return ( { + const v = parseInt(e.target.value, 10); + if (v > 0) { setSourcePageSize(v); setSourcePage(1); } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + +
+ + + {sourcePage} / {sourceTotalPages} + + +
+ + )} - + e.stopPropagation()} /> {/* 우측: 출고 정보 + 선택 품목 */} diff --git a/frontend/app/(main)/logistics/receiving/page.tsx b/frontend/app/(main)/logistics/receiving/page.tsx index a5de8c9d..6e493a5c 100644 --- a/frontend/app/(main)/logistics/receiving/page.tsx +++ b/frontend/app/(main)/logistics/receiving/page.tsx @@ -37,6 +37,9 @@ import { X, Save, ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -132,6 +135,11 @@ export default function ReceivingPage() { const [items, setItems] = useState([]); const [warehouses, setWarehouses] = useState([]); + // 소스 데이터 페이징 + const [sourcePage, setSourcePage] = useState(1); + const [sourcePageSize, setSourcePageSize] = useState(20); + const [sourceTotalCount, setSourceTotalCount] = useState(0); + // 날짜 초기화 useEffect(() => { const today = new Date(); @@ -214,18 +222,32 @@ export default function ReceivingPage() { // 소스 데이터 로드 함수 const loadSourceData = useCallback( - async (type: string, keyword?: string) => { + async (type: string, keyword?: string, pageOverride?: number) => { setSourceLoading(true); try { + const params = { + keyword: keyword || undefined, + page: pageOverride ?? sourcePage, + pageSize: sourcePageSize, + }; if (type === "구매입고") { - const res = await getPurchaseOrderSources(keyword || undefined); - if (res.success) setPurchaseOrders(res.data); + const res = await getPurchaseOrderSources(params); + if (res.success) { + setPurchaseOrders(res.data); + setSourceTotalCount(res.totalCount || 0); + } } else if (type === "반품입고") { - const res = await getShipmentSources(keyword || undefined); - if (res.success) setShipments(res.data); + const res = await getShipmentSources(params); + if (res.success) { + setShipments(res.data); + setSourceTotalCount(res.totalCount || 0); + } } else { - const res = await getItemSources(keyword || undefined); - if (res.success) setItems(res.data); + const res = await getItemSources(params); + if (res.success) { + setItems(res.data); + setSourceTotalCount(res.totalCount || 0); + } } } catch { // ignore @@ -233,7 +255,7 @@ export default function ReceivingPage() { setSourceLoading(false); } }, - [] + [sourcePage, sourcePageSize] ); const openRegisterModal = async () => { @@ -250,13 +272,15 @@ export default function ReceivingPage() { setPurchaseOrders([]); setShipments([]); setItems([]); + setSourcePage(1); + setSourceTotalCount(0); setIsModalOpen(true); // 입고번호 생성 + 발주 데이터 동시 로드 try { const [numRes] = await Promise.all([ generateReceivingNumber(), - loadSourceData(defaultType), + loadSourceData(defaultType, undefined, 1), ]); if (numRes.success) setModalInboundNo(numRes.data); } catch { @@ -266,7 +290,8 @@ export default function ReceivingPage() { // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { - await loadSourceData(modalInboundType, sourceKeyword || undefined); + setSourcePage(1); + await loadSourceData(modalInboundType, sourceKeyword || undefined, 1); }, [modalInboundType, sourceKeyword, loadSourceData]); // 입고유형 변경 시 소스 데이터 자동 리로드 @@ -278,7 +303,9 @@ export default function ReceivingPage() { setShipments([]); setItems([]); setSelectedItems([]); - loadSourceData(type); + setSourcePage(1); + setSourceTotalCount(0); + loadSourceData(type, undefined, 1); }, [loadSourceData] ); @@ -303,7 +330,7 @@ export default function ReceivingPage() { inbound_qty: po.remain_qty, unit_price: po.unit_price, total_amount: po.remain_qty * po.unit_price, - source_table: "purchase_order_mng", + source_table: po.source_table || "purchase_order_mng", source_id: po.id, }, ]); @@ -694,6 +721,7 @@ export default function ReceivingPage() { defaultMaxWidth="sm:max-w-[1600px]" defaultWidth="w-[95vw]" className="h-[90vh] p-0" + contentClassName="overflow-hidden flex flex-col" footer={
@@ -817,10 +845,56 @@ export default function ReceivingPage() { /> )}
+ + {/* 페이징 */} + {sourceTotalCount > 0 && ( +
+
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { + setSourcePageSize(v); + setSourcePage(1); + loadSourceData(modalInboundType, sourceKeyword || undefined, 1); + } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + + 총 {sourceTotalCount}건 + +
+
+ + + {sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))} + + +
+
+ )}
- + e.stopPropagation()} /> {/* 우측: 입고 정보 + 선택 품목 */} @@ -1030,7 +1104,7 @@ function SourcePurchaseOrderTable({ return (
- + 발주번호 @@ -1109,7 +1183,7 @@ function SourceShipmentTable({ return (
- + 출하번호 @@ -1186,7 +1260,7 @@ function SourceItemTable({ return (
- + 품목 diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/master-data/department/page.tsx index 4e943810..300eedd6 100644 --- a/frontend/app/(main)/master-data/department/page.tsx +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -25,6 +25,8 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import * as departmentAPI from "@/lib/api/department"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -78,6 +80,10 @@ export default function DepartmentPage() { const [deptForm, setDeptForm] = useState>({}); const [saving, setSaving] = useState(false); + // 채번 시스템 + const [numberingRuleId, setNumberingRuleId] = useState(null); + const [previewCode, setPreviewCode] = useState(null); + // 사원 모달 const [userModalOpen, setUserModalOpen] = useState(false); const [userEditMode, setUserEditMode] = useState(false); @@ -112,7 +118,6 @@ export default function DepartmentPage() { setDepts(data); setDeptCount(res.data?.data?.total || data.length); } catch (err) { - console.error("부서 조회 실패:", err); toast.error("부서 목록을 불러오는데 실패했습니다."); } finally { setDeptLoading(false); @@ -144,10 +149,28 @@ export default function DepartmentPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); // 부서 등록 - const openDeptRegister = () => { + const openDeptRegister = async () => { setDeptForm({}); setDeptEditMode(false); + setPreviewCode(null); + setNumberingRuleId(null); setDeptModalOpen(true); + + // 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출 + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setPreviewCode(previewRes.data.generatedCode); + } + } + } catch { + // 채번 규칙 없으면 무시 + } }; const openDeptEdit = () => { @@ -159,20 +182,40 @@ export default function DepartmentPage() { const handleDeptSave = async () => { if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null; setSaving(true); try { if (deptEditMode && deptForm.dept_code) { - await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, { - originalData: { dept_code: deptForm.dept_code }, - updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null }, + const response = await departmentAPI.updateDepartment(deptForm.dept_code, { + dept_name: deptForm.dept_name, + parent_dept_code: parentCode, }); + if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; } toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: deptForm.dept_code || "", + const companyCode = user?.companyCode || ""; + + // 채번 규칙이 있으면 allocate로 실제 코드 할당 + let allocatedCode: string | undefined; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + allocatedCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + return; + } + } + + const response = await departmentAPI.createDepartment(companyCode, { dept_name: deptForm.dept_name, - parent_dept_code: deptForm.parent_dept_code || null, + parent_dept_code: parentCode, + dept_code: allocatedCode, }); + if (!response.success) { + toast.error((response as any).error || "등록에 실패했습니다."); + return; + } toast.success("등록되었습니다."); } setDeptModalOpen(false); @@ -193,10 +236,9 @@ export default function DepartmentPage() { }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, { - data: [{ dept_code: selectedDeptCode }], - }); - toast.success("삭제되었습니다."); + const response = await departmentAPI.deleteDepartment(selectedDeptCode); + if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; } + toast.success(response.message || "삭제되었습니다."); setSelectedDeptId(null); fetchDepts(); } catch { toast.error("삭제에 실패했습니다."); } @@ -373,8 +415,9 @@ export default function DepartmentPage() {
- setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} - placeholder="부서코드" className="h-9" disabled={deptEditMode} /> +
diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/sales/order/page.tsx index 283f4362..855470ee 100644 --- a/frontend/app/(main)/sales/order/page.tsx +++ b/frontend/app/(main)/sales/order/page.tsx @@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label"; import { Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download, ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -28,6 +29,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { SmartSelect } from "@/components/common/SmartSelect"; const DETAIL_TABLE = "sales_order_detail"; @@ -46,7 +48,7 @@ const MASTER_TABLE = "sales_order_mng"; const GRID_COLUMNS: DataGridColumn[] = [ { key: "order_no", label: "수주번호", width: "w-[120px]" }, { key: "part_code", label: "품번", width: "w-[120px]", editable: true }, - { key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true }, + { key: "part_name", label: "품명", width: "w-[150px]", editable: true }, { key: "spec", label: "규격", width: "w-[120px]", editable: true }, { key: "unit", label: "단위", width: "w-[70px]", editable: true }, { key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, @@ -54,6 +56,7 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" }, { key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, { key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[70px]" }, { key: "due_date", label: "납기일", width: "w-[110px]" }, { key: "memo", label: "메모", width: "w-[100px]", editable: true }, ]; @@ -85,7 +88,12 @@ export default function SalesOrderPage() { const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemPageSize, setItemPageSize] = useState(20); + const [itemTotalPages, setItemTotalPages] = useState(0); + const [itemTotal, setItemTotal] = useState(0); + const [itemPageInput, setItemPageInput] = useState("1"); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -221,6 +229,23 @@ export default function SalesOrderPage() { }); const rows = res.data?.data?.data || res.data?.data?.rows || []; + // order_no → sales_order_mng 조인 (memo 등 마스터 필드 보강) + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; + let masterMap: Record = {}; + if (orderNos.length > 0) { + try { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: orderNos.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, + autoFilter: true, + }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + for (const m of masters) { + masterMap[m.order_no] = m; + } + } catch { /* skip */ } + } + // part_code → item_info 조인 (품명/규격이 비어있는 경우 보강) const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; let itemMap: Record = {}; @@ -247,19 +272,20 @@ export default function SalesOrderPage() { }; const data = rows.map((row: any) => { const item = itemMap[row.part_code]; + const master = masterMap[row.order_no]; const rawUnit = row.unit || item?.unit || ""; return { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", unit: resolveLabel("item_unit", rawUnit) || rawUnit, + memo: row.memo || master?.memo || "", }; }); setOrders(data); setTotalCount(res.data?.data?.total || data.length); } catch (err) { - console.error("수주 조회 실패:", err); toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); @@ -330,7 +356,6 @@ export default function SalesOrderPage() { setIsEditMode(true); setIsModalOpen(true); } catch (err) { - console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); } }; @@ -377,7 +402,6 @@ export default function SalesOrderPage() { setCheckedIds([]); fetchOrders(); } catch (err) { - console.error("삭제 실패:", err); toast.error("삭제에 실패했습니다."); } }; @@ -433,7 +457,6 @@ export default function SalesOrderPage() { setIsModalOpen(false); fetchOrders(); } catch (err: any) { - console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); @@ -441,7 +464,9 @@ export default function SalesOrderPage() { }; // 품목 검색 (리피터에서 추가) - const searchItems = async () => { + const searchItems = async (page?: number, size?: number) => { + const p = page ?? itemPage; + const s = size ?? itemPageSize; setItemSearchLoading(true); try { const filters: any[] = []; @@ -449,18 +474,45 @@ export default function SalesOrderPage() { 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: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); + const resData = res.data?.data; + setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || 0); + setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s)); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; + const handleItemPageChange = (newPage: number) => { + if (newPage < 1 || newPage > itemTotalPages) return; + setItemPage(newPage); + setItemPageInput(String(newPage)); + searchItems(newPage); + }; + + const commitItemPageInput = () => { + const parsed = parseInt(itemPageInput, 10); + if (isNaN(parsed) || itemPageInput.trim() === "") { + setItemPageInput(String(itemPage)); + return; + } + const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); + if (clamped !== itemPage) handleItemPageChange(clamped); + setItemPageInput(String(clamped)); + }; + + const triggerNewSearch = () => { + setItemPage(1); + setItemPageInput("1"); + searchItems(1); + }; + const addSelectedItemsToDetail = async () => { - const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id)); + const selected = Array.from(itemSelectedMap.values()); if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } // 단가방식에 따라 단가 조회 @@ -492,7 +544,7 @@ export default function SalesOrderPage() { if (price) customerPriceMap[m.item_id] = String(price); } } catch (err) { - console.error("거래처별 단가 조회 실패:", err); + // 단가 조회 실패 시 무시 } } @@ -516,15 +568,17 @@ export default function SalesOrderPage() { material: getCategoryLabel("item_material", item.material) || item.material || "", unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", qty: "", + standard_price: item.standard_price || "", unit_price: unitPrice, amount: "", + currency_code: item.currency_code || "", due_date: "", }; }); setDetailRows((prev) => [...prev, ...newRows]); toast.success(`${selected.length}개 품목이 추가되었습니다.`); - setItemCheckedIds(new Set()); + setItemSelectedMap(new Map()); setItemSelectOpen(false); }; @@ -655,30 +709,15 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, sell_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, input_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, price_mode: v }))} placeholder="선택" />
@@ -687,39 +726,23 @@ export default function SalesOrderPage() {
- + { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }} placeholder="거래처 선택" />
- + setMasterForm((p) => ({ ...p, manager_id: v }))} placeholder="담당자 선택" />
{deliveryOptions.length > 0 ? ( - + }} placeholder="납품처 선택" /> ) : ( setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} /> @@ -738,21 +761,11 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, incoterms: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, payment_term: v }))} placeholder="선택" />
@@ -781,28 +794,30 @@ export default function SalesOrderPage() {
수주 품목 -
-
+
품번 - 품명 + 품명 규격 단위 - 수량 - 단가 - 금액 - 납기일 + 기준단가 + 수량 + 단가 + 금액 + 통화 + 납기일 {detailRows.length === 0 ? ( - 품목을 추가해주세요 + 품목을 추가해주세요 ) : detailRows.map((row, idx) => ( @@ -814,6 +829,7 @@ export default function SalesOrderPage() { {row.part_name} {row.spec} {row.unit} + {row.standard_price ? Number(row.standard_price).toLocaleString() : ""} updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" /> @@ -823,6 +839,10 @@ export default function SalesOrderPage() { className="h-8 text-sm text-right" /> {row.amount ? Number(row.amount).toLocaleString() : ""} + + updateDetailRow(idx, "currency_code", e.target.value)} + className="h-8 text-sm" /> + updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> @@ -851,22 +871,26 @@ export default function SalesOrderPage() {
setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchItems()} + onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} className="h-9 flex-1" /> -
-
+
0 && itemCheckedIds.size === itemSearchResults.length} + checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))} onChange={(e) => { - if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); - else setItemCheckedIds(new Set()); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i)); + else itemSearchResults.forEach((i) => next.delete(i.id)); + return next; + }); }} /> 품목코드 @@ -880,14 +904,14 @@ export default function SalesOrderPage() { {itemSearchResults.length === 0 ? ( 검색 결과가 없습니다 ) : itemSearchResults.map((item) => ( - setItemCheckedIds((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item); return next; })}> - + {item.item_number} {item.item_name} @@ -899,13 +923,53 @@ export default function SalesOrderPage() {
+
+
+ 표시: + { + const v = Math.min(200, Math.max(1, Number(e.target.value) || 20)); + setItemPageSize(v); + setItemPage(1); + setItemPageInput("1"); + searchItems(1, v); + }} + className="h-7 w-14 rounded-md border px-1 text-center text-xs" /> +
+
+ + + setItemPageInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }} + onBlur={commitItemPageInput} + onFocus={(e) => e.target.select()} + className="h-7 w-10 rounded-md border px-1 text-center text-xs" /> + / {itemTotalPages || 1} + + +
+ 총 {itemTotal}건 +
- {itemCheckedIds.size}개 선택됨 + {itemSelectedMap.size}개 선택됨
- - +
diff --git a/frontend/app/(main)/sales/sales-item/page.tsx b/frontend/app/(main)/sales/sales-item/page.tsx index a5097b42..5da2b3b5 100644 --- a/frontend/app/(main)/sales/sales-item/page.tsx +++ b/frontend/app/(main)/sales/sales-item/page.tsx @@ -81,7 +81,7 @@ export default function SalesItemPage() { const [customerLoading, setCustomerLoading] = useState(false); // 카테고리 - const [categoryOptions, setCategoryOptions] = useState>({}); + const [categoryOptions, setCategoryOptions] = useState>({}); // 거래처 추가 모달 const [custSelectOpen, setCustSelectOpen] = useState(false); @@ -125,11 +125,11 @@ export default function SalesItemPage() { // 카테고리 로드 useEffect(() => { const load = async () => { - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; + 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 }); + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); if (v.children?.length) result.push(...flatten(v.children)); } return result; @@ -164,7 +164,11 @@ export default function SalesItemPage() { const fetchItems = useCallback(async () => { setItemLoading(true); try { - const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + + // 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" }); + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/sales/shipping-order/page.tsx b/frontend/app/(main)/sales/shipping-order/page.tsx index 9ffb45bd..5ff26159 100644 --- a/frontend/app/(main)/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/sales/shipping-order/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; +import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { @@ -117,7 +117,7 @@ export default function ShippingOrderPage() { const [sourceLoading, setSourceLoading] = useState(false); const [selectedItems, setSelectedItems] = useState([]); const [sourcePage, setSourcePage] = useState(1); - const [sourcePageSize] = useState(20); + const [sourcePageSize, setSourcePageSize] = useState(20); const [sourceTotalCount, setSourceTotalCount] = useState(0); // 텍스트 입력 debounce (500ms) @@ -592,6 +592,8 @@ export default function ShippingOrderPage() { description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."} defaultMaxWidth="max-w-[90vw]" defaultWidth="w-[1400px]" + className="h-[90vh]" + contentClassName="overflow-hidden flex flex-col" footer={ <> @@ -694,10 +696,28 @@ export default function ShippingOrderPage() { {/* 페이징 */} {sourceTotalCount > 0 && (
- - 총 {sourceTotalCount}건 중 {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}건 - +
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { setSourcePageSize(v); setSourcePage(1); fetchSourceData(1); } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + + 총 {sourceTotalCount}건 + +
+ +
)}
- + e.stopPropagation()} /> {/* 오른쪽: 폼 */} diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index e20fb806..0855fef9 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -54,7 +54,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company { key: "company_code", label: "회사코드", - width: "150px", + width: "12%", render: (value) => {value}, }, { @@ -65,11 +65,12 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company { key: "writer", label: "등록자", - width: "200px", + width: "15%", }, { key: "diskUsage", label: "디스크 사용량", + width: "15%", hideOnMobile: true, render: (_value, row) => formatDiskUsage(row), }, @@ -99,7 +100,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company cardSubtitle={(c) => {c.company_code}} cardFields={cardFields} actionsLabel="작업" - actionsWidth="180px" + actionsWidth="12%" + tableContainerClassName="!block" + cardContainerClassName="!hidden" renderActions={(company) => ( <> diff --git a/frontend/components/admin/DiskUsageSummary.tsx b/frontend/components/admin/DiskUsageSummary.tsx index 55bc1918..44676d65 100644 --- a/frontend/components/admin/DiskUsageSummary.tsx +++ b/frontend/components/admin/DiskUsageSummary.tsx @@ -15,7 +15,7 @@ interface DiskUsageSummaryProps { export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) { if (!diskUsageInfo) { return ( -
+

디스크 사용량

@@ -46,7 +46,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs const lastCheckedDate = new Date(lastChecked); return ( -
+

디스크 사용량 현황

@@ -64,7 +64,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
-
+
{/* 총 회사 수 */}
diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 1806aa94..f122a318 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -108,11 +108,11 @@ function SortableHeaderCell({ style={style} className={cn(col.width, col.minWidth, "select-none relative")} > -
+
{ e.stopPropagation(); if (col.sortable !== false) onSort(col.key); @@ -366,7 +366,6 @@ export function DataGrid({ row[colKey] = editValue; toast.success("저장됨"); } catch (err) { - console.error("셀 저장 실패:", err); toast.error("저장에 실패했습니다."); setEditingCell(null); return; diff --git a/frontend/components/common/FullscreenDialog.tsx b/frontend/components/common/FullscreenDialog.tsx index 23189fbc..c723f2c5 100644 --- a/frontend/components/common/FullscreenDialog.tsx +++ b/frontend/components/common/FullscreenDialog.tsx @@ -31,6 +31,8 @@ interface FullscreenDialogProps { /** 기본 모달 너비 (기본: "w-[95vw]") */ defaultWidth?: string; className?: string; + /** children wrapper에 추가할 className (기본: "overflow-auto") — "overflow-hidden"으로 변경하면 내부 flex 레이아웃이 고정 높이 내에서 동작 */ + contentClassName?: string; } export function FullscreenDialog({ @@ -38,6 +40,7 @@ export function FullscreenDialog({ defaultMaxWidth = "max-w-5xl", defaultWidth = "w-[95vw]", className, + contentClassName, }: FullscreenDialogProps) { const [isFullscreen, setIsFullscreen] = useState(false); @@ -73,7 +76,7 @@ export function FullscreenDialog({
-
+
{children}
diff --git a/frontend/components/common/SmartSelect.tsx b/frontend/components/common/SmartSelect.tsx new file mode 100644 index 00000000..9c160f47 --- /dev/null +++ b/frontend/components/common/SmartSelect.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * SmartSelect + * + * 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트. + * - 옵션 5개 미만: 기본 Select (드롭다운) + * - 옵션 5개 이상: Combobox (검색 + 드롭다운) + */ + +import React, { useState, useMemo } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const SEARCH_THRESHOLD = 5; + +export interface SmartSelectOption { + code: string; + label: string; +} + +interface SmartSelectProps { + options: SmartSelectOption[]; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function SmartSelect({ + options, + value, + onValueChange, + placeholder = "선택", + disabled = false, + className, +}: SmartSelectProps) { + const [open, setOpen] = useState(false); + + const selectedLabel = useMemo( + () => options.find((o) => o.code === value)?.label, + [options, value], + ); + + if (options.length < SEARCH_THRESHOLD) { + return ( + + ); + } + + return ( + + + + + + { + if (!search) return 1; + return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }} + > + + + 검색 결과가 없습니다. + + {options.map((o) => ( + { + onValueChange(o.code); + setOpen(false); + }} + > + + {o.label} + + ))} + + + + + + ); +} diff --git a/frontend/lib/api/receiving.ts b/frontend/lib/api/receiving.ts index 8a27849a..f890609d 100644 --- a/frontend/lib/api/receiving.ts +++ b/frontend/lib/api/receiving.ts @@ -50,6 +50,7 @@ export interface PurchaseOrderSource { unit_price: number; status: string; due_date: string | null; + source_table: string; } export interface ShipmentSource { @@ -156,24 +157,30 @@ export async function getReceivingWarehouses() { return res.data as { success: boolean; data: WarehouseOption[] }; } -// 소스 데이터 조회 -export async function getPurchaseOrderSources(keyword?: string) { +// 소스 데이터 조회 (페이징) +interface SourceParams { + keyword?: string; + page?: number; + pageSize?: number; +} + +export async function getPurchaseOrderSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/purchase-orders", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: PurchaseOrderSource[] }; + return res.data as { success: boolean; data: PurchaseOrderSource[]; totalCount: number }; } -export async function getShipmentSources(keyword?: string) { +export async function getShipmentSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/shipments", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: ShipmentSource[] }; + return res.data as { success: boolean; data: ShipmentSource[]; totalCount: number }; } -export async function getItemSources(keyword?: string) { +export async function getItemSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/items", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: ItemSource[] }; + return res.data as { success: boolean; data: ItemSource[]; totalCount: number }; } diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 8c42b957..acd9a183 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -11,6 +11,7 @@ import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types"; import { useAuth } from "@/hooks/useAuth"; +import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; import { Upload, File, @@ -64,7 +65,6 @@ const getFileIcon = (extension: string) => { export interface FileUploadComponentProps { component: any; componentConfig: FileUploadConfig; - componentStyle: React.CSSProperties; className: string; isInteractive: boolean; isDesignMode: boolean; @@ -82,7 +82,6 @@ export interface FileUploadComponentProps { const FileUploadComponent: React.FC = ({ component, componentConfig, - componentStyle, className, isInteractive, isDesignMode = false, // 기본값 설정 @@ -187,7 +186,7 @@ const FileUploadComponent: React.FC = ({ } } } catch (e) { - console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); + // silently ignore } }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 @@ -259,7 +258,7 @@ const FileUploadComponent: React.FC = ({ filesLoadedFromObjidRef.current = true; } } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + // silently ignore } })(); }, [imageObjidFromFormData, columnName, component.id]); @@ -287,7 +286,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(newFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } // 전역 상태 업데이트 (🆕 고유 키 사용) @@ -346,11 +345,6 @@ const FileUploadComponent: React.FC = ({ // 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도 if (!screenId) { - console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", { - componentId: component.id, - pathname: window.location.pathname, - formData: formData, - }); // screenId를 0으로 설정하여 컴포넌트 ID로만 조회 screenId = 0; } @@ -400,7 +394,7 @@ const FileUploadComponent: React.FC = ({ finalFiles = [...formattedFiles, ...additionalFiles]; } } catch (e) { - console.warn("파일 병합 중 오류:", e); + // silently ignore } setUploadedFiles(finalFiles); @@ -424,13 +418,13 @@ const FileUploadComponent: React.FC = ({ try { localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } } return true; // 새로운 로직 사용됨 } } catch (error) { - console.error("파일 조회 오류:", error); + // silently ignore } return false; // 기존 로직 사용 }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]); @@ -503,7 +497,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(files)); } catch (e) { - console.warn("localStorage 백업 실패:", e); + // silently ignore } } }; @@ -690,11 +684,9 @@ const FileUploadComponent: React.FC = ({ })); allNewFiles.push(...chunkFiles); } else { - console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response); failedChunks++; } } catch (chunkError) { - console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError); failedChunks++; } } @@ -714,7 +706,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { - console.warn("localStorage 백업 실패:", e); + // silently ignore } // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) @@ -752,8 +744,6 @@ const FileUploadComponent: React.FC = ({ uploadedFiles: updatedFiles, lastFileUpdate: timestamp, }); - } else { - console.warn("⚠️ onUpdate 콜백이 없습니다!"); } // 이미지/파일 컬럼에 objid 저장 (formData 업데이트) @@ -797,7 +787,6 @@ const FileUploadComponent: React.FC = ({ toast.success(`${allNewFiles.length}개 파일 업로드 완료`); } } catch (error) { - console.error("파일 업로드 오류:", error); setUploadStatus("error"); toast.dismiss("file-upload"); toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); @@ -828,7 +817,6 @@ const FileUploadComponent: React.FC = ({ }); toast.success(`${file.realFileName} 다운로드 완료`); } catch (error) { - console.error("파일 다운로드 오류:", error); toast.error("파일 다운로드에 실패했습니다."); } }, []); @@ -851,7 +839,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) @@ -903,7 +891,6 @@ const FileUploadComponent: React.FC = ({ toast.success(`${fileName} 삭제 완료`); } catch (error) { - console.error("파일 삭제 오류:", error); toast.error("파일 삭제에 실패했습니다."); } }, @@ -925,7 +912,6 @@ const FileUploadComponent: React.FC = ({ // objid가 없거나 유효하지 않으면 로드 중단 if (!file.objid || file.objid === "0" || file.objid === "") { - console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file); setRepresentativeImageUrl(null); return; } @@ -950,11 +936,6 @@ const FileUploadComponent: React.FC = ({ setRepresentativeImageUrl(url); } catch (error: any) { - console.error("❌ 대표 이미지 로드 실패:", { - file: file.realFileName, - objid: file.objid, - error: error?.response?.status || error?.message, - }); setRepresentativeImageUrl(null); } }, @@ -980,7 +961,7 @@ const FileUploadComponent: React.FC = ({ // 대표 이미지 로드 loadRepresentativeImage(file); } catch (e) { - console.error("❌ 대표 파일 설정 실패:", e); + // silently ignore } }, [uploadedFiles, component.id, loadRepresentativeImage] @@ -1050,25 +1031,53 @@ const FileUploadComponent: React.FC = ({ [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); - // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값) + // 🔧 커스텀 스타일 감지 및 추출 (StyleEditor에서 설정한 값, component.style에서 직접 읽기) const customStyle = component.style || {}; const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border); const hasCustomBackground = !!customStyle.backgroundColor; const hasCustomRadius = !!customStyle.borderRadius; + // 커스텀 border inline style 구축 + const customBorderStyle: React.CSSProperties = hasCustomBorder + ? { + ...(customStyle.border + ? { border: customStyle.border } + : { + borderWidth: customStyle.borderWidth || "1px", + borderStyle: customStyle.borderStyle || "solid", + borderColor: customStyle.borderColor, + }), + } + : {}; + + // 커스텀 배경/radius inline style + const customBackgroundStyle: React.CSSProperties = hasCustomBackground + ? { backgroundColor: customStyle.backgroundColor } + : {}; + const customRadiusStyle: React.CSSProperties = hasCustomRadius + ? { borderRadius: customStyle.borderRadius } + : {}; + + // 커스텀 텍스트 style (내부 텍스트 요소에 전파) + const customTextStyle: React.CSSProperties = { + ...(customStyle.color ? { color: customStyle.color } : {}), + ...(customStyle.fontSize ? { fontSize: customStyle.fontSize } : {}), + ...(customStyle.fontWeight ? { fontWeight: customStyle.fontWeight } : {}), + }; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
= ({ top: "-20px", left: "0px", fontSize: customStyle.labelFontSize || "12px", - color: customStyle.labelColor || "rgb(107, 114, 128)", + color: getAdaptiveLabelColor(customStyle.labelColor || "rgb(107, 114, 128)"), fontWeight: customStyle.labelFontWeight || "400", background: "transparent", border: "none", @@ -1106,6 +1115,11 @@ const FileUploadComponent: React.FC = ({ // 커스텀 배경이 없을 때만 기본 배경 표시 !hasCustomBackground && "bg-card", )} + style={{ + ...(hasCustomBorder ? { ...customBorderStyle, ...customRadiusStyle } : {}), + ...(hasCustomBackground ? customBackgroundStyle : {}), + ...(hasCustomRadius && !hasCustomBorder ? customRadiusStyle : {}), + }} > {/* 대표 이미지 전체 화면 표시 */} {uploadedFiles.length > 0 ? (() => { @@ -1155,7 +1169,10 @@ const FileUploadComponent: React.FC = ({ ); })() : ( -
+

업로드된 파일이 없습니다