feat: Implement Mold Management API and Frontend Integration

- Added new API endpoints for mold management, including CRUD operations for molds, mold serials, inspections, and parts.
- Created the `moldRoutes` to handle requests related to mold management.
- Developed the `moldController` to manage the business logic for mold operations, ensuring proper company code filtering for data access.
- Integrated frontend API calls for mold management, allowing users to interact with the mold data seamlessly.
- Introduced a new component for displaying status counts, enhancing the user interface for monitoring mold statuses.

These additions improve the overall functionality and user experience in managing molds within the application.
This commit is contained in:
kjs 2026-03-09 13:15:41 +09:00
parent 27558787b0
commit 13506912d9
10 changed files with 1312 additions and 0 deletions

View File

@ -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); // 차량 운행 이력 관리

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}
}

View File

@ -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;

145
frontend/lib/api/mold.ts Normal file
View File

@ -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<T> {
success: boolean;
data?: T;
message?: string;
}
const handleResponse = async <T>(promise: Promise<any>): Promise<ApiResponse<T>> => {
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<string, string>) =>
handleResponse<MoldInfo[]>(apiClient.get("/mold", { params }));
export const getMoldDetail = (moldCode: string) =>
handleResponse<MoldInfo>(apiClient.get(`/mold/${moldCode}`));
export const createMold = (data: Partial<MoldInfo>) =>
handleResponse<MoldInfo>(apiClient.post("/mold", data));
export const updateMold = (moldCode: string, data: Partial<MoldInfo>) =>
handleResponse<MoldInfo>(apiClient.put(`/mold/${moldCode}`, data));
export const deleteMold = (moldCode: string) =>
handleResponse<void>(apiClient.delete(`/mold/${moldCode}`));
// 일련번호
export const getMoldSerials = (moldCode: string) =>
handleResponse<MoldSerial[]>(apiClient.get(`/mold/${moldCode}/serials`));
export const createMoldSerial = (moldCode: string, data: Partial<MoldSerial>) =>
handleResponse<MoldSerial>(apiClient.post(`/mold/${moldCode}/serials`, data));
export const deleteMoldSerial = (id: string) =>
handleResponse<void>(apiClient.delete(`/mold/serials/${id}`));
export const getMoldSerialSummary = (moldCode: string) =>
handleResponse<MoldSerialSummary>(apiClient.get(`/mold/${moldCode}/serial-summary`));
// 점검항목
export const getMoldInspections = (moldCode: string) =>
handleResponse<MoldInspectionItem[]>(apiClient.get(`/mold/${moldCode}/inspections`));
export const createMoldInspection = (moldCode: string, data: Partial<MoldInspectionItem>) =>
handleResponse<MoldInspectionItem>(apiClient.post(`/mold/${moldCode}/inspections`, data));
export const deleteMoldInspection = (id: string) =>
handleResponse<void>(apiClient.delete(`/mold/inspections/${id}`));
// 부품
export const getMoldParts = (moldCode: string) =>
handleResponse<MoldPart[]>(apiClient.get(`/mold/${moldCode}/parts`));
export const createMoldPart = (moldCode: string, data: Partial<MoldPart>) =>
handleResponse<MoldPart>(apiClient.post(`/mold/${moldCode}/parts`, data));
export const deleteMoldPart = (id: string) =>
handleResponse<void>(apiClient.delete(`/mold/parts/${id}`));

View File

