diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 60c4615f..c68b1172 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1050,7 +1050,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2384,7 +2383,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3502,7 +3500,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3748,7 +3745,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3977,7 +3973,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4527,7 +4522,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5903,7 +5897,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6182,7 +6175,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7769,7 +7761,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8739,6 +8730,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9708,7 +9700,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10632,6 +10623,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -11525,7 +11517,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11631,7 +11622,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/src/controllers/smartFactoryLogController.ts b/backend-node/src/controllers/smartFactoryLogController.ts index 823a5511..9809b97f 100644 --- a/backend-node/src/controllers/smartFactoryLogController.ts +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -7,6 +7,7 @@ import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; import { encryptionService } from "../services/encryptionService"; import { + sendSmartFactoryLog, runScheduleNow, getTodayPlanStatus, planDailySends, @@ -502,3 +503,92 @@ export const deleteApiKey = async ( res.status(500).json({ success: false, message: "API 키 삭제 실패" }); } }; + +// ─── 즉시 전송 ─── + +/** + * GET /api/admin/smart-factory-log/users/:companyCode + * 회사별 사용자 목록 조회 (즉시 전송 대상 선택용) + */ +export const getCompanyUsers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + + const users = await query( + `SELECT user_id, user_name, dept_name + FROM user_info + WHERE company_code = $1 AND (status = 'active' OR status IS NULL) + ORDER BY user_name`, + [companyCode] + ); + + res.json({ success: true, data: users }); + } catch (error) { + logger.error("사용자 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "사용자 목록 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/send-now + * 선택한 사용자 즉시 전송 + * body: { companyCode, userIds: string[], timeStart?, timeEnd? } + */ +export const sendNow = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userIds } = req.body; + + logger.info(`=== 즉시 전송 API 호출 === companyCode=${companyCode}, userIds=${JSON.stringify(userIds)}`); + + if (!companyCode || !userIds || userIds.length === 0) { + res.status(400).json({ success: false, message: "회사코드와 사용자를 선택해주세요." }); + return; + } + + // 사용자 정보 조회 + const users = await query<{ user_id: string; user_name: string }>( + `SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND user_id = ANY($2)`, + [companyCode, userIds] + ); + + logger.info(`즉시 전송 대상: ${users.length}명 (조회된 사용자: ${users.map(u => u.user_id).join(", ")})`); + + // 현재 시간으로 즉시 전송 + let success = 0; + let fail = 0; + const remoteAddr = req.ip || "127.0.0.1"; + + for (const user of users) { + try { + logger.info(`즉시 전송 시작: ${user.user_id}`); + await sendSmartFactoryLog({ + userId: user.user_id, + userName: user.user_name, + remoteAddr, + useType: "접속", + companyCode, + }); + success++; + logger.info(`즉시 전송 성공: ${user.user_id}`); + } catch (e) { + fail++; + logger.error(`즉시 전송 실패: ${user.user_id}`, e); + } + } + + res.json({ + success: true, + data: { total: users.length, success, fail }, + message: `${success}명 전송 완료${fail > 0 ? `, ${fail}명 실패` : ""}`, + }); + } catch (error) { + logger.error("즉시 전송 실패:", error); + res.status(500).json({ success: false, message: "즉시 전송 실패" }); + } +}; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 5ca62982..6527d39e 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -46,6 +46,8 @@ import { getApiKeys, saveApiKey, deleteApiKey, + getCompanyUsers, + sendNow, } from "../controllers/smartFactoryLogController"; import { authenticateToken } from "../middleware/authMiddleware"; import { requireSuperAdmin } from "../middleware/permissionMiddleware"; @@ -115,6 +117,10 @@ router.get("/smart-factory-log/holidays", requireSuperAdmin, getHolidays); router.post("/smart-factory-log/holidays", requireSuperAdmin, addHoliday); router.delete("/smart-factory-log/holidays/:id", requireSuperAdmin, deleteHoliday); +// 스마트공장 즉시 전송 (최고관리자 전용) +router.get("/smart-factory-log/users/:companyCode", requireSuperAdmin, getCompanyUsers); +router.post("/smart-factory-log/send-now", requireSuperAdmin, sendNow); + // 스마트공장 API 키 관리 (최고관리자 전용) router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys); router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey); diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts index 70b4d31d..8a88bb99 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -74,17 +74,19 @@ export async function sendSmartFactoryLog(params: { dataUsgqty: "", }; - const encodedLogData = encodeURIComponent(JSON.stringify(logData)); + const logDataJson = JSON.stringify(logData); const response = await axios.get(SMART_FACTORY_LOG_URL, { - params: { logData: encodedLogData }, + params: { logData: logDataJson }, timeout: 5000, }); - logger.info("스마트공장 로그 전송 완료", { - userId: params.userId, - status: response.status, - }); + const responseBody = typeof response.data === "string" ? response.data : JSON.stringify(response.data); + + logger.info(`스마트공장 로그 전송 완료: userId=${params.userId}, status=${response.status}, body=${responseBody}`); + + // 응답 body에 에러가 있을 수 있음 (HTTP 200이지만 실제 실패) + const isRealSuccess = !responseBody.includes("FAIL") && !responseBody.includes("error") && !responseBody.includes("ERR"); await saveLog({ companyCode: params.companyCode || "", @@ -92,9 +94,9 @@ export async function sendSmartFactoryLog(params: { userName: params.userName, useType, connectIp: params.remoteAddr, - sendStatus: "SUCCESS", + sendStatus: isRealSuccess ? "SUCCESS" : "FAIL", responseStatus: response.status, - errorMessage: null, + errorMessage: isRealSuccess ? null : responseBody, logDt: logTimeToUse, }); } catch (error) { diff --git a/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx index 8879ba8a..39f06769 100644 --- a/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx +++ b/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx @@ -18,7 +18,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -32,16 +31,8 @@ import { import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { - Search, - RotateCcw, Plus, Save, - ClipboardList, Inbox, Pencil, FileText, @@ -50,6 +41,7 @@ import { Paperclip, Upload, Loader2, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -62,6 +54,10 @@ import { createEcn, updateEcn, } from "@/lib/api/design"; +import { useTableSettings } from "@/hooks/useTableSettings"; +import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // --- Types --- type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; @@ -119,65 +115,83 @@ interface EcnItem { const getChangeTypeStyle = (type: ChangeType) => { switch (type) { case "설계오류": - return "bg-rose-100 text-rose-800 border-rose-200"; + return "bg-destructive/10 text-destructive border-destructive/20"; case "원가절감": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; case "고객요청": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "공정개선": - return "bg-amber-100 text-amber-800 border-amber-200"; + return "bg-warning/10 text-warning border-warning/20"; case "법규대응": - return "bg-purple-100 text-purple-800 border-purple-200"; + return "bg-primary/10 text-primary border-primary/20"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; } }; const getEcrStatusStyle = (status: EcrStatus) => { switch (status) { case "요청접수": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "영향도분석": - return "bg-amber-100 text-amber-800 border-amber-200"; + return "bg-warning/10 text-warning border-warning/20"; case "ECN발행": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; case "기각": - return "bg-slate-100 text-slate-800 border-slate-200"; + return "bg-muted text-muted-foreground border-border"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; } }; const getEcnStatusStyle = (status: EcnStatus) => { switch (status) { case "ECN발행": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "도면변경": - return "bg-purple-100 text-purple-800 border-purple-200"; + return "bg-primary/10 text-primary border-primary/20"; case "통보완료": - return "bg-teal-100 text-teal-800 border-teal-200"; + return "bg-warning/10 text-warning border-warning/20"; case "적용완료": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; } }; const getImpactBadgeStyle = (impact: string) => { switch (impact) { case "BOM": - return "bg-blue-100 text-blue-800 border-blue-200"; + return "bg-info/10 text-info border-info/20"; case "공정": - return "bg-amber-100 text-amber-800 border-amber-200"; + return "bg-warning/10 text-warning border-warning/20"; case "금형": - return "bg-rose-100 text-rose-800 border-rose-200"; + return "bg-destructive/10 text-destructive border-destructive/20"; case "검사기준": - return "bg-purple-100 text-purple-800 border-purple-200"; + return "bg-primary/10 text-primary border-primary/20"; case "구매": case "원가": - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-success/10 text-success border-success/20"; default: - return "bg-gray-100 text-gray-800 border-gray-200"; + return "bg-muted text-muted-foreground border-border"; + } +}; + +const getTimelineStatusStyle = (status: string) => { + switch (status) { + case "기각": + return "bg-muted text-muted-foreground border-border"; + case "적용완료": + case "ECN발행": + return "bg-success/10 text-success border-success/20"; + case "영향도분석": + return "bg-warning/10 text-warning border-warning/20"; + case "도면변경": + return "bg-primary/10 text-primary border-primary/20"; + case "통보완료": + return "bg-info/10 text-info border-info/20"; + default: + return "bg-info/10 text-info border-info/20"; } }; @@ -278,12 +292,12 @@ function Timeline({ history }: { history: EcrHistory[] }) { className={cn( "w-3 h-3 rounded-full border-2 mt-1.5 shrink-0", isLast && isRejected - ? "bg-rose-500 border-rose-300" + ? "bg-destructive border-destructive/60" : isLast && isCompleted - ? "bg-emerald-500 border-emerald-300" + ? "bg-success border-success/60" : isLast ? "bg-primary border-primary/50 ring-4 ring-primary/10" - : "bg-emerald-500 border-emerald-300" + : "bg-success border-success/60" )} /> {!isLast && ( @@ -295,19 +309,7 @@ function Timeline({ history }: { history: EcrHistory[] }) { {h.status} @@ -325,20 +327,45 @@ function Timeline({ history }: { history: EcrHistory[] }) { ); } +// --- Grid Columns --- +const ECR_GRID_COLUMNS = [ + { key: "request_no", label: "ECR번호" }, + { key: "change_type", label: "변경유형" }, + { key: "status", label: "상태" }, + { key: "urgency", label: "긴급" }, + { key: "target_name", label: "대상 품목/설비" }, + { key: "drawing_no", label: "도면번호" }, + { key: "req_dept", label: "요청부서" }, + { key: "requester", label: "요청자" }, + { key: "request_date", label: "요청일자" }, + { key: "ecn_no", label: "관련 ECN" }, +]; + +const ECN_GRID_COLUMNS = [ + { key: "ecn_no", label: "ECN번호" }, + { key: "status", label: "상태" }, + { key: "target", label: "대상 품목/설비" }, + { key: "drawing_after", label: "도면 (변경 후)" }, + { key: "designer", label: "설계담당" }, + { key: "ecn_date", label: "발행일자" }, + { key: "apply_date", label: "적용일자" }, + { key: "notify_depts", label: "통보 부서" }, + { key: "ecr_id", label: "관련 ECR" }, +]; + // --- Main Component --- export default function DesignChangeManagementPage() { + const tsEcr = useTableSettings("c16-change-management-ecr", "dsn_design_request", ECR_GRID_COLUMNS); + const tsEcn = useTableSettings("c16-change-management-ecn", "dsn_ecn", ECN_GRID_COLUMNS); const [currentTab, setCurrentTab] = useState("ecr"); const [ecrData, setEcrData] = useState([]); const [ecnData, setEcnData] = useState([]); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); - // 검색 상태 - const [searchDateFrom, setSearchDateFrom] = useState(""); - const [searchDateTo, setSearchDateTo] = useState(""); - const [searchStatus, setSearchStatus] = useState("all"); - const [searchChangeType, setSearchChangeType] = useState("all"); - const [searchKeyword, setSearchKeyword] = useState(""); + // 검색 필터 (DynamicSearchFilter) + const [searchFilters, setSearchFilters] = useState([]); // ECR 모달 const [isEcrModalOpen, setIsEcrModalOpen] = useState(false); @@ -356,13 +383,6 @@ export default function DesignChangeManagementPage() { const [rejectReason, setRejectReason] = useState(""); const [rejectTargetId, setRejectTargetId] = useState(""); - useEffect(() => { - const today = new Date(); - const threeMonthsAgo = new Date(today); - threeMonthsAgo.setMonth(today.getMonth() - 3); - setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); - setSearchDateTo(today.toISOString().split("T")[0]); - }, []); const fetchData = useCallback(async () => { setLoading(true); @@ -379,7 +399,7 @@ export default function DesignChangeManagementPage() { setEcnData((ecnRes.data as any[]).map((r) => mapEcnFromApi(r, ecrList))); } } catch { - toast.error("데이터를 불러오는데 실패했습니다."); + toast.error("데이터를 불러오는데 실패했어요."); } finally { setLoading(false); } @@ -389,39 +409,66 @@ export default function DesignChangeManagementPage() { fetchData(); }, [fetchData]); + // snake_case → camelCase 매핑 (ECR) + const ecrFieldMap: Record = { + request_no: "id", + request_date: "date", + change_type: "changeType", + target_name: "target", + drawing_no: "drawingNo", + req_dept: "reqDept", + ecn_no: "ecnNo", + apply_timing: "applyTiming", + }; + // snake_case → camelCase 매핑 (ECN) + const ecnFieldMap: Record = { + ecn_no: "id", + ecn_date: "date", + apply_date: "applyDate", + drawing_before: "drawingBefore", + drawing_after: "drawingAfter", + ecr_id: "ecrNo", + notify_depts: "notifyDepts", + }; + const getFieldValue = (obj: any, colName: string, map: Record): string => { + const key = map[colName] || colName; + const val = obj[key]; + if (Array.isArray(val)) return val.join(","); + return val !== undefined && val !== null ? String(val) : ""; + }; + + const applyFilters = (items: any[], map: Record) => { + if (searchFilters.length === 0) return items; + return items.filter((item) => { + for (const f of searchFilters) { + const val = getFieldValue(item, f.columnName, map); + if (f.operator === "contains") { + if (!val.toLowerCase().includes(f.value.toLowerCase())) return false; + } else if (f.operator === "equals") { + if (val !== f.value) return false; + } else if (f.operator === "in") { + const allowed = f.value.split("|"); + if (!allowed.includes(val)) return false; + } else if (f.operator === "between") { + const [from, to] = f.value.split("|"); + if (from && val < from) return false; + if (to && val > to) return false; + } + } + return true; + }); + }; + // --- Filtered Data --- const filteredEcr = useMemo(() => { - return ecrData - .filter((item) => { - if (searchDateFrom && item.date < searchDateFrom) return false; - if (searchDateTo && item.date > searchDateTo) return false; - if (searchStatus !== "all" && item.status !== searchStatus) return false; - if (searchChangeType !== "all" && item.changeType !== searchChangeType) return false; - if (searchKeyword) { - const kw = searchKeyword.toLowerCase(); - const str = [item.id, item.target, item.requester, item.drawingNo].join(" ").toLowerCase(); - if (!str.includes(kw)) return false; - } - return true; - }) - .sort((a, b) => b.date.localeCompare(a.date)); - }, [ecrData, searchDateFrom, searchDateTo, searchStatus, searchChangeType, searchKeyword]); + return applyFilters(ecrData, ecrFieldMap) + .sort((a: EcrItem, b: EcrItem) => b.date.localeCompare(a.date)); + }, [ecrData, searchFilters]); const filteredEcn = useMemo(() => { - return ecnData - .filter((item) => { - if (searchDateFrom && item.date < searchDateFrom) return false; - if (searchDateTo && item.date > searchDateTo) return false; - if (searchStatus !== "all" && item.status !== searchStatus) return false; - if (searchKeyword) { - const kw = searchKeyword.toLowerCase(); - const str = [item.id, item.target, item.designer, item.ecrNo].join(" ").toLowerCase(); - if (!str.includes(kw)) return false; - } - return true; - }) - .sort((a, b) => b.date.localeCompare(a.date)); - }, [ecnData, searchDateFrom, searchDateTo, searchStatus, searchKeyword]); + return applyFilters(ecnData, ecnFieldMap) + .sort((a: EcnItem, b: EcnItem) => b.date.localeCompare(a.date)); + }, [ecnData, searchFilters]); // --- Status Counts --- const ecrStatusCounts = useMemo(() => { @@ -450,35 +497,21 @@ export default function DesignChangeManagementPage() { const handleTabSwitch = (tab: TabType) => { setCurrentTab(tab); setSelectedId(null); - setSearchStatus("all"); }; - // --- Search --- - const handleResetSearch = () => { - const today = new Date(); - const threeMonthsAgo = new Date(today); - threeMonthsAgo.setMonth(today.getMonth() - 3); - setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); - setSearchDateTo(today.toISOString().split("T")[0]); - setSearchStatus("all"); - setSearchChangeType("all"); - setSearchKeyword(""); - }; - - const handleFilterByStatus = (status: string) => { - setSearchStatus(status); + const handleFilterByStatus = (_status: string) => { + // Status filter now handled by DynamicSearchFilter }; // --- ECR/ECN Navigation --- const navigateToLink = (targetId: string) => { + setDetailOpen(false); if (targetId.startsWith("ECN")) { setCurrentTab("ecn"); setSelectedId(targetId); - setSearchStatus("all"); } else if (targetId.startsWith("ECR")) { setCurrentTab("ecr"); setSelectedId(targetId); - setSearchStatus("all"); } }; @@ -540,19 +573,19 @@ export default function DesignChangeManagementPage() { const handleSaveEcr = async () => { if (!ecrForm.changeType) { - toast.error("변경 유형을 선택하세요."); + toast.error("변경 유형을 선택해 주세요."); return; } if (!ecrForm.target?.trim()) { - toast.error("대상 품목/설비를 입력하세요."); + toast.error("대상 품목/설비를 입력해 주세요."); return; } if (!ecrForm.reason?.trim()) { - toast.error("변경 사유를 입력하세요."); + toast.error("변경 사유를 입력해 주세요."); return; } if (!ecrForm.content?.trim()) { - toast.error("변경 요구 내용을 입력하세요."); + toast.error("변경 요구 내용을 입력해 주세요."); return; } @@ -581,11 +614,11 @@ export default function DesignChangeManagementPage() { apply_timing: ecrForm.applyTiming || "즉시", }); if (res.success) { - toast.success("ECR이 수정되었습니다."); + toast.success("ECR이 수정되었어요."); setIsEcrModalOpen(false); fetchData(); } else { - toast.error(res.message || "ECR 수정에 실패했습니다."); + toast.error(res.message || "ECR 수정에 실패했어요."); } } else { const res = await createDesignRequest({ @@ -606,11 +639,11 @@ export default function DesignChangeManagementPage() { history: [historyEntry], }); if (res.success) { - toast.success("ECR이 등록되었습니다."); + toast.success("ECR이 등록되었어요."); setIsEcrModalOpen(false); fetchData(); } else { - toast.error(res.message || "ECR 등록에 실패했습니다."); + toast.error(res.message || "ECR 등록에 실패했어요."); } } }; @@ -641,15 +674,15 @@ export default function DesignChangeManagementPage() { const handleSaveEcn = async () => { if (!ecnForm.after?.trim()) { - toast.error("변경 후(TO-BE) 내용을 입력하세요."); + toast.error("변경 후(TO-BE) 내용을 입력해 주세요."); return; } if (!ecnForm.applyDate) { - toast.error("적용일자를 입력하세요."); + toast.error("적용일자를 입력해 주세요."); return; } if (!ecnForm.ecrId) { - toast.error("관련 ECR 정보가 없습니다."); + toast.error("관련 ECR 정보가 없어요."); return; } @@ -692,11 +725,11 @@ export default function DesignChangeManagementPage() { user_name: ecnForm.designer || "시스템", description: `${ecnNo} 발행`, }); - toast.success("ECN이 발행되었습니다."); + toast.success("ECN이 발행되었어요."); setIsEcnModalOpen(false); fetchData(); } else { - toast.error(res.message || "ECN 발행에 실패했습니다."); + toast.error(res.message || "ECN 발행에 실패했어요."); } }; @@ -709,19 +742,19 @@ export default function DesignChangeManagementPage() { const handleRejectSubmit = async () => { if (!rejectReason.trim()) { - toast.error("기각 사유를 입력하세요."); + toast.error("기각 사유를 입력해 주세요."); return; } const ecr = ecrData.find((r) => r.id === rejectTargetId); if (!ecr?._id) { - toast.error("ECR 정보를 찾을 수 없습니다."); + toast.error("ECR 정보를 찾을 수 없어요."); return; } const updateRes = await updateDesignRequest(ecr._id, { status: "기각", review_memo: rejectReason }); if (!updateRes.success) { - toast.error(updateRes.message || "ECR 기각에 실패했습니다."); + toast.error(updateRes.message || "ECR 기각에 실패했어요."); return; } await addRequestHistory(ecr._id, { @@ -730,571 +763,397 @@ export default function DesignChangeManagementPage() { user_name: "설계팀", description: rejectReason, }); - toast.success("ECR이 기각되었습니다."); + toast.success("ECR이 기각되었어요."); setIsRejectModalOpen(false); fetchData(); }; // --- Stat Cards --- const ecrStatCards = [ - { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, gradient: "from-indigo-500 to-blue-600", textColor: "text-white" }, - { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, gradient: "from-amber-400 to-orange-500", textColor: "text-white" }, - { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, color: "text-info" }, + { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, color: "text-warning" }, + { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, color: "text-success" }, ]; const ecnStatCards = [ - { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, gradient: "from-purple-400 to-violet-600", textColor: "text-white" }, - { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, gradient: "from-teal-400 to-cyan-600", textColor: "text-white" }, - { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, color: "text-primary" }, + { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, color: "text-info" }, + { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, color: "text-success" }, ]; const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards; const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn; - const currentStatuses = currentTab === "ecr" ? ECR_STATUSES : ECN_STATUSES; + + const handleRowClick = (id: string) => { + setSelectedId(id); + setDetailOpen(true); + }; return ( -
+
{loading && (
)} - {/* 검색 섹션 */} - - -
- -
- setSearchDateFrom(e.target.value)} - /> - ~ - setSearchDateTo(e.target.value)} - /> -
-
-
- - -
+ {/* 탭 선택 + 검색 필터 */} +
+
+ +
+ {currentTab === "ecr" ? ( + + ) : ( + + )} +
-
- - -
+ {/* 현황 카드 */} +
+ {currentStatCards.map((card) => ( + + ))} +
+ {/* 액션 바 */} +
+
+

