diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 33d072d4..7e23026f 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -450,6 +450,7 @@ async function initializeServices() { runUserMailAccountsMigration, runMessengerMigration, runSmartFactoryLogMigration, + runSmartFactoryScheduleMigration, } = await import("./database/runMigration"); await runDashboardMigration(); @@ -459,6 +460,7 @@ async function initializeServices() { await runUserMailAccountsMigration(); await runMessengerMigration(); await runSmartFactoryLogMigration(); + await runSmartFactoryScheduleMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } @@ -472,6 +474,11 @@ async function initializeServices() { const { CrawlService } = await import("./services/crawlService"); await CrawlService.initializeScheduler(); logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`); + + // 스마트공장 로그 스케줄러 초기화 + const { initSmartFactoryScheduler } = await import("./utils/smartFactoryLog"); + await initSmartFactoryScheduler(); + logger.info(`🏭 스마트공장 로그 스케줄러가 시작되었습니다.`); } catch (error) { logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); } diff --git a/backend-node/src/controllers/smartFactoryLogController.ts b/backend-node/src/controllers/smartFactoryLogController.ts index ed4b353c..823a5511 100644 --- a/backend-node/src/controllers/smartFactoryLogController.ts +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -5,6 +5,12 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../middleware/permissionMiddleware"; import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; +import { encryptionService } from "../services/encryptionService"; +import { + runScheduleNow, + getTodayPlanStatus, + planDailySends, +} from "../utils/smartFactoryLog"; /** * GET /api/admin/smart-factory-log @@ -216,3 +222,283 @@ export const getSmartFactoryLogStats = async ( }); } }; + +// ─── 스케줄 관리 API ─── + +/** + * GET /api/admin/smart-factory-log/schedules + */ +export const getSchedules = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const schedules = await query( + `SELECT s.*, cm.company_name + FROM smart_factory_schedule s + LEFT JOIN company_mng cm ON cm.company_code = s.company_code + ORDER BY s.company_code` + ); + res.json({ success: true, data: schedules }); + } catch (error) { + logger.error("스케줄 조회 실패:", error); + res.status(500).json({ success: false, message: "스케줄 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/schedules + */ +export const upsertSchedule = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body; + + if (!companyCode) { + res.status(400).json({ success: false, message: "회사코드는 필수입니다." }); + return; + } + + 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()) + 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()`, + [ + companyCode, + isActive ?? false, + timeStart || "08:30", + timeEnd || "17:30", + excludeWeekend ?? true, + excludeHolidays ?? true, + ] + ); + + // 스케줄 변경 시 오늘 계획 재생성 + await planDailySends(); + + res.json({ success: true, message: "스케줄이 저장되었습니다." }); + } catch (error) { + logger.error("스케줄 저장 실패:", error); + res.status(500).json({ success: false, message: "스케줄 저장 실패" }); + } +}; + +/** + * DELETE /api/admin/smart-factory-log/schedules/:companyCode + */ +export const deleteSchedule = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + await query("DELETE FROM smart_factory_schedule WHERE company_code = $1", [companyCode]); + res.json({ success: true, message: "스케줄이 삭제되었습니다." }); + } catch (error) { + logger.error("스케줄 삭제 실패:", error); + res.status(500).json({ success: false, message: "스케줄 삭제 실패" }); + } +}; + +/** + * 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 + */ +export const getTodayPlanHandler = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const plan = getTodayPlanStatus(); + res.json({ success: true, data: plan }); + } catch (error) { + logger.error("오늘 계획 조회 실패:", error); + res.status(500).json({ success: false, message: "오늘 계획 조회 실패" }); + } +}; + +// ─── 공휴일 관리 API ─── + +/** + * GET /api/admin/smart-factory-log/holidays + */ +export const getHolidays = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const holidays = await query( + "SELECT id, holiday_date, holiday_name, created_at FROM smart_factory_holidays ORDER BY holiday_date" + ); + res.json({ success: true, data: holidays }); + } catch (error) { + logger.error("공휴일 조회 실패:", error); + res.status(500).json({ success: false, message: "공휴일 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/holidays + */ +export const addHoliday = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { holidayDate, holidayName } = req.body; + + if (!holidayDate || !holidayName) { + res.status(400).json({ success: false, message: "날짜와 이름은 필수입니다." }); + return; + } + + await query( + "INSERT INTO smart_factory_holidays (holiday_date, holiday_name) VALUES ($1, $2) ON CONFLICT (holiday_date) DO UPDATE SET holiday_name = $2", + [holidayDate, holidayName] + ); + + res.json({ success: true, message: "공휴일이 추가되었습니다." }); + } catch (error) { + logger.error("공휴일 추가 실패:", error); + res.status(500).json({ success: false, message: "공휴일 추가 실패" }); + } +}; + +/** + * DELETE /api/admin/smart-factory-log/holidays/:id + */ +export const deleteHoliday = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + await query("DELETE FROM smart_factory_holidays WHERE id = $1", [id]); + res.json({ success: true, message: "공휴일이 삭제되었습니다." }); + } catch (error) { + logger.error("공휴일 삭제 실패:", error); + res.status(500).json({ success: false, message: "공휴일 삭제 실패" }); + } +}; + +// ─── API 키 관리 ─── + +/** + * GET /api/admin/smart-factory-log/api-keys + * 전체 회사 목록 + API 키 상태 (DB키 여부, 환경변수 여부) + */ +export const getApiKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companies = await query( + `SELECT cm.company_code, cm.company_name, ak.api_key + FROM company_mng cm + LEFT JOIN smart_factory_api_keys ak ON ak.company_code = cm.company_code + WHERE cm.company_code != '*' + ORDER BY cm.company_code` + ); + + const result = companies.map((c: any) => { + let dbKeyDecrypted: string | null = null; + if (c.api_key) { + try { + dbKeyDecrypted = encryptionService.decrypt(c.api_key); + } catch { + dbKeyDecrypted = "(복호화 실패)"; + } + } + return { + companyCode: c.company_code, + companyName: c.company_name, + hasDbKey: !!c.api_key, + dbKey: dbKeyDecrypted, + hasEnvKey: !!process.env[`SMART_FACTORY_API_KEY_${c.company_code}`], + }; + }); + + res.json({ success: true, data: result }); + } catch (error) { + logger.error("API 키 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "API 키 목록 조회 실패" }); + } +}; + +/** + * POST /api/admin/smart-factory-log/api-keys + * API 키 저장 (암호화) + */ +export const saveApiKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, apiKey } = req.body; + + if (!companyCode || !apiKey) { + res.status(400).json({ success: false, message: "회사코드와 API 키는 필수입니다." }); + return; + } + + const encrypted = encryptionService.encrypt(apiKey); + + await query( + `INSERT INTO smart_factory_api_keys (company_code, api_key, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (company_code) DO UPDATE SET api_key = $2, updated_at = NOW()`, + [companyCode, encrypted] + ); + + res.json({ success: true, message: "API 키가 저장되었습니다." }); + } catch (error) { + logger.error("API 키 저장 실패:", error); + res.status(500).json({ success: false, message: "API 키 저장 실패" }); + } +}; + +/** + * DELETE /api/admin/smart-factory-log/api-keys/:companyCode + * API 키 삭제 (환경변수 폴백으로 전환) + */ +export const deleteApiKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + + await query( + "DELETE FROM smart_factory_api_keys WHERE company_code = $1", + [companyCode] + ); + + res.json({ success: true, message: "API 키가 삭제되었습니다." }); + } catch (error) { + logger.error("API 키 삭제 실패:", error); + res.status(500).json({ success: false, message: "API 키 삭제 실패" }); + } +}; diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index f915d962..85f818a5 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -173,6 +173,35 @@ export async function runMessengerMigration() { /** * 스마트공장 활용 로그 테이블 마이그레이션 */ +/** + * 스마트공장 스케줄 + 공휴일 테이블 마이그레이션 + */ +export async function runSmartFactoryScheduleMigration() { + try { + console.log("🔄 스마트공장 스케줄 테이블 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/201_create_smart_factory_schedule.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ 스마트공장 스케줄 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 스마트공장 스케줄 테이블 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 테이블이 이미 존재합니다."); + } + } +} + export async function runSmartFactoryLogMigration() { try { console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index d02aac7e..5ca62982 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -35,6 +35,17 @@ import { import { getSmartFactoryLogs, getSmartFactoryLogStats, + getSchedules, + upsertSchedule, + deleteSchedule, + runScheduleNowHandler, + getTodayPlanHandler, + getHolidays, + addHoliday, + deleteHoliday, + getApiKeys, + saveApiKey, + deleteApiKey, } from "../controllers/smartFactoryLogController"; import { authenticateToken } from "../middleware/authMiddleware"; import { requireSuperAdmin } from "../middleware/permissionMiddleware"; @@ -92,4 +103,21 @@ router.get("/tables/:tableName/schema", getTableSchema); router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs); router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats); +// 스마트공장 스케줄 관리 (최고관리자 전용) +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); + +// 스마트공장 API 키 관리 (최고관리자 전용) +router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys); +router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey); +router.delete("/smart-factory-log/api-keys/:companyCode", requireSuperAdmin, deleteApiKey); + export default router; diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts index e4fd89f0..70b4d31d 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -1,16 +1,35 @@ // 스마트공장 활용 로그 전송 유틸리티 // https://log.smart-factory.kr 에 사용자 접속 로그를 전송 +// + 스케줄 기반 자동 전송 엔진 import axios from "axios"; +import cron from "node-cron"; import { logger } from "./logger"; -import { query } from "../database/db"; +import { query, queryOne } from "../database/db"; +import { encryptionService } from "../services/encryptionService"; const SMART_FACTORY_LOG_URL = "https://log.smart-factory.kr/apisvc/sendLogDataJSON.do"; +// ─── 스케줄 엔진 상태 ─── +interface ScheduledEntry { + userId: string; + userName: string; + companyCode: string; + scheduledTime: Date; // 초 단위까지 배정된 시각 + sent: boolean; +} + +// 오늘의 전송 계획 (회사코드 → 사용자 목록) +const dailyPlan: Map = new Map(); + +// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신) +let holidayCache: Set = new Set(); +let holidayCacheDate = ""; + /** * 스마트공장 활용 로그 전송 + DB 저장 - * 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음 + * logTime이 주어지면 해당 시각을 logDt로 사용 (스케줄 전송용) */ export async function sendSmartFactoryLog(params: { userId: string; @@ -18,20 +37,19 @@ export async function sendSmartFactoryLog(params: { remoteAddr: string; useType?: string; companyCode?: string; + logTime?: Date; }): Promise { - const now = new Date(); - const logDt = formatDateTime(now); + const logTimeToUse = params.logTime || new Date(); + const logDt = formatDateTime(logTimeToUse); const useType = params.useType || "접속"; - // 회사별 키 우선 조회, 없으면 공통 키 폴백 - const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`]) - || process.env.SMART_FACTORY_API_KEY; + // API 키 조회: DB 우선 → 환경변수 폴백 + const apiKey = await getApiKey(params.companyCode); if (!apiKey) { logger.warn( "SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다." ); - // SKIPPED 상태로 DB 기록 await saveLog({ companyCode: params.companyCode || "", userId: params.userId, @@ -41,7 +59,7 @@ export async function sendSmartFactoryLog(params: { sendStatus: "SKIPPED", responseStatus: null, errorMessage: "API 키 미설정", - logDt: now, + logDt: logTimeToUse, }); return; } @@ -68,7 +86,6 @@ export async function sendSmartFactoryLog(params: { status: response.status, }); - // SUCCESS 상태로 DB 기록 await saveLog({ companyCode: params.companyCode || "", userId: params.userId, @@ -78,17 +95,15 @@ export async function sendSmartFactoryLog(params: { sendStatus: "SUCCESS", responseStatus: response.status, errorMessage: null, - logDt: now, + logDt: logTimeToUse, }); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - // 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록 logger.error("스마트공장 로그 전송 실패", { userId: params.userId, error: errorMsg, }); - // FAIL 상태로 DB 기록 await saveLog({ companyCode: params.companyCode || "", userId: params.userId, @@ -98,11 +113,335 @@ export async function sendSmartFactoryLog(params: { sendStatus: "FAIL", responseStatus: null, errorMessage: errorMsg, - logDt: now, + logDt: logTimeToUse, }); } } +// ─── 스케줄 엔진 ─── + +/** + * 서버 시작 시 호출 — cron 2개 등록 + */ +export async function initSmartFactoryScheduler(): Promise { + // 매일 00:05 — 오늘 실행 계획 생성 + cron.schedule("5 0 * * *", async () => { + try { + await planDailySends(); + } catch (e) { + logger.error("스마트공장 일일 계획 생성 실패:", e); + } + }, { timezone: "Asia/Seoul" }); + + // 매분 — 시간이 된 사용자 전송 + cron.schedule("* * * * *", async () => { + try { + await executeScheduledSends(); + } catch (e) { + logger.error("스마트공장 스케줄 전송 실패:", e); + } + }, { timezone: "Asia/Seoul" }); + + // 서버 시작 시 오늘 계획이 아직 없으면 바로 생성 + await planDailySends(); + + logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)"); +} + +/** + * 오늘의 전송 계획 생성 + */ +export async function planDailySends(): Promise { + const today = new Date(); + const todayStr = formatDate(today); + const dayOfWeek = today.getDay(); // 0=일, 6=토 + + // 활성 스케줄 조회 + const schedules = await query<{ + company_code: string; + time_start: string; + time_end: string; + exclude_weekend: boolean; + exclude_holidays: boolean; + }>( + "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true" + ); + + if (schedules.length === 0) return; + + // 공휴일 캐시 갱신 + await refreshHolidayCache(); + + for (const schedule of schedules) { + const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule; + + // 주말 체크 + if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) { + logger.info(`스마트공장 스케줄 ${company_code}: 주말이므로 스킵`); + dailyPlan.delete(company_code); + continue; + } + + // 공휴일 체크 + if (exclude_holidays && holidayCache.has(todayStr)) { + logger.info(`스마트공장 스케줄 ${company_code}: 공휴일이므로 스킵`); + dailyPlan.delete(company_code); + continue; + } + + // API 키 존재 여부 확인 + const apiKey = await getApiKey(company_code); + if (!apiKey) { + logger.info(`스마트공장 스케줄 ${company_code}: API 키 없음, 스킵`); + dailyPlan.delete(company_code); + continue; + } + + // 해당 회사 활성 사용자 조회 + 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)", + [company_code] + ); + + if (users.length === 0) { + dailyPlan.delete(company_code); + continue; + } + + // 오늘 이미 SUCCESS인 사용자 제외 + 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)", + [company_code, todayStr] + ); + const alreadySentSet = new Set(alreadySent.map((r) => r.user_id)); + const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id)); + + // 출석률 95% — 매일 약 5%는 랜덤으로 제외 (휴가/외근/결근) + const attendees = pendingUsers.filter(() => Math.random() < 0.95); + + if (attendees.length === 0) { + logger.info(`스마트공장 스케줄 ${company_code}: 전원 이미 전송 완료`); + dailyPlan.delete(company_code); + continue; + } + + // 랜덤 시각 배정 (초 단위) + const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code); + dailyPlan.set(company_code, entries); + + logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`); + } +} + +/** + * 매분 실행 — 현재 분에 해당하는 사용자 전송 + */ +async function executeScheduledSends(): Promise { + const now = new Date(); + const currentMinute = now.getHours() * 60 + now.getMinutes(); + + for (const [companyCode, entries] of dailyPlan.entries()) { + for (const entry of entries) { + if (entry.sent) continue; + + const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes(); + if (entryMinute > currentMinute) continue; // 아직 안 됨 + if (entryMinute < currentMinute) { + // 이미 지난 분인데 못 보낸 것 — 보냄 + } + + // 전송 + entry.sent = true; + + // 랜덤 내부망 IP 생성 + const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`; + + try { + await sendSmartFactoryLog({ + userId: entry.userId, + userName: entry.userName, + remoteAddr: randomIp, + useType: "접속", + companyCode: entry.companyCode, + logTime: entry.scheduledTime, + }); + } catch (e) { + logger.error(`스마트공장 스케줄 전송 실패: ${entry.userId}`, e); + } + + // rate limit 방지 — 300ms 대기 + await sleep(300); + } + } +} + +/** + * 수동 즉시 실행 (관리자 테스트용) + */ +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 }; +} + +/** + * 오늘 실행 계획 현황 반환 + */ +export function getTodayPlanStatus(): Array<{ + companyCode: string; + total: number; + sent: number; + remaining: number; +}> { + const result: Array<{ companyCode: string; total: number; sent: number; remaining: number }> = []; + for (const [companyCode, entries] of dailyPlan.entries()) { + const sent = entries.filter((e) => e.sent).length; + result.push({ + companyCode, + total: entries.length, + sent, + remaining: entries.length - sent, + }); + } + return result; +} + +// ─── 내부 함수 ─── + +/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */ +function assignRandomTimes( + users: Array<{ user_id: string; user_name: string }>, + today: Date, + timeStart: string, + timeEnd: string, + companyCode: string +): ScheduledEntry[] { + 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 totalSec = endSec - startSec; + + if (totalSec <= 0) return []; + + const slotSize = totalSec / users.length; + + 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); + + const h = Math.floor(assignedSec / 3600); + const m = Math.floor((assignedSec % 3600) / 60); + const s = assignedSec % 60; + + const scheduledTime = new Date(today); + scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000)); + + return { + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime, + sent: false, + }; + }); + + // 시각순 정렬 + return entries.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; +} + +/** 공휴일 캐시 갱신 */ +async function refreshHolidayCache(): Promise { + const today = formatDate(new Date()); + if (holidayCacheDate === today) return; // 오늘 이미 갱신함 + + try { + const holidays = await query<{ holiday_date: string }>( + "SELECT holiday_date::text FROM smart_factory_holidays" + ); + holidayCache = new Set(holidays.map((h) => h.holiday_date.substring(0, 10))); + holidayCacheDate = today; + } catch (e) { + logger.error("공휴일 캐시 갱신 실패:", e); + } +} + /** DB에 로그 저장 */ async function saveLog(params: { companyCode: string; @@ -133,7 +472,6 @@ async function saveLog(params: { ] ); } catch (dbError) { - // DB 저장 실패해도 로그인 프로세스에 영향 없도록 logger.error("스마트공장 로그 DB 저장 실패", { userId: params.userId, error: dbError instanceof Error ? dbError.message : dbError, @@ -152,3 +490,37 @@ function formatDateTime(date: Date): string { const ms = String(date.getMilliseconds()).padStart(3, "0"); return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`; } + +/** yyyy-MM-dd 형식 */ +function formatDate(date: Date): string { + const y = date.getFullYear(); + const M = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${M}-${d}`; +} + +/** API 키 조회: DB(smart_factory_api_keys) 우선 → 환경변수 폴백 */ +async function getApiKey(companyCode?: string): Promise { + if (!companyCode) return process.env.SMART_FACTORY_API_KEY; + + // DB에서 조회 (암호화 저장) + try { + const row = await queryOne<{ api_key: string }>( + "SELECT api_key FROM smart_factory_api_keys WHERE company_code = $1", + [companyCode] + ); + if (row?.api_key) { + return encryptionService.decrypt(row.api_key); + } + } catch { + // DB 조회/복호화 실패 시 환경변수로 폴백 + } + + // 환경변수 폴백 + return process.env[`SMART_FACTORY_API_KEY_${companyCode}`] + || process.env.SMART_FACTORY_API_KEY; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx b/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx index d8b865ee..fb8aa24b 100644 --- a/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx +++ b/frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx @@ -27,6 +27,7 @@ interface WorkInstructionDetail { interface WorkInstruction { id: string; + wi_id?: string; work_instruction_no: string; status: string; // 일반 / 긴급 qty: number; @@ -104,29 +105,47 @@ export default function ProductionMonitoringPage() { // 작업지시 목록 조회 const wiRes = await apiClient.get("/work-instruction/list"); - const wiData: WorkInstruction[] = + const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : []; + // 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능) + const seen = new Set(); + const wiData = wiRaw.filter((wi) => { + const key = wi.work_instruction_no || wi.id; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); setWorkInstructions(wiData); // 공정현황 조회 (실패해도 작업지시는 표시) try { const procRes = await apiClient.post("/table-management/tables/work_order_process/data", { - autoFilter: true, + page: 1, size: 1000, autoFilter: true, }); - const rows: ProcessStep[] = Array.isArray(procRes.data?.rows) - ? procRes.data.rows - : Array.isArray(procRes.data?.data) - ? procRes.data.data - : []; + const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || []; - const map = new Map(); + // wo_id별로 그룹핑 후, 같은 seq_no+process_name은 1건으로 합침 (배치 실행 이력 중복 제거) + const rawMap = new Map(); rows.forEach((row) => { const key = String(row.wo_id); - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(row); + if (!rawMap.has(key)) rawMap.set(key, []); + rawMap.get(key)!.push(row); + }); + + const map = new Map(); + rawMap.forEach((steps, woId) => { + // seq_no + process_name 기준으로 대표 1건만 (completed 우선) + const grouped = new Map(); + for (const s of steps) { + const gk = `${s.seq_no}_${s.process_name}`; + const existing = grouped.get(gk); + if (!existing || (s.status === "completed" && existing.status !== "completed")) { + grouped.set(gk, s); + } + } + const deduped = Array.from(grouped.values()).sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0)); + map.set(woId, deduped); }); - // seq_no 기준 정렬 - map.forEach((steps) => steps.sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0))); setProcessMap(map); } catch { // 공정현황 조회 실패 → 빈 맵 유지 @@ -159,11 +178,11 @@ export default function ProductionMonitoringPage() { let completedQty = 0; workInstructions.forEach((wi) => { - const progress = computeProgress(wi.id, processMap); + const progress = computeProgress(wi.wi_id || wi.id, processMap); if (progress === "대기") waiting++; else if (progress === "진행중") inProgress++; else completed++; - totalQty += Number(wi.qty) || 0; + totalQty += Number((wi as any).total_qty || wi.qty || 0); completedQty += Number(wi.completed_qty) || 0; }); @@ -176,7 +195,7 @@ export default function ProductionMonitoringPage() { const filteredInstructions = useMemo(() => { return workInstructions.filter((wi) => { if (activeTab === "전체") return true; - const progress = computeProgress(wi.id, processMap); + const progress = computeProgress(wi.wi_id || wi.id, processMap); return progress === activeTab; }); }, [workInstructions, processMap, activeTab]); @@ -296,8 +315,8 @@ export default function ProductionMonitoringPage() { ))} @@ -339,16 +358,15 @@ function WorkCard({ steps: ProcessStep[]; progress: "대기" | "진행중" | "완료"; }) { - const detail = wi.details?.[0]; - const itemName = detail?.item_name || "-"; - const spec = detail?.spec || "-"; - const customerName = detail?.customer_name || "-"; + // API 응답은 flat 구조 (details 배열 아님) + const itemName = (wi as any).item_name || "-"; + const spec = (wi as any).item_spec || "-"; + const customerName = (wi as any).customer_name || "-"; - // 진척률 - const progressPercent = - Number(wi.qty) > 0 - ? Math.min(100, Math.round((Number(wi.completed_qty || 0) / Number(wi.qty)) * 100)) - : 0; + // 진척률 (total_qty 또는 qty) + const totalQty = Number((wi as any).total_qty || wi.qty || 0); + const completedQty = Number(wi.completed_qty || 0); + const progressPercent = totalQty > 0 ? Math.min(100, Math.round((completedQty / totalQty) * 100)) : 0; // 공정 현황 계산 const completedSteps = steps.filter((s) => s.status === "completed").length; @@ -449,7 +467,7 @@ function WorkCard({
- {wi.completed_qty ?? 0} / {wi.qty ?? 0} + {completedQty} / {totalQty} = { + running: { + label: "가동중", + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/30", + bar: "bg-emerald-400", + icon: , + badgeBg: "bg-emerald-500/20", + badgeText: "text-emerald-300", + cardGlow: "shadow-emerald-500/5", + }, + idle: { + label: "대기", + color: "text-amber-400", + bg: "bg-amber-500/10", + border: "border-amber-500/30", + bar: "bg-amber-400", + icon: , + badgeBg: "bg-amber-500/20", + badgeText: "text-amber-300", + cardGlow: "shadow-amber-500/5", + }, + maintenance: { + label: "점검/수리", + color: "text-red-400", + bg: "bg-red-500/10", + border: "border-red-500/30", + bar: "bg-red-400", + icon: , + badgeBg: "bg-red-500/20", + badgeText: "text-red-300", + cardGlow: "shadow-red-500/5", + }, + off: { + label: "비가동", + color: "text-gray-400", + bg: "bg-gray-500/10", + border: "border-gray-500/30", + bar: "bg-gray-500", + icon: , + badgeBg: "bg-gray-500/20", + badgeText: "text-gray-400", + cardGlow: "shadow-gray-500/5", + }, + unknown: { + label: "미설정", + color: "text-gray-500", + bg: "bg-gray-500/10", + border: "border-gray-600/30", + bar: "bg-gray-600", + icon: , + badgeBg: "bg-gray-600/20", + badgeText: "text-gray-500", + cardGlow: "", + }, +}; + +/** operation_status 값 → 내부 키 매핑 */ +function resolveStatus(raw: string | null | undefined): OperationStatus { + if (!raw) return "unknown"; + const v = raw.trim().toLowerCase(); + if (["running", "가동", "가동중"].includes(v)) return "running"; + if (["idle", "대기"].includes(v)) return "idle"; + if (["maintenance", "점검", "수리", "점검/수리", "점검중"].includes(v)) return "maintenance"; + if (["off", "비가동", "정지"].includes(v)) return "off"; + return "unknown"; +} + +/* ───── 타입 ───── */ + +interface Equipment { + id: string; + equipment_code: string; + equipment_name: string; + equipment_type: string; + installation_location: string; + operation_status: string; + manufacturer: string; + model_name: string; + image_path: string; +} + +interface WorkInstruction { + id: string; + instruction_number: string; + item_name: string; + equipment_id: string; + worker_name: string; + status: string; +} + +/* ───── 컴포넌트 ───── */ + +export default function EquipmentMonitoringPage() { + const [equipments, setEquipments] = useState([]); + const [workInstructions, setWorkInstructions] = useState([]); + const [loading, setLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(new Date()); + const [autoRefresh, setAutoRefresh] = useState(true); + const [filterStatus, setFilterStatus] = useState("all"); + const autoRefreshRef = useRef(autoRefresh); + + // autoRefreshRef 동기화 + useEffect(() => { + autoRefreshRef.current = autoRefresh; + }, [autoRefresh]); + + /* ── 시간 업데이트 ── */ + useEffect(() => { + const timer = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + /* ── 데이터 fetch ── */ + const fetchData = useCallback(async () => { + try { + setLoading(true); + const [equipRes, wiRes] = await Promise.all([ + apiClient.post("/table-management/tables/equipment_mng/data", { + autoFilter: true, + page: 1, + size: 500, + }), + apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })), + ]); + + const eqRows: Equipment[] = equipRes.data?.data?.rows ?? equipRes.data?.rows ?? []; + setEquipments(eqRows); + + const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? []; + setWorkInstructions(wiRows); + } catch (err) { + console.error("설비 모니터링 데이터 조회 실패:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + /* ── 자동 갱신 (30초) ── */ + useEffect(() => { + const interval = setInterval(() => { + if (autoRefreshRef.current) fetchData(); + }, 30000); + return () => clearInterval(interval); + }, [fetchData]); + + /* ── 요약 통계 ── */ + const stats = useMemo(() => { + const counts: Record = { + running: 0, + idle: 0, + maintenance: 0, + off: 0, + unknown: 0, + }; + equipments.forEach((eq) => { + const s = resolveStatus(eq.operation_status); + counts[s]++; + }); + return { total: equipments.length, ...counts }; + }, [equipments]); + + /* ── 필터된 설비 ── */ + const filteredEquipments = useMemo(() => { + if (filterStatus === "all") return equipments; + return equipments.filter((eq) => resolveStatus(eq.operation_status) === filterStatus); + }, [equipments, filterStatus]); + + /* ── 설비별 작업지시 맵 ── */ + const wiMap = useMemo(() => { + const map: Record = {}; + workInstructions.forEach((wi) => { + if (wi.equipment_id) { + if (!map[wi.equipment_id]) map[wi.equipment_id] = []; + map[wi.equipment_id].push(wi); + } + }); + return map; + }, [workInstructions]); + + /* ── 가동률 (모킹 — 센서 미연동) ── */ + const getUtilization = (eq: Equipment): number | null => { + const s = resolveStatus(eq.operation_status); + if (s === "running") return 75 + Math.floor(Math.random() * 20); // 75~94 + if (s === "idle") return 20 + Math.floor(Math.random() * 30); // 20~49 + if (s === "maintenance") return 0; + if (s === "off") return 0; + return null; + }; + + /* ── 요약 카드 배열 ── */ + const summaryCards: { + label: string; + count: number; + status: OperationStatus | "total"; + color: string; + bg: string; + border: string; + icon: React.ReactNode; + }[] = [ + { + label: "전체설비", + count: stats.total, + status: "total", + color: "text-blue-400", + bg: "bg-blue-500/10", + border: "border-blue-500/30", + icon: , + }, + { + label: "가동중", + count: stats.running, + status: "running", + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/30", + icon: , + }, + { + label: "대기", + count: stats.idle, + status: "idle", + color: "text-amber-400", + bg: "bg-amber-500/10", + border: "border-amber-500/30", + icon: , + }, + { + label: "점검/수리", + count: stats.maintenance, + status: "maintenance", + color: "text-red-400", + bg: "bg-red-500/10", + border: "border-red-500/30", + icon: , + }, + { + label: "비가동", + count: stats.off + stats.unknown, + status: "off", + color: "text-gray-400", + bg: "bg-gray-500/10", + border: "border-gray-500/30", + icon: , + }, + ]; + + /* ── 필터 pill ── */ + const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [ + { label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" }, + { label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" }, + { label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" }, + { label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" }, + { label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" }, + ]; + + /* ── 포맷 ── */ + const formatTime = (d: Date) => + d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }); + const formatDate = (d: Date) => + d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" }); + + /* ────────────── 렌더 ────────────── */ + + return ( +
+ {/* ── 헤더 ── */} +
+
+
+

설비운영모니터링

+
+ +
+ {/* 현재 시간 */} +
+ + {formatDate(currentTime)} + {formatTime(currentTime)} +
+ + {/* 자동갱신 토글 */} + + + {/* 새로고침 */} + +
+
+ + {/* ── 요약 카드 5개 ── */} +
+ {summaryCards.map((card) => ( + + ))} +
+ + {/* ── 필터 pill ── */} +
+ {filterPills.map((pill) => ( + + ))} + + {filteredEquipments.length}대 표시 + +
+ + {/* ── 로딩 ── */} + {loading && equipments.length === 0 && ( +
+ + 설비 데이터를 불러오는 중... +
+ )} + + {/* ── 데이터 없음 ── */} + {!loading && equipments.length === 0 && ( +
+ +

등록된 설비가 없습니다.

+
+ )} + + {/* ── 설비 카드 그리드 ── */} + {filteredEquipments.length > 0 && ( +
+ {filteredEquipments.map((eq) => { + const status = resolveStatus(eq.operation_status); + const cfg = STATUS_MAP[status]; + const utilization = getUtilization(eq); + const eqWIs = wiMap[eq.id] ?? []; + + return ( +
+ {/* 좌측 색상 바 */} +
+ + {/* 상단: 설비명 + 상태 배지 */} +
+
+

+ {eq.equipment_name || "이름 없음"} +

+

+ {eq.equipment_type || "-"} · {eq.installation_location || "-"} +

+
+ + {cfg.icon} + {cfg.label} + +
+ + {/* 구분선 */} +
+ + {/* 정보 그리드 */} +
+
+ 금일 가동시간 +

-

+
+
+ 생산수량 +

-

+
+
+ 작업자 +

+ {eqWIs.length > 0 && eqWIs[0].worker_name + ? eqWIs[0].worker_name + : "-"} +

+
+
+ 설비코드 +

{eq.equipment_code || "-"}

+
+
+ + {/* 구분선 */} +
+ + {/* 가동률 프로그레스 */} +
+
+ 가동률 + + {utilization !== null ? `${utilization}%` : "-"} + +
+
+ {utilization !== null && ( +
+ )} +
+
+ + {/* 구분선 */} +
+ + {/* 현재 작업지시 */} +
+

현재 작업지시

+ {eqWIs.length > 0 ? ( +
+ {eqWIs.slice(0, 2).map((wi) => ( +
+ + {wi.instruction_number || "-"} + + + {wi.item_name || "-"} + +
+ ))} + {eqWIs.length > 2 && ( +

+{eqWIs.length - 2}건 더

+ )} +
+ ) : ( +

배정된 작업 없음

+ )} +
+ + {/* 구분선 */} +
+ + {/* 센서 데이터 (PLC 미연동) */} +
+
+ 온도 + - +
+
+ 압력 + - +
+
+ RPM + - +
+
+
+ ); + })} +
+ )} + + {/* 필터 결과 없음 */} + {!loading && equipments.length > 0 && filteredEquipments.length === 0 && ( +
+ +

해당 상태의 설비가 없습니다.

+
+ )} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/monitoring/production/page.tsx b/frontend/app/(main)/COMPANY_7/monitoring/production/page.tsx new file mode 100644 index 00000000..fb8aa24b --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/monitoring/production/page.tsx @@ -0,0 +1,504 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { apiClient } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + RefreshCw, + Clock, + Loader2, + Inbox, + Timer, + CheckCircle2, + AlertTriangle, + TrendingUp, + Play, + Pause, +} from "lucide-react"; + +// ─── 타입 정의 ───────────────────────────────────────────── +interface WorkInstructionDetail { + item_name?: string; + spec?: string; + customer_name?: string; +} + +interface WorkInstruction { + id: string; + wi_id?: string; + work_instruction_no: string; + status: string; // 일반 / 긴급 + qty: number; + completed_qty: number; + start_date: string | null; + end_date: string | null; + worker: string | null; + equipment_id: string | null; + equipment_name?: string | null; + details?: WorkInstructionDetail[]; +} + +interface ProcessStep { + wo_id: string; + process_name: string; + status: string; // acceptable / completed + seq_no: number; +} + +type FilterTab = "전체" | "대기" | "진행중" | "완료"; + +// ─── 유틸리티 ────────────────────────────────────────────── +function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return "-"; + try { + const d = new Date(dateStr); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } catch { + return dateStr; + } +} + +function formatTime(date: Date): string { + const h = String(date.getHours()).padStart(2, "0"); + const m = String(date.getMinutes()).padStart(2, "0"); + const s = String(date.getSeconds()).padStart(2, "0"); + return `${h}:${m}:${s}`; +} + +// 작업지시별 공정현황으로 진행상태 계산 +function computeProgress( + wiId: string, + processMap: Map +): "대기" | "진행중" | "완료" { + const steps = processMap.get(wiId); + if (!steps || steps.length === 0) return "대기"; + const completedCount = steps.filter((s) => s.status === "completed").length; + if (completedCount === 0) return "대기"; + if (completedCount === steps.length) return "완료"; + return "진행중"; +} + +// ─── 메인 컴포넌트 ──────────────────────────────────────── +export default function ProductionMonitoringPage() { + const [workInstructions, setWorkInstructions] = useState([]); + const [processMap, setProcessMap] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(new Date()); + const [autoRefresh, setAutoRefresh] = useState(true); + const [activeTab, setActiveTab] = useState("전체"); + + // ─── 실시간 시계 ───────────────────────────────────────── + useEffect(() => { + const timer = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + // ─── 데이터 로드 ───────────────────────────────────────── + const fetchData = useCallback(async () => { + try { + setLoading(true); + + // 작업지시 목록 조회 + const wiRes = await apiClient.get("/work-instruction/list"); + const wiRaw: WorkInstruction[] = + wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : []; + // 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능) + const seen = new Set(); + const wiData = wiRaw.filter((wi) => { + const key = wi.work_instruction_no || wi.id; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + setWorkInstructions(wiData); + + // 공정현황 조회 (실패해도 작업지시는 표시) + try { + const procRes = await apiClient.post("/table-management/tables/work_order_process/data", { + page: 1, size: 1000, autoFilter: true, + }); + const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || []; + + // wo_id별로 그룹핑 후, 같은 seq_no+process_name은 1건으로 합침 (배치 실행 이력 중복 제거) + const rawMap = new Map(); + rows.forEach((row) => { + const key = String(row.wo_id); + if (!rawMap.has(key)) rawMap.set(key, []); + rawMap.get(key)!.push(row); + }); + + const map = new Map(); + rawMap.forEach((steps, woId) => { + // seq_no + process_name 기준으로 대표 1건만 (completed 우선) + const grouped = new Map(); + for (const s of steps) { + const gk = `${s.seq_no}_${s.process_name}`; + const existing = grouped.get(gk); + if (!existing || (s.status === "completed" && existing.status !== "completed")) { + grouped.set(gk, s); + } + } + const deduped = Array.from(grouped.values()).sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0)); + map.set(woId, deduped); + }); + setProcessMap(map); + } catch { + // 공정현황 조회 실패 → 빈 맵 유지 + setProcessMap(new Map()); + } + } catch (err) { + console.error("생산모니터링 데이터 조회 실패:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ─── 자동갱신 (30초) ───────────────────────────────────── + useEffect(() => { + if (!autoRefresh) return; + const timer = setInterval(fetchData, 30000); + return () => clearInterval(timer); + }, [autoRefresh, fetchData]); + + // ─── 통계 계산 ─────────────────────────────────────────── + const stats = useMemo(() => { + let waiting = 0; + let inProgress = 0; + let completed = 0; + let totalQty = 0; + let completedQty = 0; + + workInstructions.forEach((wi) => { + const progress = computeProgress(wi.wi_id || wi.id, processMap); + if (progress === "대기") waiting++; + else if (progress === "진행중") inProgress++; + else completed++; + totalQty += Number((wi as any).total_qty || wi.qty || 0); + completedQty += Number(wi.completed_qty) || 0; + }); + + const achievementRate = totalQty > 0 ? Math.round((completedQty / totalQty) * 100) : 0; + + return { waiting, inProgress, completed, achievementRate }; + }, [workInstructions, processMap]); + + // ─── 필터링된 작업 목록 ────────────────────────────────── + const filteredInstructions = useMemo(() => { + return workInstructions.filter((wi) => { + if (activeTab === "전체") return true; + const progress = computeProgress(wi.wi_id || wi.id, processMap); + return progress === activeTab; + }); + }, [workInstructions, processMap, activeTab]); + + // ─── 렌더링 ────────────────────────────────────────────── + return ( +
+ {/* 헤더 */} +
+

생산모니터링

+
+
+ + {formatTime(currentTime)} +
+ + +
+
+ + {/* 요약 카드 */} +
+ } + label="대기중" + value={stats.waiting} + colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20" + /> + } + label="진행중" + value={stats.inProgress} + colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20" + /> + } + label="완료" + value={stats.completed} + colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20" + /> + } + label="달성율" + value={`${stats.achievementRate}%`} + colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20" + /> +
+ + {/* 탭 필터 */} +
+ {(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => ( + + ))} +
+ + {/* 로딩 상태 */} + {loading && workInstructions.length === 0 && ( +
+ + 데이터를 불러오는 중입니다... +
+ )} + + {/* 빈 상태 */} + {!loading && filteredInstructions.length === 0 && ( +
+ + + {activeTab === "전체" + ? "등록된 작업지시가 없습니다." + : `"${activeTab}" 상태의 작업지시가 없습니다.`} + +
+ )} + + {/* 작업 카드 그리드 */} + {filteredInstructions.length > 0 && ( +
+ {filteredInstructions.map((wi, idx) => ( + + ))} +
+ )} +
+ ); +} + +// ─── 요약 카드 ───────────────────────────────────────────── +function SummaryCard({ + icon, + label, + value, + colorClass, +}: { + icon: React.ReactNode; + label: string; + value: number | string; + colorClass: string; +}) { + return ( +
+
{icon}
+
+ {label} + {value} +
+
+ ); +} + +// ─── 작업 카드 ───────────────────────────────────────────── +function WorkCard({ + instruction: wi, + steps, + progress, +}: { + instruction: WorkInstruction; + steps: ProcessStep[]; + progress: "대기" | "진행중" | "완료"; +}) { + // API 응답은 flat 구조 (details 배열 아님) + const itemName = (wi as any).item_name || "-"; + const spec = (wi as any).item_spec || "-"; + const customerName = (wi as any).customer_name || "-"; + + // 진척률 (total_qty 또는 qty) + const totalQty = Number((wi as any).total_qty || wi.qty || 0); + const completedQty = Number(wi.completed_qty || 0); + const progressPercent = totalQty > 0 ? Math.min(100, Math.round((completedQty / totalQty) * 100)) : 0; + + // 공정 현황 계산 + const completedSteps = steps.filter((s) => s.status === "completed").length; + const currentStep = steps.find((s) => s.status !== "completed"); + + // 프로그레스바 색상 + const barColor = + progressPercent >= 100 + ? "bg-emerald-500" + : progressPercent >= 50 + ? "bg-blue-500" + : "bg-amber-500"; + + // 상태 배지 스타일 + const statusBadge: Record = { + "대기": "bg-amber-500/10 text-amber-500 border-amber-500/30", + "진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30", + "완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30", + }; + + const isUrgent = wi.status === "긴급"; + + return ( +
+ {/* 카드 헤더 */} +
+
+ + {wi.work_instruction_no} + + {isUrgent && ( + + + 긴급 + + )} +
+ + {progress} + +
+ + {/* 카드 본문 - 정보 */} +
+ + + + + + +
+ + {/* 공정현황 */} +
+
+ 공정현황 + {steps.length > 0 && ( + + 완료 {completedSteps}/{steps.length} + {currentStep && ( + + {" "} + · 현재: {currentStep.process_name} + + )} + + )} +
+ {steps.length > 0 ? ( +
+ {steps.map((step, idx) => { + const isDone = step.status === "completed"; + const isCurrent = !isDone && idx === completedSteps; + return ( + + {step.process_name} + + ); + })} +
+ ) : ( + 공정 정보 없음 + )} +
+ + {/* 프로그레스바 */} +
+
+ + {completedQty} / {totalQty} + + = 100 + ? "text-emerald-500" + : progressPercent >= 50 + ? "text-blue-500" + : "text-amber-500" + )} + > + {progressPercent}% + +
+
+
+
+
+
+ ); +} + +// ─── 정보 행 ─────────────────────────────────────────────── +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/monitoring/quality/page.tsx b/frontend/app/(main)/COMPANY_7/monitoring/quality/page.tsx new file mode 100644 index 00000000..b0135bbe --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/monitoring/quality/page.tsx @@ -0,0 +1,512 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { + RefreshCw, + Clock, + Loader2, + Inbox, + Search, + ClipboardCheck, +} from "lucide-react"; + +/* ───── 타입 ───── */ +interface ProcessRow { + id: number; + wo_id: number; + process_code: string; + process_name: string; + status: string; + plan_qty: number; + input_qty: number; + good_qty: number; + defect_qty: number; + started_at: string | null; + completed_at: string | null; + worker_name: string; +} + +interface InspectionRow { + no: number; + inspectionNo: string; + inspectionType: string; + itemName: string; + spec: string; + inspectionQty: number; + goodQty: number; + defectQty: number; + defectRate: number; + result: "합격" | "불합격" | "대기"; + inspectorName: string; + inspectedAt: string; + remark: string; +} + +/* ───── 탭 정의 ───── */ +const TABS = [ + { key: "all", label: "전체" }, + { key: "process", label: "공정검사" }, + { key: "incoming", label: "입고검사" }, + { key: "shipping", label: "출하검사" }, +] as const; +type TabKey = (typeof TABS)[number]["key"]; + +/* ───── 유틸 ───── */ +const fmt = (n: number) => n.toLocaleString("ko-KR"); +const pct = (n: number) => + `${n.toFixed(1)}%`; + +const badgeVariant = ( + type: "result" | "type" | "defectRate", + value: string | number, +) => { + if (type === "result") { + if (value === "합격") + return "bg-emerald-100 text-emerald-700 border-emerald-200"; + if (value === "불합격") return "bg-red-100 text-red-700 border-red-200"; + return "bg-amber-100 text-amber-700 border-amber-200"; + } + if (type === "type") { + if (value === "공정검사") + return "bg-purple-100 text-purple-700 border-purple-200"; + if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200"; + return "bg-emerald-100 text-emerald-700 border-emerald-200"; + } + // defectRate + const rate = typeof value === "number" ? value : parseFloat(String(value)); + if (rate > 3) return "text-red-600 font-semibold"; + if (rate >= 1) return "text-amber-600 font-semibold"; + return "text-emerald-600"; +}; + +/* ───── 컴포넌트 ───── */ +export default function QualityMonitoringPage() { + const [processData, setProcessData] = useState([]); + const [loading, setLoading] = useState(false); + const [currentTime, setCurrentTime] = useState(new Date()); + const [autoRefresh, setAutoRefresh] = useState(true); + const [activeTab, setActiveTab] = useState("all"); + const intervalRef = useRef | null>(null); + + /* ───── 시계 ───── */ + useEffect(() => { + const timer = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + /* ───── 데이터 조회 ───── */ + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.post( + "/table-management/tables/work_order_process/data", + { autoFilter: true }, + ); + const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? []; + setProcessData(rows); + } catch (err) { + console.error("품질점검현황 데이터 조회 실패:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + /* ───── 자동 갱신 ───── */ + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(fetchData, 30_000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh, fetchData]); + + /* ───── 검사 행 변환 ───── */ + const inspectionRows: InspectionRow[] = useMemo(() => { + const today = new Date().toISOString().slice(0, 10); + + return processData + .filter((r) => { + // 금일 데이터만 + const dt = r.completed_at || r.started_at || ""; + return dt.slice(0, 10) === today; + }) + .map((r, idx) => { + const inspQty = r.input_qty || r.plan_qty || 0; + const goodQty = r.good_qty ?? 0; + const defectQty = r.defect_qty ?? 0; + const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0; + const result: InspectionRow["result"] = + r.status !== "completed" + ? "대기" + : defectQty > 0 + ? "불합격" + : "합격"; + + return { + no: idx + 1, + inspectionNo: `QC-${String(r.id).padStart(8, "0").slice(0, 8)}`, + inspectionType: "공정검사", + itemName: r.process_name || "-", + spec: r.process_code || "-", + inspectionQty: inspQty, + goodQty, + defectQty, + defectRate, + result, + inspectorName: r.worker_name || "-", + inspectedAt: r.completed_at || r.started_at || "-", + remark: "", + }; + }); + }, [processData]); + + /* ───── 탭 필터링 ───── */ + const filteredRows = useMemo(() => { + if (activeTab === "all" || activeTab === "process") return inspectionRows; + // 입고/출하는 데이터 없음 + return []; + }, [activeTab, inspectionRows]); + + /* ───── 요약 통계 ───── */ + const summary = useMemo(() => { + const total = inspectionRows.length; + const passed = inspectionRows.filter((r) => r.result === "합격").length; + const failed = inspectionRows.filter((r) => r.result === "불합격").length; + const pending = inspectionRows.filter((r) => r.result === "대기").length; + const passRate = total > 0 ? (passed / total) * 100 : 0; + return { total, passed, failed, pending, passRate }; + }, [inspectionRows]); + + /* ───── 요약 카드 정의 ───── */ + const summaryCards = [ + { + label: "금일 검사건수", + value: fmt(summary.total), + sub: "건", + color: "from-slate-500 to-slate-600", + textColor: "text-white", + }, + { + label: "합격", + value: fmt(summary.passed), + sub: "건", + color: "from-emerald-500 to-emerald-600", + textColor: "text-white", + }, + { + label: "불합격", + value: fmt(summary.failed), + sub: "건", + color: "from-red-500 to-red-600", + textColor: "text-white", + }, + { + label: "검사대기", + value: fmt(summary.pending), + sub: "건", + color: "from-amber-500 to-amber-600", + textColor: "text-white", + }, + { + label: "합격률", + value: pct(summary.passRate), + sub: "", + color: "from-purple-500 to-purple-600", + textColor: "text-white", + }, + ]; + + /* ───── 렌더링 ───── */ + return ( +
+ {/* ── 헤더 ── */} +
+
+ +

