Compare commits
19 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
ef7a6e73fb | |
|
|
5ea40ddb01 | |
|
|
43707cb9a3 | |
|
|
52f25030a4 | |
|
|
202d678e8b | |
|
|
3841611af5 | |
|
|
109b22a99c | |
|
|
316ce30663 | |
|
|
7821bf47ef | |
|
|
123da4b0d5 | |
|
|
c98b2ccb43 | |
|
|
4d6783e508 | |
|
|
2b4b7819c5 | |
|
|
f6a02b5182 | |
|
|
13506912d9 | |
|
|
27558787b0 | |
|
|
1ee946d712 | |
|
|
db31b02180 | |
|
|
ea6c5ac43c |
|
|
@ -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); // 차량 운행 이력 관리
|
||||
|
|
|
|||
|
|
@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async (
|
|||
|
||||
const group = groupResult.rows[0];
|
||||
|
||||
// 부모 카테고리 값 조회 (table_column_category_values에서)
|
||||
// 부모 카테고리 값 조회 (category_values에서)
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
value_code as value,
|
||||
value_label as label,
|
||||
value_order as display_order
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async (
|
|||
|
||||
const group = groupResult.rows[0];
|
||||
|
||||
// 자식 카테고리 값 조회 (table_column_category_values에서)
|
||||
// 자식 카테고리 값 조회 (category_values에서)
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
value_code as value,
|
||||
value_label as label,
|
||||
value_order as display_order
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
|
|||
|
|
@ -417,10 +417,10 @@ export class EntityJoinController {
|
|||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||
const joinConfigs = allJoinConfigs.filter(
|
||||
(config) => config.referenceTable !== "table_column_category_values"
|
||||
(config) => config.referenceTable !== "category_values"
|
||||
);
|
||||
|
||||
if (joinConfigs.length === 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,497 @@
|
|||
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;
|
||||
|
||||
let finalSerialNumber = serial_number;
|
||||
|
||||
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
|
||||
if (!finalSerialNumber) {
|
||||
try {
|
||||
const { numberingRuleService } = await import("../services/numberingRuleService");
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
"mold_serial",
|
||||
"serial_number"
|
||||
);
|
||||
|
||||
if (rule) {
|
||||
// formData에 mold_code를 포함 (reference 파트에서 참조)
|
||||
const formData = { mold_code: moldCode, ...req.body };
|
||||
finalSerialNumber = await numberingRuleService.allocateCode(
|
||||
rule.ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
|
||||
}
|
||||
} catch (numError: any) {
|
||||
logger.error("일련번호 자동 채번 실패", { error: numError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSerialNumber) {
|
||||
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, finalSerialNumber, 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -405,6 +405,30 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
// 테이블+컬럼 기반 채번 규칙 조회 (메인 API)
|
||||
router.get(
|
||||
"/by-column/:tableName/:columnName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== 테스트 테이블용 API ====================
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
|
||||
|
|
|
|||
|
|
@ -3019,3 +3019,72 @@ export async function toggleColumnUnique(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일)
|
||||
*
|
||||
* @route GET /api/table-management/numbering-columns
|
||||
*/
|
||||
export async function getNumberingColumnsByCompany(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드를 확인할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
|
||||
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
COALESCE(
|
||||
tl.table_label,
|
||||
initcap(replace(ttc.table_name, '_', ' '))
|
||||
) AS "tableLabel",
|
||||
ttc.column_name AS "columnName",
|
||||
COALESCE(
|
||||
ttc.column_label,
|
||||
initcap(replace(ttc.column_name, '_', ' '))
|
||||
) AS "columnLabel",
|
||||
ttc.input_type AS "inputType"
|
||||
FROM table_type_columns ttc
|
||||
LEFT JOIN table_labels tl
|
||||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'numbering'
|
||||
AND ttc.company_code = $1
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
|
||||
|
||||
logger.info("채번 컬럼 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: columnsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("채번 컬럼 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
||||
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
|
|
@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
|||
*/
|
||||
router.get("/category-columns", getCategoryColumnsByCompany);
|
||||
|
||||
/**
|
||||
* 회사 기준 모든 채번 타입 컬럼 조회
|
||||
* GET /api/table-management/numbering-columns
|
||||
*/
|
||||
router.get("/numbering-columns", getNumberingColumnsByCompany);
|
||||
|
||||
/**
|
||||
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||
* GET /api/table-management/menu/:menuObjid/category-columns
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export class EntityJoinService {
|
|||
|
||||
if (column.input_type === "category") {
|
||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||
referenceTable = referenceTable || "table_column_category_values";
|
||||
referenceTable = referenceTable || "category_values";
|
||||
referenceColumn = referenceColumn || "value_code";
|
||||
displayColumn = displayColumn || "value_label";
|
||||
|
||||
|
|
@ -308,7 +308,7 @@ export class EntityJoinService {
|
|||
const usedAliasesForColumns = new Set<string>();
|
||||
|
||||
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
|
||||
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
|
||||
// (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
|
||||
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
||||
if (
|
||||
!acc.some(
|
||||
|
|
@ -336,7 +336,7 @@ export class EntityJoinService {
|
|||
counter++;
|
||||
}
|
||||
usedAliasesForColumns.add(alias);
|
||||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
||||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응)
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
aliasMap.set(aliasKey, alias);
|
||||
logger.info(
|
||||
|
|
@ -455,9 +455,10 @@ export class EntityJoinService {
|
|||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
// category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (config.referenceTable === "category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
|
||||
}
|
||||
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
|
|
@ -528,10 +529,10 @@ export class EntityJoinService {
|
|||
return "join";
|
||||
}
|
||||
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||
if (config.referenceTable === "category_values") {
|
||||
logger.info(
|
||||
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||
`🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||
);
|
||||
return "join";
|
||||
}
|
||||
|
|
@ -723,10 +724,10 @@ export class EntityJoinService {
|
|||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
// category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (config.referenceTable === "category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
|
||||
}
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
|
|
|
|||
|
|
@ -494,7 +494,7 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
* numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
|
|
@ -502,34 +502,60 @@ class MasterDetailExcelService {
|
|||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
// 1. table_type_columns에서 numbering 타입인지 확인
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
const ttcParams = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
const ttcResult = await query<any>(
|
||||
`SELECT input_type FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
AND input_type = 'numbering' LIMIT 1`,
|
||||
ttcParams
|
||||
);
|
||||
|
||||
// 채번 타입인 행 찾기 (회사별 우선)
|
||||
for (const row of result) {
|
||||
if (row.input_type === "numbering") {
|
||||
if (ttcResult.length === 0) return null;
|
||||
|
||||
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
|
||||
const ruleCompanyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const ruleParams = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const ruleResult = await query<any>(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
ruleParams
|
||||
);
|
||||
|
||||
if (ruleResult.length > 0) {
|
||||
return { numberingRuleId: ruleResult[0].rule_id };
|
||||
}
|
||||
|
||||
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
|
||||
const fallbackResult = await query<any>(
|
||||
`SELECT detail_settings FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
AND input_type = 'numbering'
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
ttcParams
|
||||
);
|
||||
|
||||
for (const row of fallbackResult) {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
|
|
@ -540,7 +566,7 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
||||
* 회사별 설정 우선, 공통(*) 설정 fallback
|
||||
* numbering_rules 테이블에서 table_name + column_name으로 직접 조회
|
||||
* @returns Map<columnName, numberingRuleId>
|
||||
*/
|
||||
private async detectAllNumberingColumns(
|
||||
|
|
@ -549,6 +575,7 @@ class MasterDetailExcelService {
|
|||
): Promise<Map<string, string>> {
|
||||
const numberingCols = new Map<string, string>();
|
||||
try {
|
||||
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($2, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
|
|
@ -556,22 +583,26 @@ class MasterDetailExcelService {
|
|||
? [tableName, companyCode]
|
||||
: [tableName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
const ttcResult = await query<any>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
|
||||
params
|
||||
);
|
||||
|
||||
// 컬럼별로 회사 설정 우선 적용
|
||||
for (const row of result) {
|
||||
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
numberingCols.set(row.column_name, settings.numberingRuleId);
|
||||
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
|
||||
for (const row of ttcResult) {
|
||||
const ruleResult = await query<any>(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
companyCode && companyCode !== "*"
|
||||
? [tableName, row.column_name, companyCode]
|
||||
: [tableName, row.column_name]
|
||||
);
|
||||
|
||||
if (ruleResult.length > 0) {
|
||||
numberingCols.set(row.column_name, ruleResult[0].rule_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3098,7 +3098,7 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
const allValuesResult = await client.query(
|
||||
`SELECT * FROM table_column_category_values
|
||||
`SELECT * FROM category_values
|
||||
WHERE company_code = $1
|
||||
AND (${columnConditions.join(" OR ")})
|
||||
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
|
||||
|
|
@ -3115,7 +3115,7 @@ export class MenuCopyService {
|
|||
// 5. 대상 회사에 이미 존재하는 값 한 번에 조회
|
||||
const existingValuesResult = await client.query(
|
||||
`SELECT value_id, table_name, column_name, value_code
|
||||
FROM table_column_category_values WHERE company_code = $1`,
|
||||
FROM category_values WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingValueKeys = new Map(
|
||||
|
|
@ -3194,7 +3194,7 @@ export class MenuCopyService {
|
|||
});
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO table_column_category_values (
|
||||
`INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, description, color, icon,
|
||||
is_active, is_default, created_at, created_by, company_code, menu_objid
|
||||
|
|
|
|||
|
|
@ -172,6 +172,16 @@ class NumberingRuleService {
|
|||
break;
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
prefixParts.push(String(formData[refColumn]));
|
||||
} else {
|
||||
prefixParts.push("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -1245,6 +1255,14 @@ class NumberingRuleService {
|
|||
return "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
|
|
@ -1375,6 +1393,13 @@ class NumberingRuleService {
|
|||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
case "reference": {
|
||||
const refCol2 = autoConfig.referenceColumnName;
|
||||
if (refCol2 && formData && formData[refCol2]) {
|
||||
return String(formData[refCol2]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1524,6 +1549,15 @@ class NumberingRuleService {
|
|||
return "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1747,7 +1781,53 @@ class NumberingRuleService {
|
|||
`;
|
||||
const params = [companyCode, tableName, columnName];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
let result = await pool.query(query, params);
|
||||
|
||||
// fallback: column_name이 비어있는 레거시 규칙 검색
|
||||
if (result.rows.length === 0) {
|
||||
const fallbackQuery = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
cv.value_label AS "categoryValueLabel",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND (r.column_name IS NULL OR r.column_name = '')
|
||||
AND r.category_value_id IS NULL
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
result = await pool.query(fallbackQuery, [companyCode, tableName]);
|
||||
|
||||
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
|
||||
if (result.rows.length > 0) {
|
||||
const foundRule = result.rows[0];
|
||||
await pool.query(
|
||||
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
|
||||
[columnName, foundRule.ruleId, companyCode]
|
||||
);
|
||||
result.rows[0].columnName = columnName;
|
||||
logger.info("레거시 채번 규칙 자동 매핑 완료", {
|
||||
ruleId: foundRule.ruleId,
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
|
||||
|
|
@ -1760,7 +1840,6 @@ class NumberingRuleService {
|
|||
|
||||
const rule = result.rows[0];
|
||||
|
||||
// 파트 정보 조회 (테스트 테이블)
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -1779,7 +1858,7 @@ class NumberingRuleService {
|
|||
]);
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class TableCategoryValueService {
|
|||
tc.column_name AS "columnLabel",
|
||||
COUNT(cv.value_id) AS "valueCount"
|
||||
FROM table_type_columns tc
|
||||
LEFT JOIN table_column_category_values cv
|
||||
LEFT JOIN category_values cv
|
||||
ON tc.table_name = cv.table_name
|
||||
AND tc.column_name = cv.column_name
|
||||
AND cv.is_active = true
|
||||
|
|
@ -50,7 +50,7 @@ class TableCategoryValueService {
|
|||
tc.column_name AS "columnLabel",
|
||||
COUNT(cv.value_id) AS "valueCount"
|
||||
FROM table_type_columns tc
|
||||
LEFT JOIN table_column_category_values cv
|
||||
LEFT JOIN category_values cv
|
||||
ON tc.table_name = cv.table_name
|
||||
AND tc.column_name = cv.column_name
|
||||
AND cv.is_active = true
|
||||
|
|
@ -110,7 +110,7 @@ class TableCategoryValueService {
|
|||
) tc
|
||||
LEFT JOIN (
|
||||
SELECT table_name, column_name, COUNT(*) as cnt
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE is_active = true
|
||||
GROUP BY table_name, column_name
|
||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||
|
|
@ -133,7 +133,7 @@ class TableCategoryValueService {
|
|||
) tc
|
||||
LEFT JOIN (
|
||||
SELECT table_name, column_name, COUNT(*) as cnt
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE is_active = true AND company_code = $1
|
||||
GROUP BY table_name, column_name
|
||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||
|
|
@ -207,7 +207,7 @@ class TableCategoryValueService {
|
|||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
NULL::numeric AS "menuObjid",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
|
|
@ -289,7 +289,7 @@ class TableCategoryValueService {
|
|||
// 최고 관리자: 모든 회사에서 중복 체크
|
||||
duplicateQuery = `
|
||||
SELECT value_id
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_code = $3
|
||||
|
|
@ -300,7 +300,7 @@ class TableCategoryValueService {
|
|||
// 일반 회사: 자신의 회사에서만 중복 체크
|
||||
duplicateQuery = `
|
||||
SELECT value_id
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_code = $3
|
||||
|
|
@ -316,8 +316,41 @@ class TableCategoryValueService {
|
|||
throw new Error("이미 존재하는 코드입니다");
|
||||
}
|
||||
|
||||
// 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지)
|
||||
let labelDupQuery: string;
|
||||
let labelDupParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
labelDupQuery = `
|
||||
SELECT value_id
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_label = $3
|
||||
AND is_active = true
|
||||
`;
|
||||
labelDupParams = [value.tableName, value.columnName, value.valueLabel];
|
||||
} else {
|
||||
labelDupQuery = `
|
||||
SELECT value_id
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_label = $3
|
||||
AND company_code = $4
|
||||
AND is_active = true
|
||||
`;
|
||||
labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode];
|
||||
}
|
||||
|
||||
const labelDupResult = await pool.query(labelDupQuery, labelDupParams);
|
||||
|
||||
if (labelDupResult.rows.length > 0) {
|
||||
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`);
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO table_column_category_values (
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, description, color, icon,
|
||||
is_active, is_default, company_code, menu_objid, created_by
|
||||
|
|
@ -425,6 +458,32 @@ class TableCategoryValueService {
|
|||
values.push(updates.isDefault);
|
||||
}
|
||||
|
||||
// 라벨 수정 시 중복 체크 (자기 자신 제외)
|
||||
if (updates.valueLabel !== undefined) {
|
||||
const currentRow = await pool.query(
|
||||
`SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`,
|
||||
[valueId]
|
||||
);
|
||||
|
||||
if (currentRow.rows.length > 0) {
|
||||
const { table_name, column_name, company_code } = currentRow.rows[0];
|
||||
const labelDupResult = await pool.query(
|
||||
`SELECT value_id FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_label = $3
|
||||
AND company_code = $4
|
||||
AND is_active = true
|
||||
AND value_id != $5`,
|
||||
[table_name, column_name, updates.valueLabel, company_code, valueId]
|
||||
);
|
||||
|
||||
if (labelDupResult.rows.length > 0) {
|
||||
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
setClauses.push(`updated_by = $${paramIndex++}`);
|
||||
values.push(userId);
|
||||
|
|
@ -436,7 +495,7 @@ class TableCategoryValueService {
|
|||
// 최고 관리자: 모든 카테고리 값 수정 가능
|
||||
values.push(valueId);
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE value_id = $${paramIndex++}
|
||||
RETURNING
|
||||
|
|
@ -459,7 +518,7 @@ class TableCategoryValueService {
|
|||
// 일반 회사: 자신의 카테고리 값만 수정 가능
|
||||
values.push(valueId, companyCode);
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE value_id = $${paramIndex++}
|
||||
AND company_code = $${paramIndex++}
|
||||
|
|
@ -516,14 +575,14 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
valueQuery = `
|
||||
SELECT table_name, column_name, value_code
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE value_id = $1
|
||||
`;
|
||||
valueParams = [valueId];
|
||||
} else {
|
||||
valueQuery = `
|
||||
SELECT table_name, column_name, value_code
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE value_id = $1
|
||||
AND company_code = $2
|
||||
`;
|
||||
|
|
@ -635,10 +694,10 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1
|
||||
SELECT value_id FROM category_values WHERE parent_value_id = $1
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM table_column_category_values cv
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
)
|
||||
SELECT value_id FROM category_tree
|
||||
|
|
@ -647,11 +706,11 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM table_column_category_values
|
||||
SELECT value_id FROM category_values
|
||||
WHERE parent_value_id = $1 AND company_code = $2
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM table_column_category_values cv
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
WHERE cv.company_code = $2
|
||||
)
|
||||
|
|
@ -697,10 +756,10 @@ class TableCategoryValueService {
|
|||
let labelParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`;
|
||||
labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`;
|
||||
labelParams = [id];
|
||||
} else {
|
||||
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
labelParams = [id, companyCode];
|
||||
}
|
||||
|
||||
|
|
@ -730,10 +789,10 @@ class TableCategoryValueService {
|
|||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`;
|
||||
deleteQuery = `DELETE FROM category_values WHERE value_id = $1`;
|
||||
deleteParams = [id];
|
||||
} else {
|
||||
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
deleteParams = [id, companyCode];
|
||||
}
|
||||
|
||||
|
|
@ -770,7 +829,7 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 일괄 삭제 가능
|
||||
deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $2
|
||||
WHERE value_id = ANY($1::int[])
|
||||
`;
|
||||
|
|
@ -778,7 +837,7 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능
|
||||
deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $3
|
||||
WHERE value_id = ANY($1::int[])
|
||||
AND company_code = $2
|
||||
|
|
@ -819,7 +878,7 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 순서 변경 가능
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET value_order = $1, updated_at = NOW()
|
||||
WHERE value_id = $2
|
||||
`;
|
||||
|
|
@ -827,7 +886,7 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 순서 변경 가능
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET value_order = $1, updated_at = NOW()
|
||||
WHERE value_id = $2
|
||||
AND company_code = $3
|
||||
|
|
@ -1379,48 +1438,23 @@ class TableCategoryValueService {
|
|||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
|
||||
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
|
||||
query = `
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
SELECT DISTINCT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
) combined
|
||||
WHERE value_code IN (${placeholders1})
|
||||
`;
|
||||
params = [...valueCodes, ...valueCodes];
|
||||
params = [...valueCodes];
|
||||
} else {
|
||||
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
|
||||
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
|
||||
const companyIdx1 = n + 1;
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
|
||||
const companyIdx2 = 2 * n + 2;
|
||||
|
||||
const companyIdx = n + 1;
|
||||
query = `
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx1} OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
SELECT DISTINCT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx2} OR company_code = '*')
|
||||
) combined
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND (company_code = $${companyIdx} OR company_code = '*')
|
||||
`;
|
||||
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
|
||||
params = [...valueCodes, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
|
@ -1488,7 +1522,7 @@ class TableCategoryValueService {
|
|||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
@ -1498,7 +1532,7 @@ class TableCategoryValueService {
|
|||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
|
|||
|
|
@ -2691,6 +2691,32 @@ export class TableManagementService {
|
|||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
|
||||
try {
|
||||
const companyCode = data.company_code || "*";
|
||||
const numberingColsResult = await query<any>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering'
|
||||
AND company_code IN ($2, '*')`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const row of numberingColsResult) {
|
||||
const col = row.column_name;
|
||||
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
|
||||
if (rule) {
|
||||
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
|
||||
data[col] = generatedCode;
|
||||
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (numErr: any) {
|
||||
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
||||
}
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
|
|
@ -3437,10 +3463,12 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
// ORDER BY 절 구성
|
||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
||||
// sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사용
|
||||
const hasCreatedDateColumn = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? selectColumns.includes(options.sortBy)
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: hasCreatedDateColumn
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
|
@ -3505,7 +3533,7 @@ export class TableManagementService {
|
|||
const referenceTableColumns = new Map<string, string[]>();
|
||||
const uniqueRefTables = new Set(
|
||||
joinConfigs
|
||||
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
|
||||
.filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외
|
||||
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
|
||||
);
|
||||
|
||||
|
|
@ -3684,7 +3712,9 @@ export class TableManagementService {
|
|||
selectColumns,
|
||||
"", // WHERE 절은 나중에 추가
|
||||
options.sortBy
|
||||
? selectColumns.includes(options.sortBy)
|
||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
: `"${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
: hasCreatedDateForSearch
|
||||
? `main."created_date" DESC`
|
||||
: undefined,
|
||||
|
|
@ -3875,7 +3905,9 @@ export class TableManagementService {
|
|||
const whereClause = whereConditions.join(" AND ");
|
||||
const hasCreatedDateForOrder = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? selectColumns.includes(options.sortBy)
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: hasCreatedDateForOrder
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
|
@ -4310,8 +4342,8 @@ export class TableManagementService {
|
|||
];
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||
if (config.referenceTable === "category_values") {
|
||||
dbJoins.push(config);
|
||||
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -669,38 +669,6 @@ export default function TableManagementPage() {
|
|||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
||||
}
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
console.log("🔍 Numbering 저장 체크:", {
|
||||
inputType: column.inputType,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
hasNumberingRuleId: !!column.numberingRuleId,
|
||||
});
|
||||
|
||||
if (column.inputType === "numbering") {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// numberingRuleId가 있으면 저장, 없으면 제거
|
||||
if (column.numberingRuleId) {
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
|
||||
} else {
|
||||
// numberingRuleId가 없으면 빈 객체
|
||||
finalDetailSettings = JSON.stringify(existingSettings);
|
||||
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
|
||||
}
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
|
|
@ -844,28 +812,6 @@ export default function TableManagementPage() {
|
|||
// detailSettings 계산
|
||||
let finalDetailSettings = column.detailSettings || "";
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
if (column.inputType === "numbering" && column.numberingRuleId) {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
|
||||
columnName: column.columnName,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
finalDetailSettings,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
|
||||
if (column.inputType === "entity" && column.referenceTable) {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
|
|
@ -1987,118 +1933,7 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */}
|
||||
{column.inputType === "numbering" && (
|
||||
<div className="w-64">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">채번규칙</label>
|
||||
<Popover
|
||||
open={numberingComboboxOpen[column.columnName] || false}
|
||||
onOpenChange={(open) =>
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: open,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={numberingComboboxOpen[column.columnName] || false}
|
||||
disabled={numberingRulesLoading}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{numberingRulesLoading
|
||||
? "로딩 중..."
|
||||
: column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
|
||||
?.ruleName || column.numberingRuleId
|
||||
: "채번규칙 선택..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
채번규칙을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
handleColumnChange(columnIndex, "numberingRuleId", undefined);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 자동 저장 제거 - 전체 저장 버튼으로 저장
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!column.numberingRuleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule) => (
|
||||
<CommandItem
|
||||
key={rule.ruleId}
|
||||
value={`${rule.ruleName} ${rule.ruleId}`}
|
||||
onSelect={() => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
// 상태 업데이트만 (자동 저장 제거)
|
||||
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 전체 저장 버튼으로 저장
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.numberingRuleId === rule.ruleId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rule.ruleName}</span>
|
||||
{rule.tableName && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{rule.tableName}.{rule.columnName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{column.numberingRuleId && (
|
||||
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-0.5 text-[10px]">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
<span>규칙 설정됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
ArrowRight,
|
||||
Zap,
|
||||
Copy,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||
|
|
@ -35,6 +36,8 @@ import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
||||
export interface MasterDetailExcelConfig {
|
||||
|
|
@ -133,6 +136,19 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
// 중복 처리 방법 (전역 설정)
|
||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
// { [columnName]: { invalidValue: string, replacement: string | null, validOptions: {code: string, label: string}[], rowIndices: number[] }[] }
|
||||
const [categoryMismatches, setCategoryMismatches] = useState<
|
||||
Record<string, Array<{
|
||||
invalidValue: string;
|
||||
replacement: string | null;
|
||||
validOptions: Array<{ code: string; label: string }>;
|
||||
rowIndices: number[];
|
||||
}>>
|
||||
>({});
|
||||
|
||||
// 3단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
|
|
@ -601,8 +617,177 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
// 중복 체크 설정된 컬럼 수
|
||||
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
|
||||
|
||||
// 카테고리 컬럼 검증: 엑셀 데이터에서 유효하지 않은 카테고리 값 감지
|
||||
const validateCategoryColumns = async () => {
|
||||
try {
|
||||
setIsCategoryValidating(true);
|
||||
|
||||
const targetTableName = isMasterDetail && masterDetailRelation
|
||||
? masterDetailRelation.detailTable
|
||||
: tableName;
|
||||
|
||||
// 테이블의 카테고리 타입 컬럼 조회
|
||||
const colResponse = await getTableColumns(targetTableName);
|
||||
if (!colResponse.success || !colResponse.data?.columns) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const categoryColumns = colResponse.data.columns.filter(
|
||||
(col: any) => col.inputType === "category"
|
||||
);
|
||||
|
||||
if (categoryColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
||||
const mappedCategoryColumns: Array<{
|
||||
systemCol: string;
|
||||
excelCol: string;
|
||||
displayName: string;
|
||||
}> = [];
|
||||
|
||||
for (const mapping of columnMappings) {
|
||||
if (!mapping.systemColumn) continue;
|
||||
const rawName = mapping.systemColumn.includes(".")
|
||||
? mapping.systemColumn.split(".")[1]
|
||||
: mapping.systemColumn;
|
||||
|
||||
const catCol = categoryColumns.find(
|
||||
(cc: any) => (cc.columnName || cc.column_name) === rawName
|
||||
);
|
||||
if (catCol) {
|
||||
mappedCategoryColumns.push({
|
||||
systemCol: rawName,
|
||||
excelCol: mapping.excelColumn,
|
||||
displayName: catCol.displayName || catCol.display_name || rawName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedCategoryColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증
|
||||
const mismatches: typeof categoryMismatches = {};
|
||||
|
||||
for (const catCol of mappedCategoryColumns) {
|
||||
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
|
||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||
|
||||
const validValues = valuesResponse.data as Array<{
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
}>;
|
||||
|
||||
// 유효한 코드와 라벨 Set 생성
|
||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||
|
||||
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||
const invalidMap = new Map<string, number[]>();
|
||||
|
||||
allData.forEach((row, rowIdx) => {
|
||||
const val = row[catCol.excelCol];
|
||||
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||
const strVal = String(val).trim();
|
||||
|
||||
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
|
||||
if (validCodes.has(strVal)) return;
|
||||
if (validLabels.has(strVal)) return;
|
||||
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||
|
||||
if (!invalidMap.has(strVal)) {
|
||||
invalidMap.set(strVal, []);
|
||||
}
|
||||
invalidMap.get(strVal)!.push(rowIdx);
|
||||
});
|
||||
|
||||
if (invalidMap.size > 0) {
|
||||
const options = validValues.map((v) => ({
|
||||
code: v.valueCode,
|
||||
label: v.valueLabel,
|
||||
}));
|
||||
|
||||
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
||||
([invalidValue, rowIndices]) => ({
|
||||
invalidValue,
|
||||
replacement: null,
|
||||
validOptions: options,
|
||||
rowIndices,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(mismatches).length > 0) {
|
||||
return mismatches;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("카테고리 검증 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCategoryValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 대체값 선택 후 데이터에 적용
|
||||
const applyCategoryReplacements = () => {
|
||||
// 모든 대체값이 선택되었는지 확인
|
||||
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||
for (const item of items) {
|
||||
if (item.replacement === null) {
|
||||
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 엑셀 컬럼명 → 시스템 컬럼명 매핑 구축
|
||||
const systemToExcelMap = new Map<string, string>();
|
||||
for (const mapping of columnMappings) {
|
||||
if (!mapping.systemColumn) continue;
|
||||
const rawName = mapping.systemColumn.includes(".")
|
||||
? mapping.systemColumn.split(".")[1]
|
||||
: mapping.systemColumn;
|
||||
systemToExcelMap.set(rawName, mapping.excelColumn);
|
||||
}
|
||||
|
||||
const newData = allData.map((row) => ({ ...row }));
|
||||
|
||||
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||
const systemCol = key.split("|||")[0];
|
||||
const excelCol = systemToExcelMap.get(systemCol);
|
||||
if (!excelCol) continue;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.replacement) continue;
|
||||
// 선택된 대체값의 라벨 찾기
|
||||
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||
const replacementLabel = selectedOption?.label || item.replacement;
|
||||
|
||||
for (const rowIdx of item.rowIndices) {
|
||||
if (newData[rowIdx]) {
|
||||
newData[rowIdx][excelCol] = replacementLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllData(newData);
|
||||
setDisplayData(newData);
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
toast.success("카테고리 값이 대체되었습니다.");
|
||||
setCurrentStep(3);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 1 && !file) {
|
||||
toast.error("파일을 선택해주세요.");
|
||||
return;
|
||||
|
|
@ -655,7 +840,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
|
||||
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + 카테고리 검증
|
||||
if (currentStep === 2) {
|
||||
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
|
||||
const mappedSystemCols = new Set<string>();
|
||||
|
|
@ -681,6 +866,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리 컬럼 검증
|
||||
const mismatches = await validateCategoryColumns();
|
||||
if (mismatches) {
|
||||
setCategoryMismatches(mismatches);
|
||||
setShowCategoryValidation(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
|
|
@ -1108,12 +1301,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
setDuplicateAction("skip");
|
||||
// 카테고리 검증 초기화
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setIsCategoryValidating(false);
|
||||
// 🆕 마스터-디테일 모드 초기화
|
||||
setMasterFieldValues({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||
|
|
@ -1750,10 +1948,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isUploading || (currentStep === 1 && !file)}
|
||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
다음
|
||||
{isCategoryValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
</>
|
||||
) : (
|
||||
"다음"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -1769,5 +1974,112 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 카테고리 대체값 선택 다이얼로그 */}
|
||||
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
존재하지 않는 카테고리 값 감지
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
||||
{Object.entries(categoryMismatches).map(([key, items]) => {
|
||||
const [columnName, displayName] = key.split("|||");
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{displayName || columnName}
|
||||
</h4>
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
key={`${key}-${idx}`}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-destructive line-through">
|
||||
{item.invalidValue}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{item.rowIndices.length}건
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={item.replacement || ""}
|
||||
onValueChange={(val) => {
|
||||
setCategoryMismatches((prev) => {
|
||||
const updated = { ...prev };
|
||||
updated[key] = updated[key].map((it, i) =>
|
||||
i === idx ? { ...it, replacement: val } : it
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue placeholder="대체 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{item.validOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.code}
|
||||
value={opt.code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
무시하고 진행
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyCategoryReplacements}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import {
|
|||
TableChainConfig,
|
||||
uploadMultiTableExcel,
|
||||
} from "@/lib/api/multiTableExcel";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
export interface MultiTableExcelUploadModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -79,6 +81,18 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
|||
// 업로드
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
const [categoryMismatches, setCategoryMismatches] = useState<
|
||||
Record<string, Array<{
|
||||
invalidValue: string;
|
||||
replacement: string | null;
|
||||
validOptions: Array<{ code: string; label: string }>;
|
||||
rowIndices: number[];
|
||||
}>>
|
||||
>({});
|
||||
|
||||
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
|
||||
|
||||
// 선택된 모드에서 활성화되는 컬럼 목록
|
||||
|
|
@ -302,8 +316,161 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
|||
}
|
||||
};
|
||||
|
||||
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
|
||||
const validateCategoryColumns = async () => {
|
||||
try {
|
||||
setIsCategoryValidating(true);
|
||||
|
||||
if (!selectedMode) return null;
|
||||
|
||||
const mismatches: typeof categoryMismatches = {};
|
||||
|
||||
// 활성 레벨별로 카테고리 컬럼 검증
|
||||
for (const levelIdx of selectedMode.activeLevels) {
|
||||
const level = config.levels[levelIdx];
|
||||
if (!level) continue;
|
||||
|
||||
// 해당 테이블의 카테고리 타입 컬럼 조회
|
||||
const colResponse = await getTableColumns(level.tableName);
|
||||
if (!colResponse.success || !colResponse.data?.columns) continue;
|
||||
|
||||
const categoryColumns = colResponse.data.columns.filter(
|
||||
(col: any) => col.inputType === "category"
|
||||
);
|
||||
if (categoryColumns.length === 0) continue;
|
||||
|
||||
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
||||
for (const catCol of categoryColumns) {
|
||||
const catColName = catCol.columnName || catCol.column_name;
|
||||
const catDisplayName = catCol.displayName || catCol.display_name || catColName;
|
||||
|
||||
// level.columns에서 해당 dbColumn 찾기
|
||||
const levelCol = level.columns.find((lc) => lc.dbColumn === catColName);
|
||||
if (!levelCol) continue;
|
||||
|
||||
// 매핑에서 해당 excelHeader에 연결된 엑셀 컬럼 찾기
|
||||
const mapping = columnMappings.find((m) => m.targetColumn === levelCol.excelHeader);
|
||||
if (!mapping) continue;
|
||||
|
||||
// 유효한 카테고리 값 조회
|
||||
const valuesResponse = await getCategoryValues(level.tableName, catColName);
|
||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||
|
||||
const validValues = valuesResponse.data as Array<{
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
}>;
|
||||
|
||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||
|
||||
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||
const invalidMap = new Map<string, number[]>();
|
||||
|
||||
allData.forEach((row, rowIdx) => {
|
||||
const val = row[mapping.excelColumn];
|
||||
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||
const strVal = String(val).trim();
|
||||
|
||||
if (validCodes.has(strVal)) return;
|
||||
if (validLabels.has(strVal)) return;
|
||||
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||
|
||||
if (!invalidMap.has(strVal)) {
|
||||
invalidMap.set(strVal, []);
|
||||
}
|
||||
invalidMap.get(strVal)!.push(rowIdx);
|
||||
});
|
||||
|
||||
if (invalidMap.size > 0) {
|
||||
const options = validValues.map((v) => ({
|
||||
code: v.valueCode,
|
||||
label: v.valueLabel,
|
||||
}));
|
||||
|
||||
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
||||
mismatches[key] = Array.from(invalidMap.entries()).map(
|
||||
([invalidValue, rowIndices]) => ({
|
||||
invalidValue,
|
||||
replacement: null,
|
||||
validOptions: options,
|
||||
rowIndices,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(mismatches).length > 0) {
|
||||
return mismatches;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("카테고리 검증 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCategoryValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 대체값 적용
|
||||
const applyCategoryReplacements = () => {
|
||||
for (const [, items] of Object.entries(categoryMismatches)) {
|
||||
for (const item of items) {
|
||||
if (item.replacement === null) {
|
||||
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 컬럼명 → 엑셀 컬럼명 역매핑 구축
|
||||
const dbColToExcelCol = new Map<string, string>();
|
||||
if (selectedMode) {
|
||||
for (const levelIdx of selectedMode.activeLevels) {
|
||||
const level = config.levels[levelIdx];
|
||||
if (!level) continue;
|
||||
for (const lc of level.columns) {
|
||||
const mapping = columnMappings.find((m) => m.targetColumn === lc.excelHeader);
|
||||
if (mapping) {
|
||||
dbColToExcelCol.set(lc.dbColumn, mapping.excelColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newData = allData.map((row) => ({ ...row }));
|
||||
|
||||
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||
const systemCol = key.split("|||")[0];
|
||||
const excelCol = dbColToExcelCol.get(systemCol);
|
||||
if (!excelCol) continue;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.replacement) continue;
|
||||
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||
const replacementLabel = selectedOption?.label || item.replacement;
|
||||
|
||||
for (const rowIdx of item.rowIndices) {
|
||||
if (newData[rowIdx]) {
|
||||
newData[rowIdx][excelCol] = replacementLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllData(newData);
|
||||
setDisplayData(newData);
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
toast.success("카테고리 값이 대체되었습니다.");
|
||||
setCurrentStep(3);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 다음/이전 단계
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 1) {
|
||||
if (!file) {
|
||||
toast.error("파일을 선택해주세요.");
|
||||
|
|
@ -328,6 +495,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
|||
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리 컬럼 검증
|
||||
const mismatches = await validateCategoryColumns();
|
||||
if (mismatches) {
|
||||
setCategoryMismatches(mismatches);
|
||||
setShowCategoryValidation(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
|
|
@ -349,10 +524,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
|||
setDisplayData([]);
|
||||
setExcelColumns([]);
|
||||
setColumnMappings([]);
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setIsCategoryValidating(false);
|
||||
}
|
||||
}, [open, config.uploadModes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||
|
|
@ -758,10 +937,17 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
|||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isUploading || (currentStep === 1 && !file)}
|
||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
다음
|
||||
{isCategoryValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
</>
|
||||
) : (
|
||||
"다음"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -782,5 +968,112 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 카테고리 대체값 선택 다이얼로그 */}
|
||||
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
존재하지 않는 카테고리 값 감지
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
||||
{Object.entries(categoryMismatches).map(([key, items]) => {
|
||||
const [, displayName] = key.split("|||");
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{displayName}
|
||||
</h4>
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
key={`${key}-${idx}`}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-destructive line-through">
|
||||
{item.invalidValue}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{item.rowIndices.length}건
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={item.replacement || ""}
|
||||
onValueChange={(val) => {
|
||||
setCategoryMismatches((prev) => {
|
||||
const updated = { ...prev };
|
||||
updated[key] = updated[key].map((it, i) =>
|
||||
i === idx ? { ...it, replacement: val } : it
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue placeholder="대체 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{item.validOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.code}
|
||||
value={opt.code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
무시하고 진행
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyCategoryReplacements}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface AutoConfigPanelProps {
|
|||
config?: any;
|
||||
onChange: (config: any) => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
|
|
@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|||
config = {},
|
||||
onChange,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
// 1. 순번 (자동 증가)
|
||||
if (partType === "sequence") {
|
||||
|
|
@ -161,6 +163,18 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 6. 참조 (마스터-디테일 분번)
|
||||
if (partType === "reference") {
|
||||
return (
|
||||
<ReferenceConfigSection
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
isPreview={isPreview}
|
||||
tableName={tableName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -1088,3 +1102,94 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ReferenceConfigSection({
|
||||
config,
|
||||
onChange,
|
||||
isPreview,
|
||||
tableName,
|
||||
}: {
|
||||
config: any;
|
||||
onChange: (c: any) => void;
|
||||
isPreview: boolean;
|
||||
tableName?: string;
|
||||
}) {
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingCols, setLoadingCols] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) return;
|
||||
setLoadingCols(true);
|
||||
|
||||
const loadEntityColumns = async () => {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(
|
||||
`/screen-management/tables/${tableName}/columns`
|
||||
);
|
||||
const allCols = response.data?.data || response.data || [];
|
||||
const entityCols = allCols.filter(
|
||||
(c: any) =>
|
||||
(c.inputType || c.input_type) === "entity" ||
|
||||
(c.inputType || c.input_type) === "numbering"
|
||||
);
|
||||
setColumns(
|
||||
entityCols.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName:
|
||||
c.columnLabel || c.column_label || c.columnName || c.column_name,
|
||||
dataType: c.dataType || c.data_type || "",
|
||||
inputType: c.inputType || c.input_type || "",
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setLoadingCols(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEntityColumns();
|
||||
}, [tableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">참조 컬럼</Label>
|
||||
<Select
|
||||
value={config.referenceColumnName || ""}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...config, referenceColumnName: value })
|
||||
}
|
||||
disabled={isPreview || loadingCols}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingCols
|
||||
? "로딩 중..."
|
||||
: columns.length === 0
|
||||
? "엔티티 컬럼 없음"
|
||||
: "컬럼 선택"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.displayName} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
마스터 테이블과 연결된 엔티티/채번 컬럼의 값을 코드에 포함합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface NumberingRuleCardProps {
|
|||
onUpdate: (updates: Partial<NumberingRulePart>) => void;
|
||||
onDelete: () => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||
|
|
@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
onUpdate,
|
||||
onDelete,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border bg-card flex-1">
|
||||
|
|
@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
date: { dateFormat: "YYYYMMDD" },
|
||||
text: { textValue: "CODE" },
|
||||
category: { categoryKey: "", categoryMappings: [] },
|
||||
reference: { referenceColumnName: "" },
|
||||
};
|
||||
onUpdate({
|
||||
partType: newPartType,
|
||||
|
|
@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
config={part.autoConfig}
|
||||
onChange={(autoConfig) => onUpdate({ autoConfig })}
|
||||
isPreview={isPreview}
|
||||
tableName={tableName}
|
||||
/>
|
||||
) : (
|
||||
<ManualConfigPanel
|
||||
|
|
|
|||
|
|
@ -1,36 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||
import {
|
||||
saveNumberingRuleToTest,
|
||||
deleteNumberingRuleFromTest,
|
||||
getNumberingRulesFromTest,
|
||||
} from "@/lib/api/numberingRule";
|
||||
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 카테고리 값 트리 노드 타입
|
||||
interface CategoryValueNode {
|
||||
valueId: number;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
depth: number;
|
||||
path: string;
|
||||
parentValueId: number | null;
|
||||
children?: CategoryValueNode[];
|
||||
interface NumberingColumn {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface GroupedColumns {
|
||||
tableLabel: string;
|
||||
columns: NumberingColumn[];
|
||||
}
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
|
|
@ -54,138 +48,100 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
currentTableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
|
||||
const [columnSearch, setColumnSearch] = useState("");
|
||||
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||
|
||||
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||
interface CategoryOption {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
displayName: string; // "테이블명.컬럼명" 형식
|
||||
}
|
||||
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
|
||||
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
|
||||
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
|
||||
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
|
||||
// 좌측: 채번 타입 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
|
||||
loadNumberingColumns();
|
||||
}, []);
|
||||
|
||||
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
|
||||
useEffect(() => {
|
||||
if (currentRule?.categoryColumn) {
|
||||
setSelectedCategoryKey(currentRule.categoryColumn);
|
||||
} else {
|
||||
setSelectedCategoryKey("");
|
||||
}
|
||||
}, [currentRule?.categoryColumn]);
|
||||
|
||||
// 카테고리 키 선택 시 해당 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
if (selectedCategoryKey) {
|
||||
const [tableName, columnName] = selectedCategoryKey.split(".");
|
||||
if (tableName && columnName) {
|
||||
loadCategoryValues(tableName, columnName);
|
||||
}
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
}, [selectedCategoryKey]);
|
||||
|
||||
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
|
||||
const loadAllCategoryOptions = async () => {
|
||||
try {
|
||||
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
|
||||
const response = await getAllCategoryKeys();
|
||||
if (response.success && response.data) {
|
||||
const options: CategoryOption[] = response.data.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
columnName: item.columnName,
|
||||
displayName: `${item.tableName}.${item.columnName}`,
|
||||
}));
|
||||
setAllCategoryOptions(options);
|
||||
console.log("전체 카테고리 옵션 로드:", options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 옵션 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 카테고리 컬럼의 값 트리 조회
|
||||
const loadCategoryValues = async (tableName: string, columnName: string) => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const response = await getCategoryTree(tableName, columnName);
|
||||
if (response.success && response.data) {
|
||||
setCategoryValues(response.data);
|
||||
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 트리 조회 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
|
||||
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
flattenCategoryValues(node.children, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategoryValues = flattenCategoryValues(categoryValues);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
const loadNumberingColumns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
|
||||
menuObjid,
|
||||
hasMenuObjid: !!menuObjid,
|
||||
});
|
||||
|
||||
// test 테이블에서 조회
|
||||
const response = await getNumberingRulesFromTest(menuObjid);
|
||||
|
||||
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
|
||||
menuObjid,
|
||||
success: response.success,
|
||||
rulesCount: response.data?.length || 0,
|
||||
rules: response.data,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSavedRules(response.data);
|
||||
} else {
|
||||
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
|
||||
const response = await apiClient.get("/table-management/numbering-columns");
|
||||
if (response.data.success && response.data.data) {
|
||||
setNumberingColumns(response.data.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`로딩 실패: ${error.message}`);
|
||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [menuObjid]);
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
|
||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
||||
setSelectedColumn({ tableName, columnName });
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const rule = response.data.data as NumberingRuleConfig;
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
} else {
|
||||
// 규칙 없으면 신규 생성 모드
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
}
|
||||
} catch {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블별로 그룹화
|
||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
||||
if (!acc[col.tableName]) {
|
||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
||||
}
|
||||
acc[col.tableName].columns.push(col);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 검색 필터 적용
|
||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
||||
if (!columnSearch) return true;
|
||||
const search = columnSearch.toLowerCase();
|
||||
return (
|
||||
tableName.toLowerCase().includes(search) ||
|
||||
group.tableLabel.toLowerCase().includes(search) ||
|
||||
group.columns.some(
|
||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
|
|
@ -343,60 +299,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
return part;
|
||||
});
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
|
||||
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
|
||||
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
|
||||
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
|
||||
scopeType: "table" as const,
|
||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
currentTableName,
|
||||
menuObjid,
|
||||
"currentRule.tableName": currentRule.tableName,
|
||||
"currentRule.menuObjid": currentRule.menuObjid,
|
||||
"ruleToSave.tableName": ruleToSave.tableName,
|
||||
"ruleToSave.menuObjid": ruleToSave.menuObjid,
|
||||
"ruleToSave.scopeType": ruleToSave.scopeType,
|
||||
ruleToSave,
|
||||
});
|
||||
|
||||
// 테스트 테이블에 저장 (numbering_rules)
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
|
||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
|
||||
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
|
||||
setSavedRules((prev) => {
|
||||
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
|
||||
|
||||
console.log("🔍 [handleSave] setSavedRules:", {
|
||||
ruleId: ruleToSave.ruleId,
|
||||
existsInPrev,
|
||||
prevCount: prev.length,
|
||||
});
|
||||
|
||||
if (existsInPrev) {
|
||||
// 기존 규칙 업데이트
|
||||
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
|
||||
} else {
|
||||
// 새 규칙 추가
|
||||
return [...prev, savedData];
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentRule(currentData);
|
||||
setSelectedRuleId(response.data.ruleId);
|
||||
|
||||
await onSave?.(response.data);
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
|
|
@ -407,143 +323,62 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, onSave, currentTableName, menuObjid]);
|
||||
|
||||
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||
console.log("🔍 [handleSelectRule] 규칙 선택:", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
partsCount: rule.parts?.length || 0,
|
||||
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
||||
});
|
||||
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
|
||||
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
|
||||
|
||||
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
|
||||
ruleId: ruleCopy.ruleId,
|
||||
partsCount: ruleCopy.parts?.length || 0,
|
||||
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
||||
});
|
||||
|
||||
setCurrentRule(ruleCopy);
|
||||
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSavedRule = useCallback(
|
||||
async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRuleFromTest(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
||||
if (selectedRuleId === ruleId) {
|
||||
setSelectedRuleId(null);
|
||||
setCurrentRule(null);
|
||||
}
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
} else {
|
||||
showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const handleNewRule = useCallback(() => {
|
||||
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
|
||||
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 채번 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
||||
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||
};
|
||||
|
||||
console.log("📋 생성된 규칙 정보:", newRule);
|
||||
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(newRule);
|
||||
|
||||
toast.success("새 규칙이 생성되었습니다");
|
||||
}, [currentTableName, menuObjid]);
|
||||
}, [currentRule, onSave, selectedColumn]);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full gap-4 ${className}`}>
|
||||
{/* 좌측: 저장된 규칙 목록 */}
|
||||
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{editingLeftTitle ? (
|
||||
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
|
||||
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold sm:text-base">채번 컬럼</h2>
|
||||
|
||||
<Input
|
||||
value={leftTitle}
|
||||
onChange={(e) => setLeftTitle(e.target.value)}
|
||||
onBlur={() => setEditingLeftTitle(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
|
||||
className="h-8 text-sm font-semibold"
|
||||
autoFocus
|
||||
value={columnSearch}
|
||||
onChange={(e) => setColumnSearch(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
||||
<Plus className="mr-2 h-4 w-4" />새 규칙 생성
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||
{loading && numberingColumns.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
||||
</div>
|
||||
) : savedRules.length === 0 ? (
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-xs">저장된 규칙이 없습니다</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{numberingColumns.length === 0
|
||||
? "채번 타입 컬럼이 없습니다"
|
||||
: "검색 결과가 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
savedRules.map((rule) => (
|
||||
<Card
|
||||
key={rule.ruleId}
|
||||
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
|
||||
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||
}`}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
>
|
||||
<CardHeader className="px-3 py-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
||||
filteredGroups.map(([tableName, group]) => (
|
||||
<div key={tableName} className="mb-2">
|
||||
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
|
||||
<FolderTree className="h-3 w-3" />
|
||||
<span>{group.tableLabel}</span>
|
||||
<span className="text-muted-foreground/60">({group.columns.length})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSavedRule(rule.ruleId);
|
||||
}}
|
||||
{group.columns.map((col) => {
|
||||
const isSelected =
|
||||
selectedColumn?.tableName === col.tableName &&
|
||||
selectedColumn?.columnName === col.columnName;
|
||||
return (
|
||||
<div
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary border-primary border font-medium"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
||||
>
|
||||
<Trash2 className="text-destructive h-3 w-3" />
|
||||
</Button>
|
||||
{col.columnLabel}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -557,8 +392,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
{!currentRule ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">규칙을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 규칙을 선택하거나 새로 생성하세요</p>
|
||||
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">컬럼을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -624,6 +460,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
tableName={selectedColumn?.tableName}
|
||||
/>
|
||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||
{index < currentRule.parts.length - 1 && (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
interface EditModalState {
|
||||
isOpen: boolean;
|
||||
screenId: number | null;
|
||||
|
|
@ -244,6 +244,92 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 디테일 행의 FK를 통해 마스터 테이블 데이터를 자동 조회
|
||||
* - entity join 설정에서 FK 관계 탐지
|
||||
* - FK 값으로 마스터 테이블 전체 row 조회
|
||||
* - editData에 없는 필드만 병합 (디테일 데이터를 덮어쓰지 않음)
|
||||
*/
|
||||
const loadMasterDataForDetailRow = async (
|
||||
editData: Record<string, any>,
|
||||
targetScreenId: number,
|
||||
eventTableName?: string,
|
||||
): Promise<Record<string, any>> => {
|
||||
try {
|
||||
let detailTableName = eventTableName;
|
||||
if (!detailTableName) {
|
||||
const screenInfo = await screenApi.getScreen(targetScreenId);
|
||||
detailTableName = screenInfo?.tableName;
|
||||
}
|
||||
|
||||
if (!detailTableName) {
|
||||
console.log("[EditModal:MasterLoad] 테이블명을 알 수 없음 - 스킵");
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log("[EditModal:MasterLoad] 시작:", { detailTableName, editDataKeys: Object.keys(editData) });
|
||||
|
||||
const entityJoinRes = await entityJoinApi.getEntityJoinConfigs(detailTableName);
|
||||
const joinConfigs = entityJoinRes?.joinConfigs || [];
|
||||
|
||||
if (joinConfigs.length === 0) {
|
||||
console.log("[EditModal:MasterLoad] entity join 없음 - 스킵");
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log("[EditModal:MasterLoad] entity join:", joinConfigs.map((c) => `${c.sourceColumn} → ${c.referenceTable}`));
|
||||
|
||||
const masterDataResult: Record<string, any> = {};
|
||||
const processedTables = new Set<string>();
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
for (const joinConfig of joinConfigs) {
|
||||
const { sourceColumn, referenceTable, referenceColumn } = joinConfig;
|
||||
if (processedTables.has(referenceTable)) continue;
|
||||
|
||||
const fkValue = editData[sourceColumn];
|
||||
if (!fkValue) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${referenceTable}/data`,
|
||||
{
|
||||
search: { [referenceColumn || "id"]: fkValue },
|
||||
size: 1,
|
||||
page: 1,
|
||||
autoFilter: true,
|
||||
},
|
||||
);
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
if (rows.length > 0) {
|
||||
const masterRow = rows[0];
|
||||
for (const [col, val] of Object.entries(masterRow)) {
|
||||
if (val !== undefined && val !== null && editData[col] === undefined) {
|
||||
masterDataResult[col] = val;
|
||||
}
|
||||
}
|
||||
console.log("[EditModal:MasterLoad] 조회 성공:", {
|
||||
table: referenceTable,
|
||||
fk: `${sourceColumn}=${fkValue}`,
|
||||
loadedFields: Object.keys(masterDataResult),
|
||||
});
|
||||
}
|
||||
} catch (queryError) {
|
||||
console.warn("[EditModal:MasterLoad] 조회 실패:", referenceTable, queryError);
|
||||
}
|
||||
|
||||
processedTables.add(referenceTable);
|
||||
}
|
||||
|
||||
console.log("[EditModal:MasterLoad] 최종 결과:", Object.keys(masterDataResult));
|
||||
return masterDataResult;
|
||||
} catch (error) {
|
||||
console.warn("[EditModal:MasterLoad] 전체 오류:", error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = async (event: CustomEvent) => {
|
||||
|
|
@ -294,6 +380,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
// editData로 formData를 즉시 세팅 (채번 컴포넌트가 빈 formData로 마운트되어 새 번호 생성하는 것 방지)
|
||||
setFormData(enriched);
|
||||
// originalData: changedData 계산(PATCH)에만 사용
|
||||
// INSERT/UPDATE 판단에는 사용하지 않음
|
||||
|
|
@ -302,6 +390,21 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
|
||||
setIsCreateModeFlag(!!isCreateMode);
|
||||
|
||||
// 마스터 데이터 자동 조회 (수정 모드일 때, formData 세팅 이후 비동기로 병합)
|
||||
// 디테일 행 선택 시 마스터 테이블의 컬럼 데이터를 자동으로 가져와서 추가
|
||||
if (!isCreateMode && editData && screenId) {
|
||||
loadMasterDataForDetailRow(editData, screenId, tableName)
|
||||
.then((masterData) => {
|
||||
if (Object.keys(masterData).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...masterData }));
|
||||
console.log("[EditModal] 마스터 데이터 비동기 병합 완료:", Object.keys(masterData));
|
||||
}
|
||||
})
|
||||
.catch((masterError) => {
|
||||
console.warn("[EditModal] 마스터 데이터 자동 조회 중 오류 (무시):", masterError);
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[EditModal] 모달 열림:", {
|
||||
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
|
||||
hasEditData: !!editData,
|
||||
|
|
@ -1529,7 +1632,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
|
||||
const enrichedFormData = {
|
||||
...(groupData.length > 0 ? groupData[0] : formData),
|
||||
// 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
|
||||
// → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
|
@ -1589,7 +1695,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const enrichedFormData = {
|
||||
...(groupData.length > 0 ? groupData[0] : formData),
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
for (const col of categoryColumns) {
|
||||
try {
|
||||
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
|
||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
|
||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* - 체크박스를 통한 다중 선택 및 일괄 삭제 지원
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
|
|
@ -291,6 +291,10 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
||||
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
|
||||
|
||||
// 추가 모달 input ref
|
||||
const addNameRef = useRef<HTMLInputElement>(null);
|
||||
const addDescRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
valueCode: "",
|
||||
|
|
@ -508,7 +512,15 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
const response = await createCategoryValue(input);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
setIsAddModalOpen(false);
|
||||
// 폼 초기화 (모달은 닫지 않고 연속 입력)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
}));
|
||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
||||
// 기존 펼침 상태 유지하면서 데이터 새로고침
|
||||
await loadTree(true);
|
||||
// 부모 노드만 펼치기 (하위 추가 시)
|
||||
|
|
@ -746,9 +758,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
ref={addNameRef}
|
||||
id="valueLabel"
|
||||
value={formData.valueLabel}
|
||||
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addDescRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
placeholder="카테고리 이름을 입력하세요"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
|
|
@ -759,9 +779,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
설명
|
||||
</Label>
|
||||
<Input
|
||||
ref={addDescRef}
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
placeholder="선택 사항"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
|
|
@ -784,7 +812,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
onClick={() => setIsAddModalOpen(false)}
|
||||
className="h-9 flex-1 text-sm sm:flex-none"
|
||||
>
|
||||
취소
|
||||
닫기
|
||||
</Button>
|
||||
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
추가
|
||||
|
|
|
|||
|
|
@ -619,45 +619,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
try {
|
||||
// 채번 규칙 ID 캐싱 (한 번만 조회)
|
||||
if (!numberingRuleIdRef.current) {
|
||||
// table_name + column_name 기반으로 채번 규칙 조회
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
|
||||
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
|
||||
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// by-column 조회 실패 시 detailSettings fallback
|
||||
try {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const columnsResponse = await getTableColumns(tableName);
|
||||
|
||||
if (!columnsResponse.success || !columnsResponse.data) {
|
||||
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (columnsResponse.success && columnsResponse.data) {
|
||||
const columns = columnsResponse.data.columns || columnsResponse.data;
|
||||
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
||||
|
||||
if (!targetColumn) {
|
||||
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
// detailSettings에서 numberingRuleId 추출
|
||||
if (targetColumn.detailSettings) {
|
||||
try {
|
||||
// 문자열이면 파싱, 객체면 그대로 사용
|
||||
if (targetColumn?.detailSettings) {
|
||||
const parsed = typeof targetColumn.detailSettings === "string"
|
||||
? JSON.parse(targetColumn.detailSettings)
|
||||
: targetColumn.detailSettings;
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
|
||||
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
|
||||
if (parsed.numberingRuleId && onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const numberingRuleId = numberingRuleIdRef.current;
|
||||
|
||||
if (!numberingRuleId) {
|
||||
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
|
||||
console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -796,7 +796,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
|
@ -838,7 +838,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
try {
|
||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
@ -367,7 +367,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
for (const columnName of categoryColumns) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
|
|
|
|||
|
|
@ -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"; // 상태별 카운트 카드
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export function RepeaterTable({
|
|||
continue;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data?.success && response.data.data) {
|
||||
const options = response.data.data.map((item: any) => ({
|
||||
|
|
|
|||
|
|
@ -139,6 +139,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
||||
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
||||
|
||||
if (component.columnName === "data_type" || component.columnName === "unit") {
|
||||
console.log("🔥🔥🔥 [PLC 캐스케이딩 디버그]", {
|
||||
columnName: component.columnName,
|
||||
categoryRelationCode,
|
||||
cascadingRole,
|
||||
cascadingParentField,
|
||||
configKeys: config ? Object.keys(config) : "null",
|
||||
componentConfigKeys: componentConfig ? Object.keys(componentConfig) : "null",
|
||||
configCategoryRelationCode: config?.categoryRelationCode,
|
||||
componentConfigCategoryRelationCode: componentConfig?.categoryRelationCode,
|
||||
webType,
|
||||
formDataKeys: formData ? Object.keys(formData) : "null",
|
||||
parentValue: cascadingParentField && formData ? formData[cascadingParentField] : "N/A",
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 계층구조 역할 설정 (대분류/중분류/소분류)
|
||||
// 1순위: 동적으로 조회된 값 (테이블 타입관리에서 설정)
|
||||
// 2순위: config에서 전달된 값
|
||||
|
|
|
|||
|
|
@ -1588,7 +1588,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -1650,7 +1650,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
|
|||
|
|
@ -1298,7 +1298,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
targetColumn = parts[1];
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||
// 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -1381,7 +1382,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// inputType이 category인 경우 카테고리 매핑 로드
|
||||
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
|
|||
|
|
@ -1362,7 +1362,7 @@ export function UniversalFormModalComponent({
|
|||
label: String(row[optionConfig.labelColumn || "name"]),
|
||||
}));
|
||||
} else if (optionConfig.type === "code" && optionConfig.categoryKey) {
|
||||
// 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회
|
||||
// 공통코드(카테고리 컬럼): category_values 테이블에서 조회
|
||||
// categoryKey 형식: "tableName.columnName"
|
||||
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
|
||||
if (categoryTable && categoryColumn) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
import { apiClient } from "@/lib/api/client";
|
||||
import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||
|
||||
// 카테고리 컬럼 타입 (table_column_category_values 용)
|
||||
// 카테고리 컬럼 타입 (category_values 용)
|
||||
interface CategoryColumnOption {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
|
|
|
|||
|
|
@ -2526,7 +2526,7 @@ interface TableSectionSettingsModalProps {
|
|||
tables: { table_name: string; comment?: string }[];
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]>;
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
// 카테고리 목록 (table_column_category_values에서 가져옴)
|
||||
// 카테고리 목록 (category_values에서 가져옴)
|
||||
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
|
||||
onLoadCategoryList?: () => void;
|
||||
// 전체 섹션 목록 (다른 섹션 필드 참조용)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export interface SelectOptionConfig {
|
|||
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
|
||||
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
|
||||
filterCondition?: string;
|
||||
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
|
||||
// 카테고리 컬럼 기반 옵션 (category_values 테이블)
|
||||
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
|
||||
categoryKey?: string;
|
||||
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ export function BomItemEditorComponent({
|
|||
if (alreadyLoaded) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values?includeInactive=true`);
|
||||
if (response.data?.success && response.data.data) {
|
||||
const options = response.data.data.map((item: any) => ({
|
||||
value: item.valueCode || item.value_code,
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ export function BomTreeComponent({
|
|||
useEffect(() => {
|
||||
const loadLabels = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
|
||||
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values?includeInactive=true`);
|
||||
const vals = res.data?.data || [];
|
||||
if (vals.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
for (const columnName of categoryColumns) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
|
|
|
|||
|
|
@ -929,6 +929,42 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return result;
|
||||
}, []);
|
||||
|
||||
// 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율)
|
||||
const renderProgressCell = useCallback(
|
||||
(col: any, item: any, parentData: any) => {
|
||||
const current = Number(item[col.numerator] || 0);
|
||||
const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0);
|
||||
const percentage = max > 0 ? Math.round((current / max) * 100) : 0;
|
||||
const barWidth = Math.min(percentage, 100);
|
||||
const barColor =
|
||||
percentage > 100
|
||||
? "bg-red-600"
|
||||
: percentage >= 90
|
||||
? "bg-red-500"
|
||||
: percentage >= 70
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[120px] items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="bg-muted h-2 w-full rounded-full">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
{current.toLocaleString()} / {max.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium">{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||
const formatCellValue = useCallback(
|
||||
(
|
||||
|
|
@ -1894,7 +1930,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -1972,7 +2008,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
for (const col of categoryColumns) {
|
||||
const columnName = col.columnName || col.column_name;
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -3950,7 +3986,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
{tabSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
|
|
@ -4064,7 +4102,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
{listSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
|
|
@ -4486,7 +4526,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{formatCellValue(
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
|
||||
// 드래그 가능한 컬럼 아이템
|
||||
function SortableColumnRow({
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, onProgressChange, availableChildColumns, availableParentColumns,
|
||||
}: {
|
||||
id: string;
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string };
|
||||
index: number;
|
||||
isNumeric: boolean;
|
||||
isEntityJoin?: boolean;
|
||||
|
|
@ -41,6 +41,9 @@ function SortableColumnRow({
|
|||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
onProgressChange?: (updates: { numerator?: string; denominator?: string }) => void;
|
||||
availableChildColumns?: Array<{ columnName: string; columnLabel: string }>;
|
||||
availableParentColumns?: Array<{ columnName: string; columnLabel: string }>;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
|
@ -53,12 +56,44 @@ function SortableColumnRow({
|
|||
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
col.type === "progress" && "border-emerald-200 bg-emerald-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{isEntityJoin ? (
|
||||
{col.type === "progress" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="shrink-0 cursor-pointer rounded bg-emerald-100 px-1 text-[9px] font-medium text-emerald-700 hover:bg-emerald-200" title="클릭하여 설정 변경">BAR</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 space-y-2 p-3" align="start">
|
||||
<p className="text-xs font-medium">프로그레스 설정</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">현재값 (자식 컬럼)</Label>
|
||||
<Select value={col.numerator || ""} onValueChange={(v) => onProgressChange?.({ numerator: v })}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(availableChildColumns || []).map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">최대값 (부모 컬럼)</Label>
|
||||
<Select value={col.denominator || ""} onValueChange={(v) => onProgressChange?.({ denominator: v })}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(availableParentColumns || []).map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
|
|
@ -656,6 +691,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
onProgressChange={(updates) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], ...updates };
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
|
||||
availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -685,6 +727,104 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 컬럼 추가 */}
|
||||
{tab.tableName && (
|
||||
<div className="border-border/60 my-2 border-t pt-2">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 select-none">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-emerald-500 transition-transform group-open:rotate-90" />
|
||||
<span className="text-[10px] font-medium text-emerald-600">프로그레스 컬럼 추가</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<Input
|
||||
id={`tab-${tabIndex}-progress-label`}
|
||||
placeholder="예: 샷수 현황"
|
||||
className="h-7 text-xs"
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">현재값 (자식 컬럼)</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
const el = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
|
||||
if (el) el.value = v;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" id={`tab-${tabIndex}-progress-numerator`} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">최대값 (부모 컬럼)</Label>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
const el = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
|
||||
if (el) el.value = v;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{leftTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" id={`tab-${tabIndex}-progress-denominator`} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs text-emerald-700 border-emerald-300 hover:bg-emerald-100"
|
||||
onClick={() => {
|
||||
const labelEl = document.getElementById(`tab-${tabIndex}-progress-label`) as HTMLInputElement;
|
||||
const numEl = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
|
||||
const denEl = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
|
||||
const label = labelEl?.value || "프로그레스";
|
||||
const numerator = numEl?.value;
|
||||
const denominator = denEl?.value;
|
||||
if (!numerator || !denominator) return;
|
||||
updateTab({
|
||||
columns: [
|
||||
...selectedColumns,
|
||||
{
|
||||
name: `progress_${numerator}_${denominator}`,
|
||||
label,
|
||||
width: 200,
|
||||
type: "progress",
|
||||
numerator,
|
||||
denominator,
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
if (labelEl) labelEl.value = "";
|
||||
}}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
|
||||
{(() => {
|
||||
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
"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.post(`/table-management/tables/${tableName}/data`, {
|
||||
page: 1,
|
||||
size: 9999,
|
||||
search: relationColumn ? { [relationColumn]: parentValue } : {},
|
||||
});
|
||||
|
||||
const responseData = res.data?.data;
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(responseData)) {
|
||||
rows = responseData;
|
||||
} else if (responseData && typeof responseData === "object") {
|
||||
rows = Array.isArray(responseData.data) ? responseData.data :
|
||||
Array.isArray(responseData.rows) ? responseData.rows : [];
|
||||
}
|
||||
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__" || item.value === "__ALL__") {
|
||||
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} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,508 @@
|
|||
"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 [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.tableName || !config.statusColumn) {
|
||||
setStatusCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryValues = async () => {
|
||||
setLoadingCategoryValues(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${config.tableName}/${config.statusColumn}/values`
|
||||
);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const flatValues: Array<{ value: string; label: string }> = [];
|
||||
const flatten = (items: any[]) => {
|
||||
for (const item of items) {
|
||||
flatValues.push({
|
||||
value: item.valueCode || item.value_code,
|
||||
label: item.valueLabel || item.value_label,
|
||||
});
|
||||
if (item.children?.length > 0) flatten(item.children);
|
||||
}
|
||||
};
|
||||
flatten(response.data.data);
|
||||
setStatusCategoryValues(flatValues);
|
||||
}
|
||||
} catch {
|
||||
setStatusCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategoryValues(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryValues();
|
||||
}, [config.tableName, config.statusColumn]);
|
||||
|
||||
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>
|
||||
|
||||
{loadingCategoryValues && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 카테고리 값 로딩...
|
||||
</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">
|
||||
{statusCategoryValues.length > 0 ? (
|
||||
<Select
|
||||
value={item.value || ""}
|
||||
onValueChange={(v) => {
|
||||
handleItemChange(i, "value", v);
|
||||
if (v === "__ALL__" && !item.label) {
|
||||
handleItemChange(i, "label", "전체");
|
||||
} else {
|
||||
const catVal = statusCategoryValues.find((cv) => cv.value === v);
|
||||
if (catVal && !item.label) {
|
||||
handleItemChange(i, "label", catVal.label);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="카테고리 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__" className="text-xs font-medium">
|
||||
전체
|
||||
</SelectItem>
|
||||
{statusCategoryValues.map((cv) => (
|
||||
<SelectItem key={cv.value} value={cv.value} className="text-xs">
|
||||
{cv.label} ({cv.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
|
||||
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
|
||||
<p className="text-[10px] text-amber-600">
|
||||
카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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";
|
||||
|
|
@ -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" },
|
||||
};
|
||||
|
|
@ -1441,7 +1441,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
targetColumn = parts[1];
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||
// 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
@ -1524,7 +1525,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// inputType이 category인 경우 카테고리 매핑 로드
|
||||
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`);
|
||||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export type CodePartType =
|
|||
| "number" // 숫자 (고정 자릿수)
|
||||
| "date" // 날짜 (다양한 날짜 형식)
|
||||
| "text" // 문자 (텍스트)
|
||||
| "category"; // 카테고리 (카테고리 값에 따른 형식)
|
||||
| "category" // 카테고리 (카테고리 값에 따른 형식)
|
||||
| "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용)
|
||||
|
||||
/**
|
||||
* 생성 방식
|
||||
|
|
@ -77,6 +78,9 @@ export interface NumberingRulePart {
|
|||
// 카테고리용
|
||||
categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type")
|
||||
categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑
|
||||
|
||||
// 참조용 (마스터-디테일 분번)
|
||||
referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함)
|
||||
};
|
||||
|
||||
// 직접 입력 설정
|
||||
|
|
@ -132,6 +136,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string;
|
|||
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
|
||||
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
|
||||
{ value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" },
|
||||
{ value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" },
|
||||
];
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
@echo off
|
||||
|
||||
REM 스크립트가 있는 디렉토리로 이동
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo =====================================
|
||||
echo PLM 솔루션 - Windows 시작
|
||||
echo =====================================
|
||||
|
||||
echo 기존 컨테이너 및 네트워크 정리 중...
|
||||
docker-compose -f docker-compose.win.yml down -v 2>nul
|
||||
docker network rm plm-network 2>nul
|
||||
|
||||
echo PLM 서비스 시작 중...
|
||||
docker-compose -f docker-compose.win.yml build --no-cache
|
||||
docker-compose -f docker-compose.win.yml up -d
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo.
|
||||
echo ✅ PLM 서비스가 성공적으로 시작되었습니다!
|
||||
echo.
|
||||
echo 🌐 접속 URL:
|
||||
echo • 프론트엔드 (Next.js): http://localhost:3000
|
||||
echo • 백엔드 (Spring/JSP): http://localhost:9090
|
||||
echo.
|
||||
echo 📋 서비스 상태 확인:
|
||||
echo docker-compose -f docker-compose.win.yml ps
|
||||
echo.
|
||||
echo 📊 로그 확인:
|
||||
echo docker-compose -f docker-compose.win.yml logs
|
||||
echo.
|
||||
echo 5초 후 프론트엔드 페이지를 자동으로 엽니다...
|
||||
timeout /t 5 /nobreak >nul
|
||||
start http://localhost:3000
|
||||
) else (
|
||||
echo.
|
||||
echo ❌ PLM 서비스 시작에 실패했습니다!
|
||||
echo.
|
||||
echo 🔍 문제 해결 방법:
|
||||
echo 1. Docker Desktop이 실행 중인지 확인
|
||||
echo 2. 포트가 사용 중인지 확인 (3000, 9090)
|
||||
echo 3. 로그 확인: docker-compose -f docker-compose.win.yml logs
|
||||
echo.
|
||||
pause
|
||||
)
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
|
||||
REM 스크립트가 있는 디렉토리로 이동
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ============================================
|
||||
echo PLM 솔루션 - 전체 서비스 시작 (분리형)
|
||||
echo ============================================
|
||||
|
||||
echo.
|
||||
echo 🚀 백엔드와 프론트엔드를 순차적으로 시작합니다...
|
||||
echo.
|
||||
|
||||
REM 백엔드 먼저 시작
|
||||
echo ============================================
|
||||
echo 1. 백엔드 서비스 시작 중...
|
||||
echo ============================================
|
||||
|
||||
REM 기존 컨테이너 및 네트워크 정리
|
||||
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
|
||||
docker-compose -f docker-compose.frontend.win.yml down -v 2>nul
|
||||
docker network rm pms-network 2>nul
|
||||
|
||||
REM 백엔드 빌드 및 시작
|
||||
docker-compose -f docker-compose.backend.win.yml build --no-cache
|
||||
docker-compose -f docker-compose.backend.win.yml up -d
|
||||
|
||||
echo.
|
||||
echo ⏳ 백엔드 서비스 안정화 대기 중... (20초)
|
||||
timeout /t 20 /nobreak >nul
|
||||
|
||||
REM 프론트엔드 시작
|
||||
echo.
|
||||
echo ============================================
|
||||
echo 2. 프론트엔드 서비스 시작 중...
|
||||
echo ============================================
|
||||
|
||||
REM 프론트엔드 빌드 및 시작
|
||||
docker-compose -f docker-compose.frontend.win.yml build --no-cache
|
||||
docker-compose -f docker-compose.frontend.win.yml up -d
|
||||
|
||||
echo.
|
||||
echo ⏳ 프론트엔드 서비스 안정화 대기 중... (10초)
|
||||
timeout /t 10 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo 🎉 모든 서비스가 시작되었습니다!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo [DATABASE] PostgreSQL: http://39.117.244.52:11132
|
||||
echo [BACKEND] Spring Boot: http://localhost:8080/api
|
||||
echo [FRONTEND] Next.js: http://localhost:9771
|
||||
echo.
|
||||
echo 서비스 상태 확인:
|
||||
echo 백엔드: docker-compose -f docker-compose.backend.win.yml ps
|
||||
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml ps
|
||||
echo.
|
||||
echo 로그 확인:
|
||||
echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f
|
||||
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f
|
||||
echo.
|
||||
echo 서비스 중지:
|
||||
echo 백엔드: docker-compose -f docker-compose.backend.win.yml down
|
||||
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml down
|
||||
echo 전체: stop-all-separated.bat
|
||||
echo.
|
||||
echo ============================================
|
||||
|
||||
pause
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
|
||||
REM 스크립트가 있는 디렉토리로 이동
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ============================================
|
||||
echo PLM 솔루션 - 윈도우 간편 시작
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM Docker Desktop 실행 확인
|
||||
echo 🔍 Docker Desktop 상태 확인 중...
|
||||
docker --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ Docker Desktop이 실행되지 않았습니다!
|
||||
echo Docker Desktop을 먼저 실행해주세요.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ✅ Docker Desktop이 실행 중입니다.
|
||||
echo.
|
||||
|
||||
REM 기존 컨테이너 정리
|
||||
echo 🧹 기존 컨테이너 정리 중...
|
||||
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
|
||||
docker-compose -f docker-compose.frontend.win.yml down -v 2>nul
|
||||
docker network rm pms-network 2>nul
|
||||
echo.
|
||||
|
||||
REM 백엔드 시작
|
||||
echo ============================================
|
||||
echo 🚀 1단계: 백엔드 서비스 시작 중...
|
||||
echo ============================================
|
||||
docker-compose -f docker-compose.backend.win.yml up -d --build
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ 백엔드 시작 실패!
|
||||
echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ✅ 백엔드 서비스 시작 완료
|
||||
echo ⏳ 백엔드 안정화 대기 중... (30초)
|
||||
timeout /t 30 /nobreak >nul
|
||||
|
||||
REM 프론트엔드 시작
|
||||
echo.
|
||||
echo ============================================
|
||||
echo 🎨 2단계: 프론트엔드 서비스 시작 중...
|
||||
echo ============================================
|
||||
docker-compose -f docker-compose.frontend.win.yml up -d --build
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ 프론트엔드 시작 실패!
|
||||
echo 로그를 확인하세요: docker-compose -f docker-compose.frontend.win.yml logs
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ✅ 프론트엔드 서비스 시작 완료
|
||||
echo ⏳ 프론트엔드 안정화 대기 중... (15초)
|
||||
timeout /t 15 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo 🎉 PLM 솔루션이 성공적으로 시작되었습니다!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo 📱 접속 정보:
|
||||
echo • 프론트엔드: http://localhost:9771
|
||||
echo • 백엔드 API: http://localhost:8080/api
|
||||
echo • 데이터베이스: 39.117.244.52:11132
|
||||
echo.
|
||||
echo 📊 서비스 상태 확인:
|
||||
echo docker-compose -f docker-compose.backend.win.yml ps
|
||||
echo docker-compose -f docker-compose.frontend.win.yml ps
|
||||
echo.
|
||||
echo 📋 로그 확인:
|
||||
echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f
|
||||
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f
|
||||
echo.
|
||||
echo 🛑 서비스 중지:
|
||||
echo stop-all-separated.bat 실행
|
||||
echo.
|
||||
|
||||
REM 브라우저 자동 열기
|
||||
echo 5초 후 브라우저에서 애플리케이션을 엽니다...
|
||||
timeout /t 5 /nobreak >nul
|
||||
start http://localhost:9771
|
||||
|
||||
echo.
|
||||
echo 애플리케이션이 준비되었습니다!
|
||||
pause
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
|
||||
echo ============================================
|
||||
echo PLM 솔루션 - 전체 서비스 중지 (분리형)
|
||||
echo ============================================
|
||||
|
||||
echo.
|
||||
echo 🛑 백엔드와 프론트엔드 서비스를 순차적으로 중지합니다...
|
||||
echo.
|
||||
|
||||
REM 프론트엔드 먼저 중지
|
||||
echo ============================================
|
||||
echo 1. 프론트엔드 서비스 중지 중...
|
||||
echo ============================================
|
||||
|
||||
docker-compose -f docker-compose.frontend.win.yml down -v
|
||||
|
||||
echo.
|
||||
echo ⏳ 프론트엔드 서비스 완전 중지 대기 중... (5초)
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
REM 백엔드 중지
|
||||
echo.
|
||||
echo ============================================
|
||||
echo 2. 백엔드 서비스 중지 중...
|
||||
echo ============================================
|
||||
|
||||
docker-compose -f docker-compose.backend.win.yml down -v
|
||||
|
||||
echo.
|
||||
echo ⏳ 백엔드 서비스 완전 중지 대기 중... (5초)
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
REM 네트워크 정리 (선택사항)
|
||||
echo.
|
||||
echo ============================================
|
||||
echo 3. 네트워크 정리 중...
|
||||
echo ============================================
|
||||
|
||||
docker network rm pms-network 2>nul || echo 네트워크가 이미 삭제되었습니다.
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo ✅ 모든 서비스가 중지되었습니다!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo 서비스 상태 확인:
|
||||
echo docker ps
|
||||
echo.
|
||||
echo 서비스 시작:
|
||||
echo start-all-separated.bat
|
||||
echo.
|
||||
echo ============================================
|
||||
|
||||
pause
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
|
||||
REM 스크립트가 있는 디렉토리로 이동
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ============================================
|
||||
echo 백엔드 빌드 테스트 (Windows 전용)
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
echo 🔍 기존 컨테이너 정리 중...
|
||||
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
|
||||
|
||||
echo.
|
||||
echo 🚀 백엔드 빌드 시작...
|
||||
docker-compose -f docker-compose.backend.win.yml build --no-cache
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo.
|
||||
echo ✅ 백엔드 빌드 성공!
|
||||
echo.
|
||||
echo 🚀 백엔드 시작 중...
|
||||
docker-compose -f docker-compose.backend.win.yml up -d
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo ✅ 백엔드 시작 완료!
|
||||
echo.
|
||||
echo 📊 컨테이너 상태:
|
||||
docker-compose -f docker-compose.backend.win.yml ps
|
||||
echo.
|
||||
echo 📋 로그 확인:
|
||||
echo docker-compose -f docker-compose.backend.win.yml logs -f
|
||||
echo.
|
||||
echo 🌐 헬스체크:
|
||||
echo http://localhost:8080/health
|
||||
) else (
|
||||
echo ❌ 백엔드 시작 실패!
|
||||
echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs
|
||||
)
|
||||
) else (
|
||||
echo ❌ 백엔드 빌드 실패!
|
||||
echo 위의 오류 메시지를 확인하세요.
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
Loading…
Reference in New Issue