diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ad4ced77..914f608c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -138,6 +138,7 @@ import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 +import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -326,6 +327,7 @@ app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 +app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts new file mode 100644 index 00000000..4683dd75 --- /dev/null +++ b/backend-node/src/controllers/moldController.ts @@ -0,0 +1,470 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +// ============================================ +// 금형 마스터 CRUD +// ============================================ + +export async function getMoldList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { mold_code, mold_name, mold_type, operation_status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (mold_code) { + conditions.push(`mold_code ILIKE $${paramIndex}`); + params.push(`%${mold_code}%`); + paramIndex++; + } + if (mold_name) { + conditions.push(`mold_name ILIKE $${paramIndex}`); + params.push(`%${mold_name}%`); + paramIndex++; + } + if (mold_type) { + conditions.push(`mold_type = $${paramIndex}`); + params.push(mold_type); + paramIndex++; + } + if (operation_status) { + conditions.push(`operation_status = $${paramIndex}`); + params.push(operation_status); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = `SELECT * FROM mold_mng ${whereClause} ORDER BY created_date DESC`; + const result = await query(sql, params); + + logger.info("금형 목록 조회", { companyCode, count: result.length }); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("금형 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getMoldDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM mold_mng WHERE mold_code = $1 LIMIT 1`; + params = [moldCode]; + } else { + sql = `SELECT * FROM mold_mng WHERE mold_code = $1 AND company_code = $2 LIMIT 1`; + params = [moldCode, companyCode]; + } + + const result = await query(sql, params); + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("금형 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + mold_code, mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, cavity_count, + shot_count, mold_quantity, base_input_qty, operation_status, + remarks, image_path, memo, + } = req.body; + + if (!mold_code || !mold_name) { + res.status(400).json({ success: false, message: "금형코드와 금형명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_mng ( + company_code, mold_code, mold_name, mold_type, category, + manufacturer, manufacturing_number, manufacturing_date, + cavity_count, shot_count, mold_quantity, base_input_qty, + operation_status, remarks, image_path, memo, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + RETURNING * + `; + const params = [ + companyCode, mold_code, mold_name, mold_type || null, category || null, + manufacturer || null, manufacturing_number || null, manufacturing_date || null, + cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0, + operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId, + ]; + + const result = await query(sql, params); + logger.info("금형 생성", { companyCode, moldCode: mold_code }); + res.json({ success: true, data: result[0], message: "금형이 등록되었습니다." }); + } catch (error: any) { + if (error.code === "23505") { + res.status(409).json({ success: false, message: "이미 존재하는 금형코드입니다." }); + return; + } + logger.error("금형 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + const { + mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, cavity_count, + shot_count, mold_quantity, base_input_qty, operation_status, + remarks, image_path, memo, + } = req.body; + + const sql = ` + UPDATE mold_mng SET + mold_name = COALESCE($1, mold_name), + mold_type = $2, category = $3, manufacturer = $4, + manufacturing_number = $5, manufacturing_date = $6, + cavity_count = COALESCE($7, cavity_count), + shot_count = COALESCE($8, shot_count), + mold_quantity = COALESCE($9, mold_quantity), + base_input_qty = COALESCE($10, base_input_qty), + operation_status = COALESCE($11, operation_status), + remarks = $12, image_path = $13, memo = $14, + updated_date = NOW() + WHERE mold_code = $15 AND company_code = $16 + RETURNING * + `; + const params = [ + mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, + cavity_count, shot_count, mold_quantity, base_input_qty, + operation_status, remarks, image_path, memo, + moldCode, companyCode, + ]; + + const result = await query(sql, params); + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + logger.info("금형 수정", { companyCode, moldCode }); + res.json({ success: true, data: result[0], message: "금형이 수정되었습니다." }); + } catch (error: any) { + logger.error("금형 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + // 관련 데이터 먼저 삭제 + await query(`DELETE FROM mold_serial WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + await query(`DELETE FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + await query(`DELETE FROM mold_part WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + + const result = await query( + `DELETE FROM mold_mng WHERE mold_code = $1 AND company_code = $2 RETURNING id`, + [moldCode, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + logger.info("금형 삭제", { companyCode, moldCode }); + res.json({ success: true, message: "금형이 삭제되었습니다." }); + } catch (error: any) { + logger.error("금형 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 일련번호 CRUD +// ============================================ + +export async function getMoldSerials(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_serial WHERE mold_code = $1 AND company_code = $2 ORDER BY serial_number`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("일련번호 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; + + if (!serial_number) { + res.status(400).json({ success: false, message: "일련번호는 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING * + `; + const params = [ + companyCode, moldCode, serial_number, status || "STORED", + progress || 0, work_description || null, manager || null, + completion_date || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "일련번호가 등록되었습니다." }); + } catch (error: any) { + if (error.code === "23505") { + res.status(409).json({ success: false, message: "이미 존재하는 일련번호입니다." }); + return; + } + logger.error("일련번호 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_serial WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "일련번호가 삭제되었습니다." }); + } catch (error: any) { + logger.error("일련번호 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 점검항목 CRUD +// ============================================ + +export async function getMoldInspections(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("점검항목 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { + inspection_item, inspection_cycle, inspection_method, + inspection_content, lower_limit, upper_limit, unit, + is_active, checklist, remarks, + } = req.body; + + if (!inspection_item) { + res.status(400).json({ success: false, message: "점검항목명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_inspection_item ( + company_code, mold_code, inspection_item, inspection_cycle, + inspection_method, inspection_content, lower_limit, upper_limit, + unit, is_active, checklist, remarks, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + const params = [ + companyCode, moldCode, inspection_item, inspection_cycle || null, + inspection_method || null, inspection_content || null, + lower_limit || null, upper_limit || null, unit || null, + is_active || "Y", checklist || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "점검항목이 등록되었습니다." }); + } catch (error: any) { + logger.error("점검항목 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_inspection_item WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "점검항목이 삭제되었습니다." }); + } catch (error: any) { + logger.error("점검항목 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 부품 CRUD +// ============================================ + +export async function getMoldParts(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_part WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("부품 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { + part_name, replacement_cycle, unit, specification, + manufacturer, manufacturer_code, image_path, remarks, + } = req.body; + + if (!part_name) { + res.status(400).json({ success: false, message: "부품명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_part ( + company_code, mold_code, part_name, replacement_cycle, + unit, specification, manufacturer, manufacturer_code, + image_path, remarks, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING * + `; + const params = [ + companyCode, moldCode, part_name, replacement_cycle || null, + unit || null, specification || null, manufacturer || null, + manufacturer_code || null, image_path || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "부품이 등록되었습니다." }); + } catch (error: any) { + logger.error("부품 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_part WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "부품이 삭제되었습니다." }); + } catch (error: any) { + logger.error("부품 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 일련번호 현황 집계 +// ============================================ + +export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = ` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use, + COUNT(*) FILTER (WHERE status = 'REPAIR') as repair, + COUNT(*) FILTER (WHERE status = 'STORED') as stored, + COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed + FROM mold_serial + WHERE mold_code = $1 AND company_code = $2 + `; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("일련번호 현황 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/moldRoutes.ts b/backend-node/src/routes/moldRoutes.ts new file mode 100644 index 00000000..76eaa67d --- /dev/null +++ b/backend-node/src/routes/moldRoutes.ts @@ -0,0 +1,49 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getMoldList, + getMoldDetail, + createMold, + updateMold, + deleteMold, + getMoldSerials, + createMoldSerial, + deleteMoldSerial, + getMoldInspections, + createMoldInspection, + deleteMoldInspection, + getMoldParts, + createMoldPart, + deleteMoldPart, + getMoldSerialSummary, +} from "../controllers/moldController"; + +const router = express.Router(); +router.use(authenticateToken); + +// 금형 마스터 +router.get("/", getMoldList); +router.get("/:moldCode", getMoldDetail); +router.post("/", createMold); +router.put("/:moldCode", updateMold); +router.delete("/:moldCode", deleteMold); + +// 일련번호 +router.get("/:moldCode/serials", getMoldSerials); +router.post("/:moldCode/serials", createMoldSerial); +router.delete("/serials/:id", deleteMoldSerial); + +// 일련번호 현황 집계 +router.get("/:moldCode/serial-summary", getMoldSerialSummary); + +// 점검항목 +router.get("/:moldCode/inspections", getMoldInspections); +router.post("/:moldCode/inspections", createMoldInspection); +router.delete("/inspections/:id", deleteMoldInspection); + +// 부품 +router.get("/:moldCode/parts", getMoldParts); +router.post("/:moldCode/parts", createMoldPart); +router.delete("/parts/:id", deleteMoldPart); + +export default router; diff --git a/frontend/lib/api/mold.ts b/frontend/lib/api/mold.ts new file mode 100644 index 00000000..524828b7 --- /dev/null +++ b/frontend/lib/api/mold.ts @@ -0,0 +1,145 @@ +import { apiClient } from "./client"; + +export interface MoldInfo { + id: string; + company_code: string; + mold_code: string; + mold_name: string; + mold_type: string | null; + category: string | null; + manufacturer: string | null; + manufacturing_number: string | null; + manufacturing_date: string | null; + cavity_count: number; + shot_count: number; + mold_quantity: number; + base_input_qty: number; + operation_status: string; + remarks: string | null; + image_path: string | null; + memo: string | null; + created_date: string; + updated_date: string; + writer: string | null; +} + +export interface MoldSerial { + id: string; + company_code: string; + mold_code: string; + serial_number: string; + status: string; + progress: number; + work_description: string | null; + manager: string | null; + completion_date: string | null; + remarks: string | null; + created_date: string; +} + +export interface MoldInspectionItem { + id: string; + company_code: string; + mold_code: string; + inspection_item: string; + inspection_cycle: string | null; + inspection_method: string | null; + inspection_content: string | null; + lower_limit: string | null; + upper_limit: string | null; + unit: string | null; + is_active: string; + checklist: string | null; + remarks: string | null; + created_date: string; +} + +export interface MoldPart { + id: string; + company_code: string; + mold_code: string; + part_name: string; + replacement_cycle: string | null; + unit: string | null; + specification: string | null; + manufacturer: string | null; + manufacturer_code: string | null; + image_path: string | null; + remarks: string | null; + created_date: string; +} + +export interface MoldSerialSummary { + total: number; + in_use: number; + repair: number; + stored: number; + disposed: number; +} + +interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +const handleResponse = async (promise: Promise): Promise> => { + try { + const response = await promise; + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message || "오류가 발생했습니다.", + }; + } +}; + +// 금형 마스터 +export const getMoldList = (params?: Record) => + handleResponse(apiClient.get("/mold", { params })); + +export const getMoldDetail = (moldCode: string) => + handleResponse(apiClient.get(`/mold/${moldCode}`)); + +export const createMold = (data: Partial) => + handleResponse(apiClient.post("/mold", data)); + +export const updateMold = (moldCode: string, data: Partial) => + handleResponse(apiClient.put(`/mold/${moldCode}`, data)); + +export const deleteMold = (moldCode: string) => + handleResponse(apiClient.delete(`/mold/${moldCode}`)); + +// 일련번호 +export const getMoldSerials = (moldCode: string) => + handleResponse(apiClient.get(`/mold/${moldCode}/serials`)); + +export const createMoldSerial = (moldCode: string, data: Partial) => + handleResponse(apiClient.post(`/mold/${moldCode}/serials`, data)); + +export const deleteMoldSerial = (id: string) => + handleResponse(apiClient.delete(`/mold/serials/${id}`)); + +export const getMoldSerialSummary = (moldCode: string) => + handleResponse(apiClient.get(`/mold/${moldCode}/serial-summary`)); + +// 점검항목 +export const getMoldInspections = (moldCode: string) => + handleResponse(apiClient.get(`/mold/${moldCode}/inspections`)); + +export const createMoldInspection = (moldCode: string, data: Partial) => + handleResponse(apiClient.post(`/mold/${moldCode}/inspections`, data)); + +export const deleteMoldInspection = (id: string) => + handleResponse(apiClient.delete(`/mold/inspections/${id}`)); + +// 부품 +export const getMoldParts = (moldCode: string) => + handleResponse(apiClient.get(`/mold/${moldCode}/parts`)); + +export const createMoldPart = (moldCode: string, data: Partial) => + handleResponse(apiClient.post(`/mold/${moldCode}/parts`, data)); + +export const deleteMoldPart = (id: string) => + handleResponse(apiClient.delete(`/mold/parts/${id}`)); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 5fb50b5e..174235ed 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -118,6 +118,7 @@ import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화 +import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx new file mode 100644 index 00000000..fe25bd68 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { StatusCountConfig, StatusCountItem, STATUS_COLOR_MAP } from "./types"; +import { apiClient } from "@/lib/api/client"; + +export interface StatusCountComponentProps extends ComponentRendererProps {} + +export const StatusCountComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + formData, + ...props +}) => { + const config = (component.componentConfig || {}) as StatusCountConfig; + const [counts, setCounts] = useState>({}); + const [loading, setLoading] = useState(false); + + const { + title, + tableName, + statusColumn = "status", + relationColumn, + parentColumn, + items = [], + cardSize = "md", + } = config; + + const parentValue = formData?.[parentColumn || relationColumn]; + + const fetchCounts = useCallback(async () => { + if (!tableName || !parentValue || isDesignMode) return; + + setLoading(true); + try { + const res = await apiClient.get(`/table-management/data/${tableName}`, { + params: { + autoFilter: "true", + [relationColumn]: parentValue, + }, + }); + + const rows: any[] = res.data?.data || res.data?.rows || res.data || []; + const grouped: Record = {}; + + for (const row of rows) { + const val = row[statusColumn] || "UNKNOWN"; + grouped[val] = (grouped[val] || 0) + 1; + } + + setCounts(grouped); + } catch (err) { + console.error("[v2-status-count] 데이터 조회 실패:", err); + setCounts({}); + } finally { + setLoading(false); + } + }, [tableName, statusColumn, relationColumn, parentValue, isDesignMode]); + + useEffect(() => { + fetchCounts(); + }, [fetchCounts]); + + const getColorClasses = (color: string) => { + if (STATUS_COLOR_MAP[color]) return STATUS_COLOR_MAP[color]; + return { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" }; + }; + + const getCount = (item: StatusCountItem) => { + if (item.value === "__TOTAL__") { + return Object.values(counts).reduce((sum, c) => sum + c, 0); + } + const values = item.value.split(",").map((v) => v.trim()); + return values.reduce((sum, v) => sum + (counts[v] || 0), 0); + }; + + const sizeClasses = { + sm: { card: "px-3 py-2", number: "text-xl", label: "text-[10px]" }, + md: { card: "px-4 py-3", number: "text-2xl", label: "text-xs" }, + lg: { card: "px-6 py-4", number: "text-3xl", label: "text-sm" }, + }; + + const sz = sizeClasses[cardSize] || sizeClasses.md; + + if (isDesignMode && !parentValue) { + return ( +
+ {title &&
{title}
} +
+ {(items.length > 0 ? items : [{ label: "상태1", color: "green" }, { label: "상태2", color: "blue" }, { label: "상태3", color: "orange" }]).map( + (item: any, i: number) => { + const colors = getColorClasses(item.color || "gray"); + return ( +
+ 0 + {item.label} +
+ ); + } + )} +
+
+ ); + } + + return ( +
+ {title &&
{title}
} +
+ {items.map((item, i) => { + const colors = getColorClasses(item.color); + const count = getCount(item); + + return ( +
+ + {loading ? "-" : count} + + {item.label} +
+ ); + })} +
+
+ ); +}; + +export const StatusCountWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx new file mode 100644 index 00000000..bd029ab3 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx @@ -0,0 +1,424 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { StatusCountConfig, StatusCountItem, STATUS_COLOR_MAP } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi, EntityJoinConfig } from "@/lib/api/entityJoin"; +import { Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface StatusCountConfigPanelProps { + config: StatusCountConfig; + onChange: (config: Partial) => void; +} + +const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP); + +interface SearchableComboboxProps { + value: string; + onSelect: (value: string) => void; + items: Array<{ value: string; label: string; sublabel?: string }>; + placeholder: string; + searchPlaceholder: string; + emptyText: string; + disabled?: boolean; + loading?: boolean; +} + +const SearchableCombobox: React.FC = ({ + value, + onSelect, + items, + placeholder, + searchPlaceholder, + emptyText, + disabled, + loading, +}) => { + const [open, setOpen] = useState(false); + + if (loading) { + return ( +
+ 로딩중... +
+ ); + } + + const selectedItem = items.find((item) => item.value === value); + + return ( + + + + + + + + + + {emptyText} + + + {items.map((item) => ( + { + onSelect(item.value === value ? "" : item.value); + setOpen(false); + }} + className="text-xs" + > + +
+ {item.label} + {item.sublabel && ( + + {item.sublabel} + + )} +
+
+ ))} +
+
+
+
+
+ ); +}; + +export const StatusCountConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const items = config.items || []; + const [tables, setTables] = useState>([]); + const [columns, setColumns] = useState>([]); + const [entityJoins, setEntityJoins] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingJoins, setLoadingJoins] = useState(false); + + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const result = await tableTypeApi.getTables(); + setTables( + (result || []).map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.tableName || t.table_name, + })) + ); + } catch (err) { + console.error("테이블 목록 로드 실패:", err); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + useEffect(() => { + if (!config.tableName) { + setColumns([]); + setEntityJoins([]); + return; + } + + const loadColumns = async () => { + setLoadingColumns(true); + try { + const result = await tableTypeApi.getColumns(config.tableName); + setColumns( + (result || []).map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name, + })) + ); + } catch (err) { + console.error("컬럼 목록 로드 실패:", err); + } finally { + setLoadingColumns(false); + } + }; + + const loadEntityJoins = async () => { + setLoadingJoins(true); + try { + const result = await entityJoinApi.getEntityJoinConfigs(config.tableName); + setEntityJoins(result?.joinConfigs || []); + } catch (err) { + console.error("엔티티 조인 설정 로드 실패:", err); + setEntityJoins([]); + } finally { + setLoadingJoins(false); + } + }; + + loadColumns(); + loadEntityJoins(); + }, [config.tableName]); + + const handleChange = (key: keyof StatusCountConfig, value: any) => { + onChange({ [key]: value }); + }; + + const handleItemChange = (index: number, key: keyof StatusCountItem, value: string) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [key]: value }; + handleChange("items", newItems); + }; + + const addItem = () => { + handleChange("items", [ + ...items, + { value: "", label: "새 상태", color: "gray" }, + ]); + }; + + const removeItem = (index: number) => { + handleChange( + "items", + items.filter((_: StatusCountItem, i: number) => i !== index) + ); + }; + + const tableComboItems = tables.map((t) => ({ + value: t.tableName, + label: t.displayName, + sublabel: t.displayName !== t.tableName ? t.tableName : undefined, + })); + + const columnComboItems = columns.map((c) => ({ + value: c.columnName, + label: c.columnLabel, + sublabel: c.columnLabel !== c.columnName ? c.columnName : undefined, + })); + + const relationComboItems = entityJoins.map((ej) => { + const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable; + return { + value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`, + label: `${ej.sourceColumn} -> ${refTableLabel}`, + sublabel: `${ej.referenceTable}.${ej.referenceColumn}`, + }; + }); + + const currentRelationValue = config.relationColumn && config.parentColumn + ? relationComboItems.find((item) => { + const [srcCol] = item.value.split("::"); + return srcCol === config.relationColumn; + })?.value || "" + : ""; + + return ( +
+
상태별 카운트 설정
+ +
+ + handleChange("title", e.target.value)} + placeholder="일련번호 현황" + className="h-8 text-xs" + /> +
+ +
+ + { + onChange({ tableName: v, statusColumn: "", relationColumn: "", parentColumn: "" }); + }} + items={tableComboItems} + placeholder="테이블 선택" + searchPlaceholder="테이블명 또는 라벨 검색..." + emptyText="테이블을 찾을 수 없습니다" + loading={loadingTables} + /> +
+ +
+ + handleChange("statusColumn", v)} + items={columnComboItems} + placeholder={config.tableName ? "상태 컬럼 선택" : "테이블을 먼저 선택"} + searchPlaceholder="컬럼명 또는 라벨 검색..." + emptyText="컬럼을 찾을 수 없습니다" + disabled={!config.tableName} + loading={loadingColumns} + /> +
+ +
+ + {loadingJoins ? ( +
+ 로딩중... +
+ ) : entityJoins.length > 0 ? ( + { + if (!v) { + onChange({ relationColumn: "", parentColumn: "" }); + return; + } + const [sourceCol, refPart] = v.split("::"); + const [refTable, refCol] = refPart.split("."); + onChange({ relationColumn: sourceCol, parentColumn: refCol }); + }} + items={relationComboItems} + placeholder="엔티티 관계 선택" + searchPlaceholder="관계 검색..." + emptyText="엔티티 관계가 없습니다" + disabled={!config.tableName} + /> + ) : ( +
+

+ {config.tableName ? "설정된 엔티티 관계가 없습니다" : "테이블을 먼저 선택하세요"} +

+
+ )} + {config.relationColumn && config.parentColumn && ( +

+ 자식 FK: {config.relationColumn} + {" -> "} + 부모 매칭: {config.parentColumn} +

+ )} +
+ +
+ + +
+ +
+
+ + +
+ + {items.map((item: StatusCountItem, i: number) => ( +
+
+ handleItemChange(i, "value", e.target.value)} + placeholder="상태값 (예: IN_USE)" + className="h-7 text-xs" + /> + +
+
+ handleItemChange(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 text-xs" + /> + +
+
+ ))} +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx new file mode 100644 index 00000000..feec6b82 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2StatusCountDefinition } from "./index"; +import { StatusCountComponent } from "./StatusCountComponent"; + +export class StatusCountRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2StatusCountDefinition; + + render(): React.ReactElement { + return ; + } +} + +StatusCountRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/v2-status-count/index.ts b/frontend/lib/registry/components/v2-status-count/index.ts new file mode 100644 index 00000000..27495f0c --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/index.ts @@ -0,0 +1,38 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { StatusCountWrapper } from "./StatusCountComponent"; +import { StatusCountConfigPanel } from "./StatusCountConfigPanel"; + +export const V2StatusCountDefinition = createComponentDefinition({ + id: "v2-status-count", + name: "상태별 카운트", + nameEng: "Status Count", + description: "관련 테이블의 상태별 데이터 건수를 카드 형태로 표시하는 범용 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: StatusCountWrapper, + configPanel: StatusCountConfigPanel, + defaultConfig: { + title: "상태 현황", + tableName: "", + statusColumn: "status", + relationColumn: "", + parentColumn: "", + items: [ + { value: "ACTIVE", label: "사용중", color: "blue" }, + { value: "STANDBY", label: "대기", color: "green" }, + { value: "REPAIR", label: "수리중", color: "orange" }, + { value: "DISPOSED", label: "폐기", color: "red" }, + ], + cardSize: "md", + }, + defaultSize: { width: 800, height: 100 }, + icon: "BarChart3", + tags: ["상태", "카운트", "통계", "현황", "v2"], + version: "1.0.0", + author: "개발팀", +}); + +export type { StatusCountConfig, StatusCountItem } from "./types"; diff --git a/frontend/lib/registry/components/v2-status-count/types.ts b/frontend/lib/registry/components/v2-status-count/types.ts new file mode 100644 index 00000000..b1e4985a --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/types.ts @@ -0,0 +1,29 @@ +import { ComponentConfig } from "@/types/component"; + +export interface StatusCountItem { + value: string; + label: string; + color: string; // "green" | "blue" | "orange" | "red" | "gray" | "purple" | hex color +} + +export interface StatusCountConfig extends ComponentConfig { + title?: string; + tableName: string; + statusColumn: string; + relationColumn: string; + parentColumn?: string; + items: StatusCountItem[]; + showTotal?: boolean; + cardSize?: "sm" | "md" | "lg"; +} + +export const STATUS_COLOR_MAP: Record = { + green: { bg: "bg-emerald-50", text: "text-emerald-600", border: "border-emerald-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-500", border: "border-orange-200" }, + red: { bg: "bg-red-50", text: "text-red-500", border: "border-red-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-500", border: "border-gray-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + yellow: { bg: "bg-yellow-50", text: "text-yellow-600", border: "border-yellow-200" }, + cyan: { bg: "bg-cyan-50", text: "text-cyan-600", border: "border-cyan-200" }, +};