@ -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"; // 상태별 카운트 카드
/**
*

View File

@ -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<StatusCountComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
formData,
...props
}) => {
const config = (component.componentConfig || {}) as StatusCountConfig;
const [counts, setCounts] = useState<Record<string, number>>({});
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<string, number> = {};
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 (
<div className="flex h-full w-full flex-col gap-2 rounded-lg border border-dashed border-gray-300 p-3">
{title && <div className="text-xs font-medium text-muted-foreground">{title}</div>}
<div className="flex flex-1 items-center justify-center gap-2">
{(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 (
<div
key={i}
className={`flex flex-1 flex-col items-center rounded-lg border ${colors.border} ${colors.bg} ${sz.card}`}
>
<span className={`font-bold ${colors.text} ${sz.number}`}>0</span>
<span className={`${colors.text} ${sz.label}`}>{item.label}</span>
</div>
);
}
)}
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col gap-2">
{title && <div className="text-sm font-medium text-foreground">{title}</div>}
<div className="flex flex-1 items-stretch gap-2">
{items.map((item, i) => {
const colors = getColorClasses(item.color);
const count = getCount(item);
return (
<div
key={item.value + i}
className={`flex flex-1 flex-col items-center justify-center rounded-lg border ${colors.border} ${colors.bg} ${sz.card} transition-shadow hover:shadow-sm`}
>
<span className={`font-bold ${colors.text} ${sz.number}`}>
{loading ? "-" : count}
</span>
<span className={`mt-0.5 ${colors.text} ${sz.label}`}>{item.label}</span>
</div>
);
})}
</div>
</div>
);
};
export const StatusCountWrapper: React.FC<StatusCountComponentProps> = (props) => {
return <StatusCountComponent {...props} />;
};

View File

@ -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<StatusCountConfig>) => 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<SearchableComboboxProps> = ({
value,
onSelect,
items,
placeholder,
searchPlaceholder,
emptyText,
disabled,
loading,
}) => {
const [open, setOpen] = useState(false);
if (loading) {
return (
<div className="flex h-8 items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
);
}
const selectedItem = items.find((item) => item.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="h-8 w-full justify-between text-xs font-normal"
>
<span className="truncate">
{selectedItem
? selectedItem.sublabel
? `${selectedItem.label} (${selectedItem.sublabel})`
: selectedItem.label
: placeholder}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs">
{emptyText}
</CommandEmpty>
<CommandGroup>
{items.map((item) => (
<CommandItem
key={item.value}
value={`${item.label} ${item.sublabel || ""} ${item.value}`}
onSelect={() => {
onSelect(item.value === value ? "" : item.value);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === item.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{item.label}</span>
{item.sublabel && (
<span className="text-[10px] text-muted-foreground">
{item.sublabel}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
config,
onChange,
}) => {
const items = config.items || [];
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [columns, setColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [entityJoins, setEntityJoins] = useState<EntityJoinConfig[]>([]);
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 (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || ""}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="일련번호 현황"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<SearchableCombobox
value={config.tableName || ""}
onSelect={(v) => {
onChange({ tableName: v, statusColumn: "", relationColumn: "", parentColumn: "" });
}}
items={tableComboItems}
placeholder="테이블 선택"
searchPlaceholder="테이블명 또는 라벨 검색..."
emptyText="테이블을 찾을 수 없습니다"
loading={loadingTables}
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<SearchableCombobox
value={config.statusColumn || ""}
onSelect={(v) => handleChange("statusColumn", v)}
items={columnComboItems}
placeholder={config.tableName ? "상태 컬럼 선택" : "테이블을 먼저 선택"}
searchPlaceholder="컬럼명 또는 라벨 검색..."
emptyText="컬럼을 찾을 수 없습니다"
disabled={!config.tableName}
loading={loadingColumns}
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
{loadingJoins ? (
<div className="flex h-8 items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : entityJoins.length > 0 ? (
<SearchableCombobox
value={currentRelationValue}
onSelect={(v) => {
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}
/>
) : (
<div className="rounded-md border border-dashed p-2">
<p className="text-center text-[10px] text-muted-foreground">
{config.tableName ? "설정된 엔티티 관계가 없습니다" : "테이블을 먼저 선택하세요"}
</p>
</div>
)}
{config.relationColumn && config.parentColumn && (
<p className="text-[10px] text-muted-foreground">
FK: <span className="font-medium">{config.relationColumn}</span>
{" -> "}
: <span className="font-medium">{config.parentColumn}</span>
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.cardSize || "md"}
onValueChange={(v) => handleChange("cardSize", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm" className="text-xs"></SelectItem>
<SelectItem value="md" className="text-xs"></SelectItem>
<SelectItem value="lg" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button variant="ghost" size="sm" onClick={addItem} className="h-6 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{items.map((item: StatusCountItem, i: number) => (
<div key={i} className="space-y-1 rounded-md border p-2">
<div className="flex items-center gap-1">
<Input
value={item.value}
onChange={(e) => handleItemChange(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs"
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(i)}
className="h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
<div className="flex gap-1">
<Input
value={item.label}
onChange={(e) => handleItemChange(i, "label", e.target.value)}
placeholder="표시 라벨"
className="h-7 text-xs"
/>
<Select
value={item.color}
onValueChange={(v) => handleItemChange(i, "color", v)}
>
<SelectTrigger className="h-7 w-24 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c} className="text-xs">
<div className="flex items-center gap-1">
<div
className={`h-3 w-3 rounded-full ${STATUS_COLOR_MAP[c].bg} border ${STATUS_COLOR_MAP[c].border}`}
/>
{c}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -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 <StatusCountComponent {...this.props} />;
}
}
StatusCountRenderer.registerSelf();

View File

@ -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";

View File

@ -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<string, { bg: string; text: string; border: string }> = {
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" },
};