jskim-node #406

Merged
kjs merged 7 commits from jskim-node into main 2026-03-09 18:05:31 +09:00
10 changed files with 1312 additions and 0 deletions
Showing only changes of commit 13506912d9 - Show all commits

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" },
};