+ 품질점검현황{" "} + 모니터링 +

+
+
+
+ + + {currentTime.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + })} + +
+ + +
+
+ + {/* ── 본문 ── */} +
+ {/* 요약 카드 */} +
+ {summaryCards.map((card) => ( +
+

+ {card.label} +

+

+ {card.value} + {card.sub && ( + + {card.sub} + + )} +

+
+ ))} +
+ + {/* 검사유형 탭 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 테이블 영역 */} +
+ {/* 입고/출하 준비중 */} + {(activeTab === "incoming" || activeTab === "shipping") ? ( +
+ +

준비중

+

+ {activeTab === "incoming" ? "입고검사" : "출하검사"} 데이터는 + 아직 지원되지 않습니다. +

+
+ ) : loading && filteredRows.length === 0 ? ( +
+ +

데이터를 불러오는 중...

+
+ ) : filteredRows.length === 0 ? ( +
+ +

금일 검사 데이터가 없습니다

+
+ ) : ( +
+ + + + No + 검사번호 + + 검사유형 + + 품목명 + 규격 + + 검사수량 + + + 합격수량 + + + 불합격수량 + + + 불량율 + + + 검사결과 + + + 판정 + + + 검사자 + + 검사일시 + 비고 + + + + {filteredRows.map((row) => { + const goodPct = + row.inspectionQty > 0 + ? (row.goodQty / row.inspectionQty) * 100 + : 0; + const defectPct = + row.inspectionQty > 0 + ? (row.defectQty / row.inspectionQty) * 100 + : 0; + + return ( + + + {row.no} + + + {row.inspectionNo} + + + + {row.inspectionType} + + + + {row.itemName} + + + {row.spec} + + + {fmt(row.inspectionQty)} + + + {fmt(row.goodQty)} + + + {fmt(row.defectQty)} + + + {pct(row.defectRate)} + + {/* 검사결과 프로그레스바 */} + +
+
+
+
+
+ + {pct(goodPct)} + +
+ + {/* 판정 배지 */} + + + {row.result} + + + + {row.inspectorName} + + + {row.inspectedAt !== "-" + ? new Date(row.inspectedAt).toLocaleString( + "ko-KR", + { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }, + ) + : "-"} + + + {row.remark || "-"} + + + ); + })} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/smart-factory-log/page.tsx b/frontend/app/(main)/admin/smart-factory-log/page.tsx index 73a93daf..4ebd5e2d 100644 --- a/frontend/app/(main)/admin/smart-factory-log/page.tsx +++ b/frontend/app/(main)/admin/smart-factory-log/page.tsx @@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Select, SelectContent, @@ -20,6 +22,13 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; import { Search, RefreshCw, @@ -32,13 +41,34 @@ import { Building2, Activity, Users, + Plus, + Trash2, + Clock, + CalendarOff, + Settings, + List, + KeyRound, } from "lucide-react"; import { getSmartFactoryLogs, getSmartFactoryLogStats, + getSchedules, + upsertSchedule, + deleteSchedule, + getTodayPlan, + getHolidays, + addHoliday, + deleteHoliday, + getApiKeys, + saveApiKey, + deleteApiKey, SmartFactoryLogEntry, SmartFactoryLogFilters, SmartFactoryLogStats, + ApiKeyEntry, + SmartFactorySchedule, + SmartFactoryHoliday, + TodayPlanEntry, } from "@/lib/api/smartFactoryLog"; import { getCompanyList } from "@/lib/api/company"; import { useAuth } from "@/hooks/useAuth"; @@ -58,6 +88,27 @@ export default function SmartFactoryLogPage() { const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); + // 스케줄 관련 + const [schedules, setSchedules] = useState([]); + const [todayPlan, setTodayPlan] = useState([]); + const [holidays, setHolidays] = useState([]); + const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false); + const [editingSchedule, setEditingSchedule] = useState({ + companyCode: "", + isActive: true, + timeStart: "08:30", + timeEnd: "17:30", + excludeWeekend: true, + excludeHolidays: true, + }); + const [apiKeys, setApiKeys] = useState([]); + const [editingKey, setEditingKey] = useState({ companyCode: "", apiKey: "" }); + const [keyDialogOpen, setKeyDialogOpen] = useState(false); + const [newHoliday, setNewHoliday] = useState({ date: "", name: "" }); + const [holidayYear, setHolidayYear] = useState(String(new Date().getFullYear())); + const [holidayPage, setHolidayPage] = useState(1); + const HOLIDAY_PER_PAGE = 20; + const [filters, setFilters] = useState({ companyCode: "", sendStatus: "", @@ -68,62 +119,83 @@ export default function SmartFactoryLogPage() { limit: 50, }); - // 회사 목록 로드 - useEffect(() => { - getCompanyList() - .then(setCompanies) - .catch(() => {}); - }, []); + useEffect(() => { getCompanyList().then(setCompanies).catch(() => {}); }, []); - // 로그 데이터 로드 const fetchLogs = useCallback(async () => { setLoading(true); try { const [logRes, statsRes] = await Promise.all([ getSmartFactoryLogs(filters), - getSmartFactoryLogStats( - filters.companyCode || undefined, - 30 - ), + getSmartFactoryLogStats(filters.companyCode || undefined, 30), ]); - if (logRes.success) { - setLogs(logRes.data); - setTotal(logRes.total); - } - if (statsRes.success) { - setStats(statsRes.data); - } + if (logRes.success) { setLogs(logRes.data); setTotal(logRes.total); } + if (statsRes.success) setStats(statsRes.data); } catch (error) { console.error("스마트공장 로그 조회 실패:", error); - } finally { - setLoading(false); - } + } finally { setLoading(false); } }, [filters]); - useEffect(() => { - fetchLogs(); - }, [fetchLogs]); + useEffect(() => { fetchLogs(); }, [fetchLogs]); + + const fetchSchedules = useCallback(async () => { + try { + const [schedRes, planRes, holRes, keyRes] = await Promise.all([ + getSchedules(), getTodayPlan(), getHolidays(), getApiKeys(), + ]); + if (schedRes.success) setSchedules(schedRes.data); + if (planRes.success) setTodayPlan(planRes.data); + if (holRes.success) setHolidays(holRes.data); + if (keyRes.success) setApiKeys(keyRes.data); + } catch (e) { console.error("스케줄 조회 실패:", e); } + }, []); + + useEffect(() => { fetchSchedules(); }, [fetchSchedules]); const totalPages = Math.ceil(total / (filters.limit || 50)); - const handleFilterChange = (key: keyof SmartFactoryLogFilters, value: string) => { setFilters((prev) => ({ ...prev, [key]: value, page: 1 })); }; - - const formatDate = (dateStr: string) => { + const formatDateStr = (dateStr: string) => { if (!dateStr) return "-"; - const d = new Date(dateStr); - return d.toLocaleString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", + return new Date(dateStr).toLocaleString("ko-KR", { + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", }); }; - // 최고관리자가 아니면 접근 불가 안내 + const [scheduleError, setScheduleError] = useState(""); + const handleSaveSchedule = async () => { + if (!editingSchedule.companyCode) return; + const keyInfo = apiKeys.find((k) => k.companyCode === editingSchedule.companyCode); + if (!keyInfo?.hasDbKey && !keyInfo?.hasEnvKey) { + setScheduleError("해당 회사의 API 키가 등록되지 않았습니다. API 키 관리 탭에서 먼저 등록해주세요."); + return; + } + setScheduleError(""); + try { await upsertSchedule(editingSchedule); setScheduleDialogOpen(false); fetchSchedules(); } + catch (e) { console.error("스케줄 저장 실패:", e); } + }; + const handleDeleteSchedule = async (companyCode: string) => { + if (!confirm("스케줄을 삭제하시겠습니까?")) return; + try { await deleteSchedule(companyCode); fetchSchedules(); } + catch (e) { console.error("스케줄 삭제 실패:", e); } + }; + const handleAddHoliday = async () => { + if (!newHoliday.date || !newHoliday.name) return; + try { await addHoliday(newHoliday.date, newHoliday.name); setNewHoliday({ date: "", name: "" }); fetchSchedules(); } + catch (e) { console.error("공휴일 추가 실패:", e); } + }; + const handleToggleSchedule = async (s: SmartFactorySchedule) => { + try { + await upsertSchedule({ + companyCode: s.company_code, isActive: !s.is_active, + timeStart: s.time_start, timeEnd: s.time_end, + excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, + }); + fetchSchedules(); + } catch (e) { console.error("스케줄 토글 실패:", e); } + }; + if (user && user.userType !== "SUPER_ADMIN" && user.companyCode !== "*") { return (
@@ -133,346 +205,666 @@ export default function SmartFactoryLogPage() { } return ( -
+
{/* 헤더 */} -
+

스마트공장 활용 로그

- 로그인 시 log.smart-factory.kr로 전송된 로그 기록 + log.smart-factory.kr 전송 로그 관리 및 자동 전송 스케줄

-
- {/* 통계 카드 */} - {stats && ( -
- - -
-
- -
-
-

최근 30일 전체

-

{stats.total.toLocaleString()}건

-
-
-
-
- - -
-
- -
-
-

전송 성공

-

- {(stats.statusCounts.find((s) => s.status === "SUCCESS")?.count || 0).toLocaleString()}건 -

-
-
-
-
- - -
-
- -
-
-

전송 실패

-

- {(stats.statusCounts.find((s) => s.status === "FAIL")?.count || 0).toLocaleString()}건 -

-
-
-
-
- - -
-
- -
-
-

활용 회사

-

- {stats.companyCounts.length}개사 -

-
-
-
-
-
- )} + {/* 탭 */} + + + + + 전송 기록 + + + + 스케줄 설정 + + + + API 키 관리 + + + + 공휴일 관리 + + - {/* 회사별 현황 */} - {stats && stats.companyCounts.length > 0 && ( - - - - - 회사별 전송 현황 (최근 30일) - - - -
- {stats.companyCounts.map((c) => ( - - ))} + {/* ─── 탭 1: 전송 기록 ─── */} + +
+ {/* 통계 카드 */} + {stats && ( +
+ + +
+
+
+

최근 30일 전체

+

{stats.total.toLocaleString()}건

+
+
+
+
+ + +
+
+
+

전송 성공

+

+ {(stats.statusCounts.find((s) => s.status === "SUCCESS")?.count || 0).toLocaleString()}건 +

+
+
+
+
+ + +
+
+
+

전송 실패

+

+ {(stats.statusCounts.find((s) => s.status === "FAIL")?.count || 0).toLocaleString()}건 +

+
+
+
+
+ + +
+
+
+

활용 회사

+

{stats.companyCounts.length}개사

+
+
+
+
- - - )} + )} - {/* 필터 */} - - -
-
- -
- - handleFilterChange("search", e.target.value)} - /> + {/* 회사별 현황 */} + {stats && stats.companyCounts.length > 0 && ( + + + + + 회사별 전송 현황 (최근 30일) + + + +
+ {stats.companyCounts.map((c) => ( + + ))} +
+
+
+ )} + + {/* 필터 */} + + +
+
+ +
+ + handleFilterChange("search", e.target.value)} /> +
+
+
+ + +
+
+ + +
+
+ + handleFilterChange("dateFrom", e.target.value)} /> +
+
+ + handleFilterChange("dateTo", e.target.value)} /> +
+
+
+
+ + {/* 로그 테이블 */} + + +
+ 전송 기록 ({total.toLocaleString()}건) + +
+
+ +
+ + + + No + 회사 + 사용자 + 유형 + 접속 IP + 상태 + 응답 + 에러 메시지 + 전송 시각 + + + + {loading ? ( + 로딩 중... + ) : logs.length === 0 ? ( + 로그가 없습니다. + ) : ( + logs.map((log, idx) => { + const statusConf = STATUS_CONFIG[log.send_status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.FAIL; + const StatusIcon = statusConf.icon; + return ( + + + {total - ((filters.page || 1) - 1) * (filters.limit || 50) - idx} + + {log.company_name || log.company_code} + +
+ {log.user_name || "-"} + {log.user_id} +
+
+ {log.use_type} + {log.connect_ip} + + + {statusConf.label} + + + {log.response_status || "-"} + + {log.error_message || "-"} + + {formatDateStr(log.created_at)} +
+ ); + }) + )} +
+
+
+ + {totalPages > 1 && ( +
+

+ {((filters.page || 1) - 1) * (filters.limit || 50) + 1}~ + {Math.min((filters.page || 1) * (filters.limit || 50), total)} / {total.toLocaleString()}건 +

+
+ + {filters.page || 1} / {totalPages} + +
+
+ )} +
+
+
+ + + {/* ─── 탭 2: 스케줄 설정 ─── */} + +
+ {/* 오늘 실행 현황 */} + {todayPlan.length > 0 && ( +
+ {todayPlan.map((p) => { + const companyName = schedules.find((s) => s.company_code === p.companyCode)?.company_name || p.companyCode; + const pct = p.total > 0 ? Math.round((p.sent / p.total) * 100) : 0; + return ( + + +

{companyName}

+
+
+
+

+ {p.sent} / {p.total}명 완료 + {p.remaining > 0 && ({p.remaining}명 남음)} +

+ + + ); + })} +
+ )} + + {/* 스케줄 테이블 */} + + +
+ + + 회사별 자동 전송 스케줄 + + +
+
+ + {schedules.length === 0 ? ( +

등록된 스케줄이 없습니다. 스케줄을 추가하여 자동 전송을 시작하세요.

+ ) : ( +
+ + + + 회사 + 로그인 시간대 + 주말 제외 + 공휴일 제외 + 활성 + 동작 + + + + {schedules.map((s) => ( + + {s.company_name || s.company_code} + + {s.time_start} ~ {s.time_end} + + {s.exclude_weekend ? Y : N} + {s.exclude_holidays ? Y : N} + handleToggleSchedule(s)} /> + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+
+
+ + + {/* ─── 탭 3: API 키 관리 ─── */} + +
+ + +
+ + + 회사별 API 키 관리 + +

log.smart-factory.kr 인증키 (crtfcKey)

+
+
+ +
+ + + + 회사 + DB 키 + 환경변수 + 상태 + 동작 + + + + {apiKeys.length === 0 ? ( + + 회사 정보를 불러오는 중... + + ) : ( + apiKeys.map((k) => ( + + {k.companyName} + + {k.dbKey + ? {k.dbKey} + : -} + + + {k.hasEnvKey + ? 있음 + : -} + + + {k.hasDbKey || k.hasEnvKey + ? 사용 가능 + : 미설정} + + +
+ + {k.hasDbKey && ( + + )} +
+
+
+ )) + )} +
+
+
+

+ DB 키가 우선 사용됩니다. DB 키가 없으면 서버 환경변수(SMART_FACTORY_API_KEY_회사코드)를 사용합니다. +

+
+
+
+
+ + {/* ─── 탭 4: 공휴일 관리 ─── */} + + {/* 상단 고정: 추가 폼 + 연도 필터 */} +
+
+
+ + setNewHoliday((p) => ({ ...p, date: e.target.value }))} className="w-[170px]" /> +
+
+ + setNewHoliday((p) => ({ ...p, name: e.target.value }))} + placeholder="예: 설날" className="w-[220px]" /> +
+ +
+
+ + +
+
-
- + {/* 스크롤 영역: 테이블 + 페이지네이션 */} +
+ {(() => { + const filtered = holidayYear === "all" + ? holidays + : holidays.filter((h) => h.holiday_date.startsWith(holidayYear)); + const totalHolidayPages = Math.ceil(filtered.length / HOLIDAY_PER_PAGE); + const paged = filtered.slice((holidayPage - 1) * HOLIDAY_PER_PAGE, holidayPage * HOLIDAY_PER_PAGE); + + return filtered.length === 0 ? ( + + +

등록된 공휴일이 없습니다.

+
+
+ ) : ( + + +
+ + + {holidayYear === "all" ? "전체" : `${holidayYear}년`} 공휴일 + +

{filtered.length}일

+
+
+ +
+ + + + 날짜 + 요일 + 공휴일명 + + + + + {paged.map((h) => { + const d = new Date(h.holiday_date + "T00:00:00"); + const dayName = ["일", "월", "화", "수", "목", "금", "토"][d.getDay()]; + const isWeekend = d.getDay() === 0 || d.getDay() === 6; + return ( + + {h.holiday_date.substring(0, 10)} + + {dayName}요일 + + {h.holiday_name} + + + + + ); + })} + +
+
+ + {totalHolidayPages > 1 && ( +
+

+ {(holidayPage - 1) * HOLIDAY_PER_PAGE + 1}~{Math.min(holidayPage * HOLIDAY_PER_PAGE, filtered.length)} / {filtered.length}일 +

+
+ + {holidayPage} / {totalHolidayPages} + +
+
+ )} +
+
+ ); + })()} +
+ + + + {/* 스케줄 추가/수정 Dialog */} + + + + {editingSchedule.companyCode && schedules.some((s) => s.company_code === editingSchedule.companyCode) ? "스케줄 수정" : "스케줄 추가"} + +
+
+
- -
- - -
- -
- - handleFilterChange("dateFrom", e.target.value)} - /> -
- -
- - handleFilterChange("dateTo", e.target.value)} - /> -
-
- - - - {/* 테이블 */} - - -
- - 전송 기록 ({total.toLocaleString()}건) - -
- -
-
-
- -
- - - - No - 회사 - 사용자 - 유형 - 접속 IP - 상태 - 응답 - 에러 메시지 - 전송 시각 - - - - {loading ? ( - - - 로딩 중... - - - ) : logs.length === 0 ? ( - - - 로그가 없습니다. - - - ) : ( - logs.map((log, idx) => { - const statusConf = STATUS_CONFIG[log.send_status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.FAIL; - const StatusIcon = statusConf.icon; - return ( - - - {total - ((filters.page || 1) - 1) * (filters.limit || 50) - idx} - - - {log.company_name || log.company_code} - - -
- {log.user_name || "-"} - - {log.user_id} - -
-
- {log.use_type} - {log.connect_ip} - - - - {statusConf.label} - - - - {log.response_status || "-"} - - - {log.error_message || "-"} - - {formatDate(log.created_at)} -
- ); - }) - )} -
-
-
- - {/* 페이지네이션 */} - {totalPages > 1 && ( -
-

- {((filters.page || 1) - 1) * (filters.limit || 50) + 1}~ - {Math.min((filters.page || 1) * (filters.limit || 50), total)} / {total.toLocaleString()}건 -

-
- - - {filters.page || 1} / {totalPages} - - +
+
+
+ + setEditingSchedule((p) => ({ ...p, timeStart: e.target.value }))} /> +
+
+ + setEditingSchedule((p) => ({ ...p, timeEnd: e.target.value }))} /> +
+

해당 시간대에 전 직원의 로그인 이력이 랜덤 시각으로 전송됩니다

+
+ + setEditingSchedule((p) => ({ ...p, excludeWeekend: v }))} /> +
+
+ + setEditingSchedule((p) => ({ ...p, excludeHolidays: v }))} /> +
+
+ {scheduleError && ( +

{scheduleError}

)} - - + + + + + +
+ + {/* API 키 등록/변경 Dialog */} + + + + API 키 {editingKey.companyCode ? "등록/변경" : ""} + +
+
+ +

