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..6f98d37c 100644 --- a/backend-node/src/controllers/smartFactoryLogController.ts +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -7,9 +7,8 @@ import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; import { encryptionService } from "../services/encryptionService"; import { - runScheduleNow, + sendSmartFactoryLog, getTodayPlanStatus, - planDailySends, } from "../utils/smartFactoryLog"; /** @@ -254,7 +253,7 @@ export const upsertSchedule = async ( res: Response ): Promise => { try { - const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body; + const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays, dailyCount } = req.body; if (!companyCode) { res.status(400).json({ success: false, message: "회사코드는 필수입니다." }); @@ -262,11 +261,11 @@ export const upsertSchedule = async ( } await query( - `INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) + `INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, daily_count, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (company_code) DO UPDATE SET is_active = $2, time_start = $3, time_end = $4, - exclude_weekend = $5, exclude_holidays = $6, updated_at = NOW()`, + exclude_weekend = $5, exclude_holidays = $6, daily_count = $7, updated_at = NOW()`, [ companyCode, isActive ?? false, @@ -274,13 +273,12 @@ export const upsertSchedule = async ( timeEnd || "17:30", excludeWeekend ?? true, excludeHolidays ?? true, + Math.max(1, Math.min(3, dailyCount || 1)), ] ); - // 스케줄 변경 시 오늘 계획 재생성 - await planDailySends(); - - res.json({ success: true, message: "스케줄이 저장되었습니다." }); + // 계획은 매일 00:05에만 생성 (즉시 재생성하면 지난 시각 소급 전송 위험) + res.json({ success: true, message: "스케줄이 저장되었습니다. 내일 00:05부터 적용됩니다." }); } catch (error) { logger.error("스케줄 저장 실패:", error); res.status(500).json({ success: false, message: "스케줄 저장 실패" }); @@ -307,23 +305,6 @@ export const deleteSchedule = async ( /** * POST /api/admin/smart-factory-log/schedules/:companyCode/run-now */ -export const runScheduleNowHandler = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { companyCode } = req.params; - const result = await runScheduleNow(companyCode); - res.json({ success: true, data: result }); - } catch (error) { - logger.error("즉시 실행 실패:", error); - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : "즉시 실행 실패", - }); - } -}; - /** * GET /api/admin/smart-factory-log/schedules/today-plan */ @@ -502,3 +483,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..cd31c8a4 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -38,7 +38,6 @@ import { getSchedules, upsertSchedule, deleteSchedule, - runScheduleNowHandler, getTodayPlanHandler, getHolidays, addHoliday, @@ -46,6 +45,8 @@ import { getApiKeys, saveApiKey, deleteApiKey, + getCompanyUsers, + sendNow, } from "../controllers/smartFactoryLogController"; import { authenticateToken } from "../middleware/authMiddleware"; import { requireSuperAdmin } from "../middleware/permissionMiddleware"; @@ -108,13 +109,17 @@ router.get("/smart-factory-log/schedules", requireSuperAdmin, getSchedules); router.get("/smart-factory-log/schedules/today-plan", requireSuperAdmin, getTodayPlanHandler); router.post("/smart-factory-log/schedules", requireSuperAdmin, upsertSchedule); router.delete("/smart-factory-log/schedules/:companyCode", requireSuperAdmin, deleteSchedule); -router.post("/smart-factory-log/schedules/:companyCode/run-now", requireSuperAdmin, runScheduleNowHandler); + // 스마트공장 공휴일 관리 (최고관리자 전용) 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..e491399f 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -16,7 +16,8 @@ interface ScheduledEntry { userId: string; userName: string; companyCode: string; - scheduledTime: Date; // 초 단위까지 배정된 시각 + scheduledTime: Date; + useType: "접속" | "종료"; sent: boolean; } @@ -74,17 +75,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 +95,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) { @@ -142,9 +145,8 @@ export async function initSmartFactoryScheduler(): Promise { } }, { timezone: "Asia/Seoul" }); - // 서버 시작 시 오늘 계획이 아직 없으면 바로 생성 - await planDailySends(); - + // 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성) + // 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지 logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)"); } @@ -163,8 +165,9 @@ export async function planDailySends(): Promise { time_end: string; exclude_weekend: boolean; exclude_holidays: boolean; + daily_count: number; }>( - "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true" + "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count FROM smart_factory_schedule WHERE is_active = true" ); if (schedules.length === 0) return; @@ -173,7 +176,7 @@ export async function planDailySends(): Promise { await refreshHolidayCache(); for (const schedule of schedules) { - const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule; + const { company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count } = schedule; // 주말 체크 if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) { @@ -225,11 +228,12 @@ export async function planDailySends(): Promise { continue; } - // 랜덤 시각 배정 (초 단위) - const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code); + // 접속/종료 쌍 + 다회 시각 배정 + const entries = assignSessionPairs(attendees, today, time_start, time_end, company_code, daily_count); dailyPlan.set(company_code, entries); - logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`); + const sessionCount = entries.filter((e) => e.useType === "접속").length; + logger.info(`스마트공장 스케줄 ${company_code}: ${attendees.length}명 × 최대${daily_count}회 = ${sessionCount}세션 계획 (${time_start}~${time_end})`); } } @@ -245,10 +249,7 @@ async function executeScheduledSends(): Promise { if (entry.sent) continue; const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes(); - if (entryMinute > currentMinute) continue; // 아직 안 됨 - if (entryMinute < currentMinute) { - // 이미 지난 분인데 못 보낸 것 — 보냄 - } + if (entryMinute !== currentMinute) continue; // 정확히 해당 분에만 전송 // 전송 entry.sent = true; @@ -261,7 +262,7 @@ async function executeScheduledSends(): Promise { userId: entry.userId, userName: entry.userName, remoteAddr: randomIp, - useType: "접속", + useType: entry.useType, companyCode: entry.companyCode, logTime: entry.scheduledTime, }); @@ -275,71 +276,6 @@ async function executeScheduledSends(): Promise { } } -/** - * 수동 즉시 실행 (관리자 테스트용) - */ -export async function runScheduleNow(companyCode: string): Promise<{ total: number; sent: number; skipped: number }> { - const schedule = await query<{ - time_start: string; - time_end: string; - }>( - "SELECT time_start, time_end FROM smart_factory_schedule WHERE company_code = $1 AND is_active = true", - [companyCode] - ); - - if (schedule.length === 0) { - throw new Error("활성 스케줄이 없습니다."); - } - - // API 키 확인 - const apiKey = await getApiKey(companyCode); - if (!apiKey) { - throw new Error("API 키가 설정되지 않았습니다. API 키 관리에서 먼저 등록해주세요."); - } - - const { time_start, time_end } = schedule[0]; - const today = new Date(); - - // 사용자 조회 - const users = await query<{ user_id: string; user_name: string }>( - "SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)", - [companyCode] - ); - - // 오늘 이미 전송된 사용자 제외 - const todayStr = formatDate(today); - const alreadySent = await query<{ user_id: string }>( - "SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)", - [companyCode, todayStr] - ); - const alreadySentSet = new Set(alreadySent.map((r) => r.user_id)); - const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id)); - - let sent = 0; - for (const user of pendingUsers) { - // 시간 범위 내 랜덤 시각 생성 - const randomTime = generateRandomTime(today, time_start, time_end); - const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`; - - try { - await sendSmartFactoryLog({ - userId: user.user_id, - userName: user.user_name, - remoteAddr: randomIp, - useType: "접속", - companyCode, - logTime: randomTime, - }); - sent++; - } catch (e) { - logger.error(`스마트공장 즉시 전송 실패: ${user.user_id}`, e); - } - - await sleep(300); - } - - return { total: users.length, sent, skipped: alreadySentSet.size }; -} /** * 오늘 실행 계획 현황 반환 @@ -365,13 +301,17 @@ export function getTodayPlanStatus(): Array<{ // ─── 내부 함수 ─── -/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */ -function assignRandomTimes( +/** + * 사용자별 접속/종료 쌍을 생성 + * dailyCount: 최대 접속 횟수 (사용자별 1~dailyCount 랜덤) + */ +function assignSessionPairs( users: Array<{ user_id: string; user_name: string }>, today: Date, timeStart: string, timeEnd: string, - companyCode: string + companyCode: string, + dailyCount: number ): ScheduledEntry[] { const [startH, startM] = timeStart.split(":").map(Number); const [endH, endM] = timeEnd.split(":").map(Number); @@ -381,49 +321,68 @@ function assignRandomTimes( if (totalSec <= 0) return []; - const slotSize = totalSec / users.length; + const allEntries: ScheduledEntry[] = []; + const maxCount = Math.max(1, Math.min(3, dailyCount)); - const entries: ScheduledEntry[] = users.map((user, idx) => { - // 각 슬롯 내에서 랜덤 오프셋 (초 단위) - const slotStart = startSec + Math.floor(slotSize * idx); - const randomOffset = Math.floor(Math.random() * slotSize); - const assignedSec = Math.min(slotStart + randomOffset, endSec - 1); + for (const user of users) { + // 사용자별 1 ~ maxCount 사이 랜덤 횟수 + const count = Math.floor(Math.random() * maxCount) + 1; + // 시간대를 횟수로 균등 분할 + const slotSec = Math.floor(totalSec / count); - const h = Math.floor(assignedSec / 3600); - const m = Math.floor((assignedSec % 3600) / 60); - const s = assignedSec % 60; + for (let i = 0; i < count; i++) { + const slotStart = startSec + slotSec * i; + const slotEnd = i < count - 1 ? slotStart + slotSec : endSec; - const scheduledTime = new Date(today); - scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000)); + // 접속 시각: 슬롯 전반부에서 랜덤 + const loginWindow = Math.floor((slotEnd - slotStart) * 0.4); // 슬롯의 앞 40% + const loginSec = slotStart + Math.floor(Math.random() * Math.max(loginWindow, 60)); + const clampedLoginSec = Math.min(loginSec, endSec - 120); // 최소 2분 여유 - return { - userId: user.user_id, - userName: user.user_name, - companyCode, - scheduledTime, - sent: false, - }; - }); + // 종료 시각: 접속 후 30분~2시간 사이 랜덤 + const minSession = 30 * 60; // 30분 + const maxSession = 120 * 60; // 2시간 + const sessionLen = minSession + Math.floor(Math.random() * (maxSession - minSession)); + const logoutSec = Math.min(clampedLoginSec + sessionLen, endSec - 1); + + // 접속과 종료 시각이 너무 가까우면(2분 미만) 스킵 + if (logoutSec - clampedLoginSec < 120) continue; + + const loginTime = secToDate(today, clampedLoginSec); + const logoutTime = secToDate(today, logoutSec); + + allEntries.push({ + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime: loginTime, + useType: "접속", + sent: false, + }); + + allEntries.push({ + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime: logoutTime, + useType: "종료", + sent: false, + }); + } + } // 시각순 정렬 - return entries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); + return allEntries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); } -/** 단일 랜덤 시각 생성 (즉시 실행용) */ -function generateRandomTime(today: Date, timeStart: string, timeEnd: string): Date { - const [startH, startM] = timeStart.split(":").map(Number); - const [endH, endM] = timeEnd.split(":").map(Number); - const startSec = startH * 3600 + startM * 60; - const endSec = endH * 3600 + endM * 60; - const randomSec = startSec + Math.floor(Math.random() * (endSec - startSec)); - - const h = Math.floor(randomSec / 3600); - const m = Math.floor((randomSec % 3600) / 60); - const s = randomSec % 60; - - const time = new Date(today); - time.setHours(h, m, s, Math.floor(Math.random() * 1000)); - return time; +/** 초(하루 내)를 Date로 변환 */ +function secToDate(today: Date, sec: number): Date { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const d = new Date(today); + d.setHours(h, m, s, Math.floor(Math.random() * 1000)); + return d; } /** 공휴일 캐시 갱신 */ diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx new file mode 100644 index 00000000..cacd9d02 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx @@ -0,0 +1,1703 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Plus, + Trash2, + Save, + Loader2, + Pencil, + ClipboardCheck, + AlertTriangle, + Wrench, + Search, + Inbox, + Settings2, +} from "lucide-react"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { useTableSettings } from "@/hooks/useTableSettings"; +import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; + +/* ───── 테이블명 ───── */ +const INSPECTION_TABLE = "inspection_standard"; + +const INSPECTION_COLUMNS = [ + { key: "inspection_code", label: "검사코드" }, + { key: "inspection_type", label: "검사유형" }, + { key: "inspection_criteria", label: "검사기준" }, + { key: "inspection_item", label: "검사항목" }, + { key: "inspection_method", label: "검사방법" }, + { key: "judgment_criteria", label: "판단기준" }, + { key: "unit", label: "단위" }, + { key: "apply_type", label: "적용구분" }, + { key: "manager", label: "관리자" }, +]; +const DEFECT_TABLE = "defect_standard_mng"; +const EQUIPMENT_TABLE = "inspection_equipment_mng"; + +/* ───── 카테고리 flatten ───── */ +const flattenCategories = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel }); + if (v.children?.length) result.push(...flattenCategories(v.children)); + } + return result; +}; + +export default function InspectionManagementPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const ts = useTableSettings("c16-inspection", INSPECTION_TABLE, INSPECTION_COLUMNS); + + const [activeTab, setActiveTab] = useState("inspection"); + + /* ───── 검사기준 ───── */ + const [inspections, setInspections] = useState([]); + const [inspLoading, setInspLoading] = useState(false); + const [inspCount, setInspCount] = useState(0); + const [inspChecked, setInspChecked] = useState([]); + const [inspModalOpen, setInspModalOpen] = useState(false); + const [inspEditMode, setInspEditMode] = useState(false); + const [inspForm, setInspForm] = useState>({}); + const [inspSaving, setInspSaving] = useState(false); + const [searchFilters, setSearchFilters] = useState([]); + + /* ───── 불량관리 ───── */ + const [defects, setDefects] = useState([]); + const [defLoading, setDefLoading] = useState(false); + const [defCount, setDefCount] = useState(0); + const [defChecked, setDefChecked] = useState([]); + const [defModalOpen, setDefModalOpen] = useState(false); + const [defEditMode, setDefEditMode] = useState(false); + const [defForm, setDefForm] = useState>({}); + const [defSaving, setDefSaving] = useState(false); + const [defKeyword, setDefKeyword] = useState(""); + + /* ───── 검사장비 ───── */ + const [equipments, setEquipments] = useState([]); + const [eqLoading, setEqLoading] = useState(false); + const [eqCount, setEqCount] = useState(0); + const [eqChecked, setEqChecked] = useState([]); + const [eqModalOpen, setEqModalOpen] = useState(false); + const [eqEditMode, setEqEditMode] = useState(false); + const [eqForm, setEqForm] = useState>({}); + const [eqSaving, setEqSaving] = useState(false); + const [eqKeyword, setEqKeyword] = useState(""); + + /* ───── 채번 ───── */ + const [numberingRuleId, setNumberingRuleId] = useState(null); + const [previewCode, setPreviewCode] = useState(null); + + /* ───── 카테고리 옵션 ───── */ + const [catOptions, setCatOptions] = useState>({}); + const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); + + /* ═══════════════════ 카테고리 로드 ═══════════════════ */ + useEffect(() => { + const load = async () => { + const optMap: Record = {}; + const catList = [ + { table: INSPECTION_TABLE, col: "inspection_type" }, + { table: INSPECTION_TABLE, col: "apply_type" }, + { table: INSPECTION_TABLE, col: "inspection_method" }, + { table: INSPECTION_TABLE, col: "judgment_criteria" }, + { table: INSPECTION_TABLE, col: "unit" }, + { table: DEFECT_TABLE, col: "defect_type" }, + { table: DEFECT_TABLE, col: "severity" }, + { table: DEFECT_TABLE, col: "inspection_type" }, + { table: DEFECT_TABLE, col: "is_active" }, + { table: EQUIPMENT_TABLE, col: "equipment_type" }, + { table: EQUIPMENT_TABLE, col: "equipment_status" }, + ]; + await Promise.all( + catList.map(async ({ table, col }) => { + try { + const res = await apiClient.get(`/table-categories/${table}/${col}/values`); + if (res.data?.data?.length > 0) { + optMap[`${table}.${col}`] = flattenCategories(res.data.data); + } + } catch { + /* skip */ + } + }), + ); + setCatOptions(optMap); + // 사용자 목록 로드 + try { + const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { + page: 1, + size: 500, + autoFilter: true, + }); + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; + setUserOptions( + users.map((u: any) => ({ + code: u.user_id || u.id, + label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`, + })), + ); + } catch { + /* skip */ + } + }; + load(); + }, []); + + const getCatLabel = (table: string, col: string, code: string) => { + if (!code) return ""; + const opts = catOptions[`${table}.${col}`]; + if (!opts) return code; + // 쉼표 구분 다중 코드 지원 + if (code.includes(",")) { + return code + .split(",") + .filter(Boolean) + .map((c) => opts.find((o) => o.code === c)?.label || c) + .join(", "); + } + return opts.find((o) => o.code === code)?.label || code; + }; + + const inspTableColumns = useMemo(() => { + return ts.visibleColumns.map((col) => { + const base: EDataTableColumn = { key: col.key, label: col.label }; + if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) { + base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]); + } + return base; + }); + }, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps + + /* ═══════════════════ 데이터 조회 ═══════════════════ */ + // 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용 + const MULTI_VALUE_COLUMNS = ["inspection_type"]; + + const fetchInspections = useCallback(async () => { + setInspLoading(true); + try { + const filters = searchFilters.map((f) => ({ + columnName: f.columnName, + operator: MULTI_VALUE_COLUMNS.includes(f.columnName) ? "contains" : f.operator, + value: f.value, + })); + const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { + page: 1, + size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setInspections(rows); + setInspCount(rows.length); + } catch { + toast.error("검사기준 조회에 실패했어요"); + } finally { + setInspLoading(false); + } + }, [searchFilters]); + + const fetchDefects = useCallback(async () => { + setDefLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, { + page: 1, + size: 500, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setDefects(rows); + setDefCount(rows.length); + } catch { + toast.error("불량관리 조회에 실패했어요"); + } finally { + setDefLoading(false); + } + }, []); + + const fetchEquipments = useCallback(async () => { + setEqLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { + page: 1, + size: 500, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setEquipments(rows); + setEqCount(rows.length); + } catch { + toast.error("검사장비 조회에 실패했어요"); + } finally { + setEqLoading(false); + } + }, []); + + useEffect(() => { + fetchInspections(); + }, [fetchInspections]); + useEffect(() => { + fetchDefects(); + fetchEquipments(); + }, []); + + /* ───── 클라이언트 필터 ───── */ + const filteredDefects = defKeyword.trim() + ? defects.filter( + (r) => + (r.defect_name || "").toLowerCase().includes(defKeyword.toLowerCase()) || + (r.defect_type || "").toLowerCase().includes(defKeyword.toLowerCase()), + ) + : defects; + + const filteredEquipments = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + + /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ + const openInspCreate = async () => { + setInspForm({}); + setInspEditMode(false); + setNumberingRuleId(null); + setPreviewCode(null); + setInspModalOpen(true); + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const prev = await previewNumberingCode(ruleId); + if (prev.success && prev.data?.generatedCode) { + setPreviewCode(prev.data.generatedCode); + } + } + } catch { /* 채번 규칙 없으면 무시 */ } + }; + const openInspEdit = (row: any) => { + setInspForm({ ...row }); + setInspEditMode(true); + setInspModalOpen(true); + }; + const saveInspection = async () => { + if (!numberingRuleId && !inspForm.inspection_code) { + toast.error("검사코드는 필수예요"); + return; + } + if (!inspForm.inspection_type) { + toast.error("유형을 1개 이상 선택해주세요"); + return; + } + if (!inspForm.inspection_criteria) { + toast.error("검사기준은 필수예요"); + return; + } + if (!inspForm.inspection_item) { + toast.error("검사항목은 필수예요"); + return; + } + if (!inspForm.judgment_criteria) { + toast.error("판단기준은 필수예요"); + return; + } + setInspSaving(true); + try { + let finalCode = inspForm.inspection_code || ""; + if (!inspEditMode && numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + finalCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + setInspSaving(false); + return; + } + } + if (inspEditMode) { + await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, { + originalData: { id: inspForm.id }, + updatedData: inspForm, + }); + toast.success("검사기준을 수정했어요"); + } else { + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + id: crypto.randomUUID(), + ...inspForm, + inspection_code: finalCode, + }); + toast.success("검사기준을 등록했어요"); + } + setInspModalOpen(false); + fetchInspections(); + } catch { + toast.error("저장에 실패했어요"); + } finally { + setInspSaving(false); + } + }; + const deleteInspections = async () => { + if (inspChecked.length === 0) { + toast.error("삭제할 항목을 선택해주세요"); + return; + } + const ok = await confirm("검사기준 삭제", { + description: `선택한 ${inspChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { + data: inspChecked.map((id) => ({ id })), + }); + toast.success(`${inspChecked.length}건을 삭제했어요`); + setInspChecked([]); + fetchInspections(); + } catch { + toast.error("삭제에 실패했어요"); + } + }; + + /* ═══════════════════ 불량관리 CRUD ═══════════════════ */ + const openDefCreate = async () => { + setDefForm({}); + setDefEditMode(false); + setNumberingRuleId(null); + setPreviewCode(null); + setDefModalOpen(true); + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const prev = await previewNumberingCode(ruleId); + if (prev.success && prev.data?.generatedCode) { + setPreviewCode(prev.data.generatedCode); + } + } + } catch { /* 채번 규칙 없으면 무시 */ } + }; + const openDefEdit = (row: any) => { + setDefForm({ ...row }); + setDefEditMode(true); + setDefModalOpen(true); + }; + const saveDefect = async () => { + if (!numberingRuleId && !defForm.defect_code) { + toast.error("불량코드는 필수예요"); + return; + } + if (!defForm.defect_type) { + toast.error("불량유형은 필수예요"); + return; + } + if (!defForm.defect_name) { + toast.error("불량명은 필수예요"); + return; + } + if (!defForm.severity) { + toast.error("심각도는 필수예요"); + return; + } + if (!defForm.defect_content) { + toast.error("불량내용은 필수예요"); + return; + } + if (!defForm.inspection_type) { + toast.error("검사유형을 1개 이상 선택해주세요"); + return; + } + setDefSaving(true); + try { + let finalCode = defForm.defect_code || ""; + if (!defEditMode && numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + finalCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + setDefSaving(false); + return; + } + } + if (defEditMode) { + await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, { + originalData: { id: defForm.id }, + updatedData: defForm, + }); + toast.success("불량유형을 수정했어요"); + } else { + await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { id: crypto.randomUUID(), ...defForm, defect_code: finalCode }); + toast.success("불량유형을 등록했어요"); + } + setDefModalOpen(false); + fetchDefects(); + } catch { + toast.error("저장에 실패했어요"); + } finally { + setDefSaving(false); + } + }; + const deleteDefects = async () => { + if (defChecked.length === 0) { + toast.error("삭제할 항목을 선택해주세요"); + return; + } + const ok = await confirm("불량유형 삭제", { + description: `선택한 ${defChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${DEFECT_TABLE}/delete`, { + data: defChecked.map((id) => ({ id })), + }); + toast.success(`${defChecked.length}건을 삭제했어요`); + setDefChecked([]); + fetchDefects(); + } catch { + toast.error("삭제에 실패했어요"); + } + }; + + /* ═══════════════════ 검사장비 CRUD ═══════════════════ */ + const openEqCreate = async () => { + setEqForm({ + calibration_period: "12", + equipment_status: "NORMAL", + }); + setEqEditMode(false); + setNumberingRuleId(null); + setPreviewCode(null); + setEqModalOpen(true); + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const prev = await previewNumberingCode(ruleId); + if (prev.success && prev.data?.generatedCode) { + setPreviewCode(prev.data.generatedCode); + } + } else { + // 채번 규칙 없으면 기존 수동 채번 fallback + const maxNum = + equipments + .map((e: any) => e.equipment_code || "") + .filter((c: string) => /^EQP-\d+$/.test(c)) + .map((c: string) => parseInt(c.replace("EQP-", ""), 10)) + .sort((a: number, b: number) => b - a)[0] || 0; + setEqForm((p) => ({ ...p, equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}` })); + } + } catch { + // 채번 규칙 조회 실패 시 기존 수동 채번 fallback + const maxNum = + equipments + .map((e: any) => e.equipment_code || "") + .filter((c: string) => /^EQP-\d+$/.test(c)) + .map((c: string) => parseInt(c.replace("EQP-", ""), 10)) + .sort((a: number, b: number) => b - a)[0] || 0; + setEqForm((p) => ({ ...p, equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}` })); + } + }; + const openEqEdit = (row: any) => { + setEqForm({ ...row }); + setEqEditMode(true); + setEqModalOpen(true); + }; + const saveEquipment = async () => { + if (!numberingRuleId && !eqForm.equipment_code) { + toast.error("장비코드는 필수예요"); + return; + } + if (!eqForm.equipment_name) { + toast.error("장비명은 필수예요"); + return; + } + if (!eqForm.equipment_type) { + toast.error("장비유형은 필수예요"); + return; + } + setEqSaving(true); + try { + let finalCode = eqForm.equipment_code || ""; + if (!eqEditMode && numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + finalCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + setEqSaving(false); + return; + } + } + if (eqEditMode) { + await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, { + originalData: { id: eqForm.id }, + updatedData: eqForm, + }); + toast.success("검사장비를 수정했어요"); + } else { + await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { id: crypto.randomUUID(), ...eqForm, equipment_code: finalCode }); + toast.success("검사장비를 등록했어요"); + } + setEqModalOpen(false); + fetchEquipments(); + } catch { + toast.error("저장에 실패했어요"); + } finally { + setEqSaving(false); + } + }; + const deleteEquipments = async () => { + if (eqChecked.length === 0) { + toast.error("삭제할 항목을 선택해주세요"); + return; + } + const ok = await confirm("검사장비 삭제", { + description: `선택한 ${eqChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${EQUIPMENT_TABLE}/delete`, { + data: eqChecked.map((id) => ({ id })), + }); + toast.success(`${eqChecked.length}건을 삭제했어요`); + setEqChecked([]); + fetchEquipments(); + } catch { + toast.error("삭제에 실패했어요"); + } + }; + + /* ═══════════════════ JSX ═══════════════════ */ + return ( +
+ {ConfirmDialogComponent} + +
+ +
+ + + + 검사기준 + + {inspCount} + + + + + 불량관리 + + {defCount} + + + + + 검사장비 + + {eqCount} + + + +
+ + {/* ──── 검사기준 탭 ──── */} + +
+ + + + + +
+ } + /> +
+
+ openInspEdit(row)} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-inspection-main" + /> +
+ + + {/* ──── 불량관리 탭 ──── */} + +
+
+
+ + setDefKeyword(e.target.value)} + /> +
+ + {filteredDefects.length}건 + +
+
+ + + +
+
+
+ + + + + 0 && defChecked.length === filteredDefects.length} + onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map((r) => r.id) : [])} + /> + + + 불량코드 + + + 불량유형 + + + 불량명 + + + 불량내용 + + + 심각도 + + + 검사유형 + + + 적용대상 + + + 사용여부 + + + 등록일 + + + 관리자 + + + 비고 + + + + + {defLoading ? ( + + + + + + ) : filteredDefects.length === 0 ? ( + + + +

등록된 불량유형이 없어요

+
+
+ ) : ( + filteredDefects.map((row) => { + const severityLabel = getCatLabel(DEFECT_TABLE, "severity", row.severity); + const severityColor = + severityLabel === "치명적" + ? "destructive" + : severityLabel === "심각" + ? "destructive" + : severityLabel === "보통" + ? "secondary" + : "outline"; + return ( + + setDefChecked((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id], + ) + } + onDoubleClick={() => openDefEdit(row)} + > + e.stopPropagation()}> + + setDefChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id))) + } + /> + + {row.defect_code || "-"} + + + {getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)} + + + {row.defect_name || "-"} + + {row.defect_content || "-"} + + + + {severityLabel || "-"} + + + +
+ {row.inspection_type + ? row.inspection_type + .split(",") + .filter(Boolean) + .map((c: string) => ( + + {getCatLabel(DEFECT_TABLE, "inspection_type", c)} + + )) + : "-"} +
+
+ +
+ {row.apply_target + ? row.apply_target + .split(",") + .filter(Boolean) + .map((t: string) => ( + + {t} + + )) + : "-"} +
+
+ + + {getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"} + + + + {row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")} + + {row.manager_id || "-"} + {row.remarks || "-"} +
+ ); + }) + )} +
+
+
+
+ + {/* ──── 검사장비 탭 ──── */} + +
+
+
+ + setEqKeyword(e.target.value)} + /> +
+ + {filteredEquipments.length}건 + +
+
+ + + +
+
+
+ + + + + 0 && eqChecked.length === filteredEquipments.length} + onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])} + /> + + + 장비코드 + + + 장비명 + + + 장비유형 + + + 모델명 + + + 제조사 + + + 설치장소 + + + 최근교정일 + + + 교정주기(개월) + + + 장비상태 + + + 담당자 + + + + + {eqLoading ? ( + + + + + + ) : filteredEquipments.length === 0 ? ( + + + +

등록된 검사장비가 없어요

+
+
+ ) : ( + filteredEquipments.map((row) => { + const statusLabel = getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status); + const statusColor = + statusLabel === "정상" ? "default" : statusLabel === "폐기" ? "destructive" : "secondary"; + return ( + + setEqChecked((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id], + ) + } + onDoubleClick={() => openEqEdit(row)} + > + e.stopPropagation()}> + + setEqChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id))) + } + /> + + {row.equipment_code || "-"} + {row.equipment_name || "-"} + + + {getCatLabel(EQUIPMENT_TABLE, "equipment_type", row.equipment_type) || "-"} + + + {row.model_name || "-"} + {row.manufacturer || "-"} + {row.installation_location || "-"} + {row.last_calibration_date || "-"} + {row.calibration_period ? `${row.calibration_period}개월` : "-"} + + + {statusLabel || "-"} + + + + {userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"} + + + ); + }) + )} +
+
+
+
+ +
+ + {/* ═══════════════════ 검사기준 모달 ═══════════════════ */} + + + + {inspEditMode ? "검사기준 수정" : "검사기준 등록"} + 검사기준 정보를 입력해주세요 + +
+ {/* 검사코드 */} +
+ + {!inspEditMode && numberingRuleId ? ( + + ) : inspEditMode ? ( + + ) : ( + setInspForm((p) => ({ ...p, inspection_code: e.target.value }))} + placeholder="검사코드 입력" + /> + )} +
+ {/* 유형 (다중선택) */} +
+ +
+ {(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map((o) => { + const types: string[] = inspForm.inspection_type + ? inspForm.inspection_type.split(",").filter(Boolean) + : []; + const checked = types.includes(o.code); + return ( +
+ { + const next = v ? [...types, o.code] : types.filter((t) => t !== o.code); + setInspForm((p) => ({ ...p, inspection_type: next.join(",") })); + }} + /> + +
+ ); + })} +
+
+ {/* 검사기준 */} +
+ + setInspForm((p) => ({ ...p, inspection_criteria: e.target.value }))} + placeholder="검사기준 입력" + /> +
+ {/* 기준상세 */} +
+ + setInspForm((p) => ({ ...p, criteria_detail: e.target.value }))} + placeholder="기준상세 입력" + /> +
+ {/* 검사항목 */} +
+ + setInspForm((p) => ({ ...p, inspection_item: e.target.value }))} + placeholder="검사항목 입력" + /> +
+ {/* 검사방법 */} +
+ + +
+ {/* 판단기준 */} +
+ + +
+ {/* 단위 */} +
+ + +
+ {/* 적용구분 */} +
+ + +
+ {/* 관리자 */} +
+ + +
+ {/* 비고 */} +
+ +