+ {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} +

+ {currentList.length}건 +
+
{currentTab === "ecr" && ( -
- - -
- )} - -
- - setSearchKeyword(e.target.value)} - /> -
- -
- -
- -
- - + )} + +
+
- {/* 메인 분할 레이아웃 */} -
- - {/* 왼쪽: 목록 */} - -
-
-
- - {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} - - {currentList.length}건 - -
- {currentTab === "ecr" && ( - - )} -
+ {/* 테이블 영역 */} +
+
+ {currentTab === "ecr" ? ( + {val} }, + { key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? 긴급 : - }, + { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, + { key: "drawingNo", label: "도면번호", width: "w-[150px]" }, + { key: "reqDept", label: "요청부서", width: "w-[80px]" }, + { key: "requester", label: "요청자", width: "w-[70px]" }, + { key: "date", label: "요청일자", width: "w-[100px]" }, + { key: "ecnNo", label: "관련 ECN", width: "w-[130px]", render: (val: any) => val ? : - }, + ] as EDataTableColumn[]} + data={tsEcr.groupData(filteredEcr)} + rowKey={(row) => row.id} + selectedId={selectedId} + onSelect={(id) => { if (id) handleRowClick(id); }} + onRowClick={(row) => handleRowClick(row.id)} + emptyMessage="조건에 맞는 ECR이 없어요" + showRowNumber + showPagination={false} + draggableColumns={false} + /> + ) : ( + {val} }, + { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, + { key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => {val} }, + { key: "designer", label: "설계담당", width: "w-[80px]" }, + { key: "date", label: "발행일자", width: "w-[100px]" }, + { key: "applyDate", label: "적용일자", width: "w-[100px]" }, + { key: "notifyDepts", label: "통보 부서", width: "w-[140px]", render: (val: any) => {Array.isArray(val) ? val.join(", ") : val} }, + { key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => }, + ] as EDataTableColumn[]} + data={tsEcn.groupData(filteredEcn)} + rowKey={(row) => row.id} + selectedId={selectedId} + onSelect={(id) => { if (id) handleRowClick(id); }} + onRowClick={(row) => handleRowClick(row.id)} + emptyMessage="조건에 맞는 ECN이 없어요" + showRowNumber + showPagination={false} + draggableColumns={false} + /> + )} +
+
-
- {currentTab === "ecr" ? ( - - - - No - ECR번호 - 변경유형 - 상태 - 긴급 - 대상 품목/설비 - 도면번호 - 요청부서 - 요청자 - 요청일자 - 관련 ECN - - - - {filteredEcr.length === 0 ? ( - - -
- - 조건에 맞는 ECR이 없습니다 -
-
-
+ {/* 상세 정보 다이얼로그 */} + + + + {currentTab === "ecr" ? `ECR 상세 — ${selectedEcr?.id || ""}` : `ECN 상세 — ${selectedEcn?.id || ""}`} + {currentTab === "ecr" ? "설계변경요청의 상세 정보를 확인해요." : "설계변경통지의 상세 정보를 확인해요."} + +
+
+ {/* ECR 상세 */} + {selectedEcr ? ( + <> +
+

+ 기본 정보 +

+
+
+ ECR번호 + {selectedEcr.id} +
+
+ 상태 + + {selectedEcr.status} + +
+
+ 변경 유형 + + {selectedEcr.changeType} + +
+
+ 긴급도 + + {selectedEcr.urgency === "긴급" ? ( + 긴급 ) : ( - filteredEcr.map((item, idx) => ( - setSelectedId(item.id)} - > - {idx + 1} - {item.id} - - - {item.changeType} - - - - - {item.status} - - - - {item.urgency === "긴급" ? ( - - 긴급 - - ) : ( - "-" - )} - - {item.target} - {item.drawingNo} - {item.reqDept} - {item.requester} - {item.date} - - {item.ecnNo ? ( - - ) : ( - "-" - )} - - - )) + "보통" )} - -
- ) : ( - - - - No - ECN번호 - 상태 - 대상 품목/설비 - 도면 (변경 후) - 설계담당 - 발행일자 - 적용일자 - 통보 부서 - 관련 ECR - - - - {filteredEcn.length === 0 ? ( - - -
- - 조건에 맞는 ECN이 없습니다 -
-
-
- ) : ( - filteredEcn.map((item, idx) => ( - setSelectedId(item.id)} - > - {idx + 1} - {item.id} - - - {item.status} - - - {item.target} - {item.drawingAfter} - {item.designer} - {item.date} - {item.applyDate} - {item.notifyDepts.join(", ")} - - - - - )) - )} -
-
- )} -
-
-
- - - - {/* 오른쪽: 상세 */} - -
-
- - - 상세 정보 - - {selectedEcr && ( -
- - {selectedEcr.status === "영향도분석" && ( - <> - - - + +
+
+ 대상 품목/설비 + {selectedEcr.target} +
+
+ 도면번호 + {selectedEcr.drawingNo} +
+
+ 요청부서 / 요청자 + {selectedEcr.reqDept} / {selectedEcr.requester} +
+
+ 요청일자 + {selectedEcr.date} +
+
+ 희망 적용시점 + {selectedEcr.applyTiming} +
+
+ 관련 ECN + {selectedEcr.ecnNo ? ( + + ) : ( + 미발행 )}
- )} -
+
+ -
- {/* 현황 카드 */} -
- {currentStatCards.map((card) => ( - +
+

변경 사유

+
+ {selectedEcr.reason} +
+
+ +
+

변경 요구 내용

+
+ {selectedEcr.content} +
+
+ +
+

영향 범위

+
+ {selectedEcr.impact.map((imp) => ( + + {imp} + ))}
+
- {/* ECR 상세 */} - {selectedEcr ? ( -
-
-

- 기본 정보 -

-
-
- ECR번호 - {selectedEcr.id} -
-
- 상태 - - {selectedEcr.status} - -
-
- 변경 유형 - - {selectedEcr.changeType} - -
-
- 긴급도 - - {selectedEcr.urgency === "긴급" ? ( - 긴급 - ) : ( - "보통" - )} - -
-
- 대상 품목/설비 - {selectedEcr.target} -
-
- 도면번호 - {selectedEcr.drawingNo} -
-
- 요청부서 / 요청자 - {selectedEcr.reqDept} / {selectedEcr.requester} -
-
- 요청일자 - {selectedEcr.date} -
-
- 희망 적용시점 - {selectedEcr.applyTiming} -
-
- 관련 ECN - {selectedEcr.ecnNo ? ( - - ) : ( - 미발행 - )} -
-
-
- -
-

변경 사유

-
- {selectedEcr.reason} -
-
- -
-

변경 요구 내용

-
- {selectedEcr.content} -
-
- -
-

영향 범위

-
- {selectedEcr.impact.map((imp) => ( - - {imp} - - ))} -
-
- -
-

처리 이력

- -
+
+

처리 이력

+ +
+ + ) : selectedEcn ? ( + <> +
+

+ ECN 기본 정보 +

+
+
+ ECN번호 + {selectedEcn.id}
- ) : selectedEcn ? ( -
-
-

- ECN 기본 정보 -

-
-
- ECN번호 - {selectedEcn.id} -
-
- 상태 - - {selectedEcn.status} - -
-
- 대상 품목/설비 - {selectedEcn.target} -
-
- 설계담당 - {selectedEcn.designer} -
-
- 발행일자 - {selectedEcn.date} -
-
- 적용일자 - {selectedEcn.applyDate} -
-
- 관련 ECR - -
-
- 통보 부서 - {selectedEcn.notifyDepts.join(", ")} -
-
-
- -
-

변경 전/후 비교

-
-
-
- 변경 전 ({selectedEcn.drawingBefore}) -
-
{selectedEcn.before}
-
-
-
- 변경 후 ({selectedEcn.drawingAfter}) -
-
{selectedEcn.after}
-
-
-
- -
-

변경 사유

-
- {selectedEcn.reason} -
- {selectedEcn.remark && ( -

비고: {selectedEcn.remark}

- )} -
- -
-

처리 이력

- -
+
+ 상태 + + {selectedEcn.status} +
- ) : ( -
-
- +
+ 대상 품목/설비 + {selectedEcn.target} +
+
+ 설계담당 + {selectedEcn.designer} +
+
+ 발행일자 + {selectedEcn.date} +
+
+ 적용일자 + {selectedEcn.applyDate} +
+
+ 관련 ECR + +
+
+ 통보 부서 + {selectedEcn.notifyDepts.join(", ")} +
+
+
+ +
+

변경 전/후 비교

+
+
+
+ 변경 전 ({selectedEcn.drawingBefore})
-

좌측 목록에서 항목을 선택하세요

+
{selectedEcn.before}
+
+
+ 변경 후 ({selectedEcn.drawingAfter}) +
+
{selectedEcn.after}
+
+
+
+ +
+

변경 사유

+
+ {selectedEcn.reason} +
+ {selectedEcn.remark && ( +

비고: {selectedEcn.remark}

)} -
-
- - -
+ + +
+

처리 이력

+ +
+ + ) : null} +
+
+ + {selectedEcr && ( + <> + + {selectedEcr.status === "영향도분석" && ( + <> + + + + )} + + )} + + + {/* ECR 등록/수정 모달 */} - + - - {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} - - - {isEcrEditMode ? "ECR 정보를 수정합니다." : "새로운 설계변경요청을 등록합니다."} - + {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} + {isEcrEditMode ? "ECR 정보를 수정해요." : "새로운 설계변경요청을 등록해요."} - -
-
+
+
{/* 좌측: 요청 정보 */}

변경 요청 정보

- +
- +
- + setEcrForm((p) => ({ ...p, changeType: v as ChangeType }))}> @@ -1332,7 +1191,7 @@ export default function DesignChangeManagementPage() {
- + setEcrForm((p) => ({ ...p, target: e.target.value }))} @@ -1356,7 +1215,7 @@ export default function DesignChangeManagementPage() {
- + setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))} @@ -1367,7 +1226,7 @@ export default function DesignChangeManagementPage() {
- +
- + setEcrForm((p) => ({ ...p, requester: e.target.value }))} @@ -1397,27 +1256,27 @@ export default function DesignChangeManagementPage() {

변경 내용

- +