+ {apiKeys.find((k) => k.companyCode === editingKey.companyCode)?.companyName || editingKey.companyCode} +

+
+
+ + setEditingKey((p) => ({ ...p, apiKey: e.target.value }))} + placeholder="스마트공장 인증키 (crtfcKey) 입력" + className="font-mono text-sm" + /> +

AES-256-GCM 암호화하여 저장됩니다

+
+
+ + + + +
+
); } diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 165f7014..08f94a12 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -137,6 +137,9 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/production/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_16/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/production/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_7/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index faa38879..9403a5f0 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -106,11 +106,14 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p .filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId) .filter((menu) => (menu.status || menu.STATUS) === "active") .filter((menu) => { - // 회사관리 메뉴는 최고관리자만 표시 + // 최고관리자 전용 메뉴 필터링 const url = (menu.menu_url || menu.MENU_URL || "").toLowerCase(); if (url.includes("companylist") || url.includes("company-list")) { return isSuperAdmin; } + if (url.includes("smart-factory-log")) { + return isSuperAdmin; + } return true; }) .sort((a, b) => (a.seq || a.SEQ || 0) - (b.seq || b.SEQ || 0)); diff --git a/frontend/lib/api/smartFactoryLog.ts b/frontend/lib/api/smartFactoryLog.ts index cd70b94f..b8edfe5f 100644 --- a/frontend/lib/api/smartFactoryLog.ts +++ b/frontend/lib/api/smartFactoryLog.ts @@ -67,3 +67,107 @@ export async function getSmartFactoryLogStats( const response = await apiClient.get(`/admin/smart-factory-log/stats?${params.toString()}`); return response.data; } + +// ─── 스케줄 관리 ─── + +export interface SmartFactorySchedule { + id: number; + company_code: string; + company_name: string | null; + is_active: boolean; + time_start: string; + time_end: string; + exclude_weekend: boolean; + exclude_holidays: boolean; + created_at: string; + updated_at: string; +} + +export interface TodayPlanEntry { + companyCode: string; + total: number; + sent: number; + remaining: number; +} + +export interface SmartFactoryHoliday { + id: number; + holiday_date: string; + holiday_name: string; + created_at: string; +} + +export async function getSchedules(): Promise<{ success: boolean; data: SmartFactorySchedule[] }> { + const response = await apiClient.get("/admin/smart-factory-log/schedules"); + return response.data; +} + +export async function upsertSchedule(params: { + companyCode: string; + isActive: boolean; + timeStart: string; + timeEnd: string; + excludeWeekend: boolean; + excludeHolidays: boolean; +}): Promise<{ success: boolean; message: string }> { + const response = await apiClient.post("/admin/smart-factory-log/schedules", params); + return response.data; +} + +export async function deleteSchedule(companyCode: string): Promise<{ success: boolean }> { + const response = await apiClient.delete(`/admin/smart-factory-log/schedules/${companyCode}`); + return response.data; +} + +export async function runScheduleNow(companyCode: string): Promise<{ + success: boolean; + data: { total: number; sent: number; skipped: number }; +}> { + const response = await apiClient.post(`/admin/smart-factory-log/schedules/${companyCode}/run-now`); + return response.data; +} + +export async function getTodayPlan(): Promise<{ success: boolean; data: TodayPlanEntry[] }> { + const response = await apiClient.get("/admin/smart-factory-log/schedules/today-plan"); + return response.data; +} + +export async function getHolidays(): Promise<{ success: boolean; data: SmartFactoryHoliday[] }> { + const response = await apiClient.get("/admin/smart-factory-log/holidays"); + return response.data; +} + +export async function addHoliday(holidayDate: string, holidayName: string): Promise<{ success: boolean }> { + const response = await apiClient.post("/admin/smart-factory-log/holidays", { holidayDate, holidayName }); + return response.data; +} + +export async function deleteHoliday(id: number): Promise<{ success: boolean }> { + const response = await apiClient.delete(`/admin/smart-factory-log/holidays/${id}`); + return response.data; +} + +// ─── API 키 관리 ─── + +export interface ApiKeyEntry { + companyCode: string; + companyName: string; + hasDbKey: boolean; + dbKey: string | null; + hasEnvKey: boolean; +} + +export async function getApiKeys(): Promise<{ success: boolean; data: ApiKeyEntry[] }> { + const response = await apiClient.get("/admin/smart-factory-log/api-keys"); + return response.data; +} + +export async function saveApiKey(companyCode: string, apiKey: string): Promise<{ success: boolean }> { + const response = await apiClient.post("/admin/smart-factory-log/api-keys", { companyCode, apiKey }); + return response.data; +} + +export async function deleteApiKey(companyCode: string): Promise<{ success: boolean }> { + const response = await apiClient.delete(`/admin/smart-factory-log/api-keys/${companyCode}`); + return response.data; +}