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:
@@ -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 키 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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("🔄 스마트공장 로그 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user