From ac5292f9b06e1cc6ef498cbc214793e76c577658 Mon Sep 17 00:00:00 2001 From: kjs Date: Sun, 29 Mar 2026 20:04:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20COMPANY=5F29=20=EB=B6=80=EC=84=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=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], }; });