feat: Implement smart factory schedule management functionality

- Added new API endpoints for managing smart factory schedules, including retrieval, creation, updating, and deletion of schedules.
- Integrated schedule management into the smart factory log controller, enhancing the overall functionality.
- Implemented a scheduler initialization process to automate daily plan generation and scheduled sends.
- Developed a frontend page for monitoring equipment, production, and quality, with real-time data fetching and auto-refresh capabilities.

These changes aim to provide comprehensive scheduling capabilities for smart factory operations, improving efficiency and operational visibility for users.
This commit is contained in:
kjs
2026-04-07 14:16:26 +09:00
parent 9aa8ca136b
commit c3e973bb1a
13 changed files with 3222 additions and 389 deletions
+7
View File
@@ -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);
}
@@ -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<void> => {
try {
const schedules = await query<any>(
`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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
try {
const holidays = await query<any>(
"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<void> => {
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<void> => {
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<void> => {
try {
const companies = await query<any>(
`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<void> => {
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<void> => {
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 키 삭제 실패" });
}
};
+29
View File
@@ -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("🔄 스마트공장 로그 테이블 마이그레이션 시작...");
+28
View File
@@ -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;
+387 -15
View File
@@ -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<string, ScheduledEntry[]> = new Map();
// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신)
let holidayCache: Set<string> = 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<void> {
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<void> {
// 매일 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<void> {
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<void> {
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<void> {
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<string | undefined> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -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<string>();
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<string, ProcessStep[]>();
// wo_id별로 그룹핑 후, 같은 seq_no+process_name은 1건으로 합침 (배치 실행 이력 중복 제거)
const rawMap = new Map<string, ProcessStep[]>();
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<string, ProcessStep[]>();
rawMap.forEach((steps, woId) => {
// seq_no + process_name 기준으로 대표 1건만 (completed 우선)
const grouped = new Map<string, ProcessStep>();
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() {
<WorkCard
key={wi.id || `wi-${idx}`}
instruction={wi}
steps={processMap.get(wi.id) || []}
progress={computeProgress(wi.id, processMap)}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
/>
))}
</div>
@@ -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({
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">
{wi.completed_qty ?? 0} / {wi.qty ?? 0}
{completedQty} / {totalQty}
</span>
<span
className={cn(
@@ -0,0 +1,575 @@
"use client";
/**
* 설비운영모니터링 — 하드코딩 페이지
*
* 설비(equipment_mng) 목록 + 작업지시(work_instruction) 연결
* 실시간 카드 그리드 형태 모니터링 대시보드
*/
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 { cn } from "@/lib/utils";
import {
RefreshCw,
Clock,
Loader2,
Inbox,
Wrench,
Zap,
Pause,
Power,
} from "lucide-react";
/* ───── 상태 정의 ───── */
type OperationStatus = "running" | "idle" | "maintenance" | "off" | "unknown";
interface StatusConfig {
label: string;
color: string;
bg: string;
border: string;
bar: string;
icon: React.ReactNode;
badgeBg: string;
badgeText: string;
cardGlow: string;
}
const STATUS_MAP: Record<OperationStatus, StatusConfig> = {
running: {
label: "가동중",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/30",
bar: "bg-emerald-400",
icon: <Zap className="h-4 w-4" />,
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: <Pause className="h-4 w-4" />,
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: <Wrench className="h-4 w-4" />,
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: <Power className="h-4 w-4" />,
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: <Power className="h-4 w-4" />,
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<Equipment[]>([]);
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("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<OperationStatus, number> = {
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<string, WorkInstruction[]> = {};
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: <Inbox className="h-5 w-5" />,
},
{
label: "가동중",
count: stats.running,
status: "running",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/30",
icon: <Zap className="h-5 w-5" />,
},
{
label: "대기",
count: stats.idle,
status: "idle",
color: "text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/30",
icon: <Pause className="h-5 w-5" />,
},
{
label: "점검/수리",
count: stats.maintenance,
status: "maintenance",
color: "text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/30",
icon: <Wrench className="h-5 w-5" />,
},
{
label: "비가동",
count: stats.off + stats.unknown,
status: "off",
color: "text-gray-400",
bg: "bg-gray-500/10",
border: "border-gray-500/30",
icon: <Power className="h-5 w-5" />,
},
];
/* ── 필터 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 (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
{/* ── 헤더 ── */}
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
<h1 className="text-2xl font-bold tracking-tight"></h1>
</div>
<div className="flex items-center gap-3">
{/* 현재 시간 */}
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
<Button
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
autoRefresh
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button
variant="outline"
size="sm"
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
onClick={fetchData}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
className={cn(
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
card.bg,
card.border,
"hover:shadow-lg"
)}
>
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
{card.icon}
</div>
<div className="text-left">
<p className="text-xs text-gray-500">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
))}
</div>
{/* ── 필터 pill ── */}
<div className="flex flex-wrap gap-2">
{filterPills.map((pill) => (
<button
key={pill.value}
onClick={() => setFilterStatus(pill.value)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
filterStatus === pill.value
? cn(pill.color, "ring-1 ring-white/20")
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
</div>
{/* ── 로딩 ── */}
{loading && equipments.length === 0 && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
<span className="ml-3 text-gray-500"> ...</span>
</div>
)}
{/* ── 데이터 없음 ── */}
{!loading && equipments.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
<Inbox className="h-12 w-12 mb-3" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
{filteredEquipments.map((eq) => {
const status = resolveStatus(eq.operation_status);
const cfg = STATUS_MAP[status];
const utilization = getUtilization(eq);
const eqWIs = wiMap[eq.id] ?? [];
return (
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
{/* 상단: 설비명 + 상태 배지 */}
<div className="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-white truncate">
{eq.equipment_name || "이름 없음"}
</h3>
<p className="text-xs text-gray-500 mt-0.5 truncate">
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
</p>
</div>
<Badge
className={cn(
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
cfg.badgeBg,
cfg.badgeText
)}
>
{cfg.icon}
{cfg.label}
</Badge>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 정보 그리드 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
<div>
<span className="text-gray-500 text-xs"> </span>
<p className="text-white font-medium">-</p>
</div>
<div>
<span className="text-gray-500 text-xs"></span>
<p className="text-white font-medium">-</p>
</div>
<div>
<span className="text-gray-500 text-xs"></span>
<p className="text-white font-medium">
{eqWIs.length > 0 && eqWIs[0].worker_name
? eqWIs[0].worker_name
: "-"}
</p>
</div>
<div>
<span className="text-gray-500 text-xs"></span>
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 가동률 프로그레스 */}
<div className="px-4 pl-5 py-2.5">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-gray-500"></span>
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
{utilization !== null && (
<div
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
style={{ width: `${utilization}%` }}
/>
)}
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 현재 작업지시 */}
<div className="px-4 pl-5 py-2.5">
<p className="text-xs text-gray-500 mb-1"> </p>
{eqWIs.length > 0 ? (
<div className="space-y-1">
{eqWIs.slice(0, 2).map((wi) => (
<div key={wi.id} className="flex items-center gap-2 text-sm">
<span className="text-blue-400 font-mono text-xs shrink-0">
{wi.instruction_number || "-"}
</span>
<span className="text-gray-300 truncate">
{wi.item_name || "-"}
</span>
</div>
))}
{eqWIs.length > 2 && (
<p className="text-xs text-gray-600">+{eqWIs.length - 2} </p>
)}
</div>
) : (
<p className="text-sm text-gray-600 italic"> </p>
)}
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 센서 데이터 (PLC 미연동) */}
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-gray-600"></span>
<span className="text-gray-500 font-mono">-</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-600"></span>
<span className="text-gray-500 font-mono">-</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-600">RPM</span>
<span className="text-gray-500 font-mono">-</span>
</div>
</div>
</div>
);
})}
</div>
)}
{/* 필터 결과 없음 */}
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
<Inbox className="h-10 w-10 mb-2" />
<p> .</p>
</div>
)}
</div>
);
}
@@ -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<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
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<WorkInstruction[]>([]);
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
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<string>();
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<string, ProcessStep[]>();
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<string, ProcessStep[]>();
rawMap.forEach((steps, woId) => {
// seq_no + process_name 기준으로 대표 1건만 (completed 우선)
const grouped = new Map<string, ProcessStep>();
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 (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchData}
disabled={loading}
className="gap-1.5"
>
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
</Button>
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? "ON" : "OFF"}
</Button>
</div>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
label="대기중"
value={stats.waiting}
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
/>
<SummaryCard
icon={<Loader2 className="w-5 h-5" />}
label="진행중"
value={stats.inProgress}
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
/>
<SummaryCard
icon={<CheckCircle2 className="w-5 h-5" />}
label="완료"
value={stats.completed}
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
/>
<SummaryCard
icon={<TrendingUp className="w-5 h-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
/>
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? "default" : "outline"}
size="sm"
onClick={() => setActiveTab(tab)}
className={cn(
"min-w-[64px]",
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
)}
>
{tab}
{tab === "전체" && ` (${workInstructions.length})`}
{tab === "대기" && ` (${stats.waiting})`}
{tab === "진행중" && ` (${stats.inProgress})`}
{tab === "완료" && ` (${stats.completed})`}
</Button>
))}
</div>
{/* 로딩 상태 */}
{loading && workInstructions.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-10 h-10 animate-spin mb-3" />
<span className="text-sm"> ...</span>
</div>
)}
{/* 빈 상태 */}
{!loading && filteredInstructions.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Inbox className="w-12 h-12 mb-3" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
key={wi.id || `wi-${idx}`}
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
/>
))}
</div>
)}
</div>
);
}
// ─── 요약 카드 ─────────────────────────────────────────────
function SummaryCard({
icon,
label,
value,
colorClass,
}: {
icon: React.ReactNode;
label: string;
value: number | string;
colorClass: string;
}) {
return (
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-2xl font-bold text-foreground">{value}</span>
</div>
</div>
);
}
// ─── 작업 카드 ─────────────────────────────────────────────
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<string, string> = {
"대기": "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 (
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">
{wi.work_instruction_no}
</span>
{isUrgent && (
<Badge
variant="outline"
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
>
<AlertTriangle className="w-3 h-3" />
</Badge>
)}
</div>
<Badge variant="outline" className={cn("text-xs", statusBadge[progress])}>
{progress}
</Badge>
</div>
{/* 카드 본문 - 정보 */}
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
<InfoRow label="품목명" value={itemName} />
<InfoRow label="규격" value={spec} />
<InfoRow label="거래처" value={customerName} />
<InfoRow label="작업자" value={wi.worker || "-"} />
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
</div>
{/* 공정현황 */}
<div className="px-4 py-3 border-b">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-medium"></span>
{steps.length > 0 && (
<span className="text-xs text-muted-foreground">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex gap-1 flex-wrap">
{steps.map((step, idx) => {
const isDone = step.status === "completed";
const isCurrent = !isDone && idx === completedSteps;
return (
<span
key={`${step.wo_id}-${step.seq_no}-${idx}`}
className={cn(
"px-2 py-0.5 rounded text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
!isDone && !isCurrent && "bg-muted text-muted-foreground"
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div>
{/* 프로그레스바 */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
</div>
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</div>
);
}
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
);
}
@@ -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<ProcessRow[]>([]);
const [loading, setLoading] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<div className="flex items-center gap-3">
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
</h1>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Clock className="h-4 w-4" />
<span className="font-mono">
{currentTime.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchData}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="ml-1"></span>
</Button>
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh((p) => !p)}
className={cn(
autoRefresh &&
"bg-emerald-600 hover:bg-emerald-700 text-white",
)}
>
<Clock className="h-4 w-4 mr-1" />
{autoRefresh ? "ON" : "OFF"}
</Button>
</div>
</div>
{/* ── 본문 ── */}
<div className="flex-1 overflow-auto p-6 space-y-6">
{/* 요약 카드 */}
<div className="grid grid-cols-5 gap-4">
{summaryCards.map((card) => (
<div
key={card.label}
className={cn(
"rounded-xl bg-gradient-to-br p-5 shadow-md",
card.color,
)}
>
<p className="text-sm font-medium text-white/80">
{card.label}
</p>
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
{card.value}
{card.sub && (
<span className="ml-1 text-base font-normal text-white/70">
{card.sub}
</span>
)}
</p>
</div>
))}
</div>
{/* 검사유형 탭 */}
<div className="flex items-center gap-2">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
activeTab === tab.key
? "bg-emerald-600 text-white shadow"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
)}
>
{tab.label}
</button>
))}
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Search className="h-12 w-12 mb-4 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
</p>
</div>
) : loading && filteredRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Loader2 className="h-10 w-10 animate-spin mb-4" />
<p> ...</p>
</div>
) : filteredRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Inbox className="h-12 w-12 mb-4 opacity-40" />
<p className="text-lg font-medium"> </p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[90px] text-center">
</TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px] text-right">
</TableHead>
<TableHead className="min-w-[80px] text-right">
</TableHead>
<TableHead className="min-w-[80px] text-right">
</TableHead>
<TableHead className="min-w-[70px] text-right">
</TableHead>
<TableHead className="min-w-[160px] text-center">
</TableHead>
<TableHead className="min-w-[70px] text-center">
</TableHead>
<TableHead className="min-w-[80px] text-center">
</TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={row.no}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
>
<TableCell className="text-center text-sm text-gray-500">
{row.no}
</TableCell>
<TableCell className="font-mono text-sm">
{row.inspectionNo}
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={cn(
"text-xs",
badgeVariant("type", row.inspectionType),
)}
>
{row.inspectionType}
</Badge>
</TableCell>
<TableCell className="text-sm font-medium">
{row.itemName}
</TableCell>
<TableCell className="text-sm text-gray-500">
{row.spec}
</TableCell>
<TableCell className="text-right text-sm">
{fmt(row.inspectionQty)}
</TableCell>
<TableCell className="text-right text-sm text-emerald-600">
{fmt(row.goodQty)}
</TableCell>
<TableCell className="text-right text-sm text-red-600">
{fmt(row.defectQty)}
</TableCell>
<TableCell
className={cn(
"text-right text-sm",
badgeVariant("defectRate", row.defectRate),
)}
>
{pct(row.defectRate)}
</TableCell>
{/* 검사결과 프로그레스바 */}
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${goodPct}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${defectPct}%` }}
/>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
{pct(goodPct)}
</span>
</div>
</TableCell>
{/* 판정 배지 */}
<TableCell className="text-center">
<Badge
variant="outline"
className={cn(
"text-xs",
badgeVariant("result", row.result),
)}
>
{row.result}
</Badge>
</TableCell>
<TableCell className="text-center text-sm">
{row.inspectorName}
</TableCell>
<TableCell className="text-sm text-gray-500">
{row.inspectedAt !== "-"
? new Date(row.inspectedAt).toLocaleString(
"ko-KR",
{
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
)
: "-"}
</TableCell>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -137,6 +137,9 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/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 }),
+4 -1
View File
@@ -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));
+104
View File
@@ -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;
}