jskim-node #418

Merged
kjs merged 52 commits from jskim-node into main 2026-03-16 14:53:15 +09:00
50 changed files with 6514 additions and 1117 deletions

View File

@ -0,0 +1,318 @@
/**
* (company_7)
*
* :
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1 (ROLLBACK)
* npx ts-node scripts/btn-bulk-update-company7.ts --run # (COMMIT)
* npx ts-node scripts/btn-bulk-update-company7.ts --backup #
* npx ts-node scripts/btn-bulk-update-company7.ts --restore #
*/
import { Pool } from "pg";
// ── 배포 DB 연결 ──
const pool = new Pool({
connectionString:
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
});
const COMPANY_CODE = "COMPANY_7";
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
const actionIconMap: Record<string, string> = {
save: "Check",
delete: "Trash2",
edit: "Pencil",
navigate: "ArrowRight",
modal: "Maximize2",
transferData: "SendHorizontal",
excel_download: "Download",
excel_upload: "Upload",
quickInsert: "Zap",
control: "Settings",
barcode_scan: "ScanLine",
operation_control: "Truck",
event: "Send",
copy: "Copy",
};
const FALLBACK_ICON = "SquareMousePointer";
function getIconForAction(actionType?: string): string {
if (actionType && actionIconMap[actionType]) {
return actionIconMap[actionType];
}
return FALLBACK_ICON;
}
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
function isTopLevelButton(comp: any): boolean {
return (
comp.url?.includes("v2-button-primary") ||
comp.overrides?.type === "v2-button-primary"
);
}
function isTabChildButton(comp: any): boolean {
return comp.componentType === "v2-button-primary";
}
function isButtonComponent(comp: any): boolean {
return isTopLevelButton(comp) || isTabChildButton(comp);
}
// ── 탭 위젯인지 판별 ──
function isTabsWidget(comp: any): boolean {
return (
comp.url?.includes("v2-tabs-widget") ||
comp.overrides?.type === "v2-tabs-widget"
);
}
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
function applyButtonStyle(config: any, actionType: string | undefined) {
const iconName = getIconForAction(actionType);
config.displayMode = "icon-text";
config.icon = {
name: iconName,
type: "lucide",
size: "보통",
...(config.icon?.color ? { color: config.icon.color } : {}),
};
config.iconTextPosition = "right";
config.iconGap = 6;
if (!config.style) config.style = {};
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
config.style.borderRadius = "8px";
config.style.labelColor = "#FFFFFF";
config.style.fontSize = "12px";
config.style.fontWeight = "normal";
config.style.labelTextAlign = "left";
if (actionType === "delete") {
config.style.backgroundColor = "#F04544";
} else if (actionType === "excel_upload" || actionType === "excel_download") {
config.style.backgroundColor = "#212121";
} else {
config.style.backgroundColor = "#3B83F6";
}
}
function updateButtonStyle(comp: any): boolean {
if (isTopLevelButton(comp)) {
const overrides = comp.overrides || {};
const actionType = overrides.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(overrides, actionType);
comp.overrides = overrides;
return true;
}
if (isTabChildButton(comp)) {
const config = comp.componentConfig || {};
const actionType = config.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(config, actionType);
comp.componentConfig = config;
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
if (!comp.style) comp.style = {};
comp.style.borderRadius = "8px";
comp.style.labelColor = "#FFFFFF";
comp.style.fontSize = "12px";
comp.style.fontWeight = "normal";
comp.style.labelTextAlign = "left";
comp.style.backgroundColor = config.style.backgroundColor;
return true;
}
return false;
}
// ── 백업 테이블 생성 ──
async function createBackup() {
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
const exists = await pool.query(
`SELECT to_regclass($1) AS tbl`,
[BACKUP_TABLE],
);
if (exists.rows[0].tbl) {
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
return;
}
await pool.query(
`CREATE TABLE ${BACKUP_TABLE} AS
SELECT * FROM screen_layouts_v2
WHERE company_code = $1`,
[COMPANY_CODE],
);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
}
// ── 백업에서 원복 ──
async function restoreFromBackup() {
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
const result = await pool.query(
`UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data,
updated_at = backup.updated_at
FROM ${BACKUP_TABLE} AS backup
WHERE target.screen_id = backup.screen_id
AND target.company_code = backup.company_code
AND target.layer_id = backup.layer_id`,
);
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
}
// ── 메인: 버튼 일괄 변경 ──
async function updateButtons(testMode: boolean) {
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
// company_7 레코드 조회
const rows = await pool.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE company_code = $1
ORDER BY screen_id, layer_id`,
[COMPANY_CODE],
);
console.log(`대상 레코드 수: ${rows.rowCount}`);
if (!rows.rowCount) {
console.log("변경할 레코드가 없습니다.");
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
let totalUpdated = 0;
let totalButtons = 0;
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
for (const row of targetRows) {
const layoutData = row.layout_data;
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
continue;
}
let buttonsInRow = 0;
for (const comp of layoutData.components) {
// 최상위 버튼 처리
if (updateButtonStyle(comp)) {
buttonsInRow++;
}
// 탭 위젯 내부 버튼 처리
if (isTabsWidget(comp)) {
const tabs = comp.overrides?.tabs || [];
for (const tab of tabs) {
const tabComps = tab.components || [];
for (const tabComp of tabComps) {
if (updateButtonStyle(tabComp)) {
buttonsInRow++;
}
}
}
}
}
if (buttonsInRow > 0) {
await client.query(
`UPDATE screen_layouts_v2
SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
);
totalUpdated++;
totalButtons += buttonsInRow;
console.log(
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
);
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
if (testMode) {
const sampleBtn = layoutData.components.find(isButtonComponent);
if (sampleBtn) {
console.log("\n--- 변경 후 샘플 버튼 ---");
console.log(JSON.stringify(sampleBtn, null, 2));
}
}
}
}
console.log(`\n--- 결과 ---`);
console.log(`변경된 레코드: ${totalUpdated}`);
console.log(`변경된 버튼: ${totalButtons}`);
if (testMode) {
await client.query("ROLLBACK");
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
} else {
await client.query("COMMIT");
console.log("\nCOMMIT 완료.");
}
} catch (err) {
await client.query("ROLLBACK");
console.error("\n에러 발생. ROLLBACK 완료.", err);
throw err;
} finally {
client.release();
}
}
// ── CLI 진입점 ──
async function main() {
const arg = process.argv[2];
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
console.log("사용법:");
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
console.log(" --run : 전체 실행 (COMMIT)");
console.log(" --backup : 백업 테이블 생성");
console.log(" --restore : 백업에서 원복");
process.exit(1);
}
try {
if (arg === "--backup") {
await createBackup();
} else if (arg === "--restore") {
await restoreFromBackup();
} else if (arg === "--test") {
await createBackup();
await updateButtons(true);
} else if (arg === "--run") {
await createBackup();
await updateButtons(false);
}
} catch (err) {
console.error("스크립트 실행 실패:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();

View File

@ -314,13 +314,14 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
const { formData, manualInputValue } = req.body;
try {
const previewCode = await numberingRuleService.previewCode(
ruleId,
companyCode,
formData
formData,
manualInputValue
);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {

View File

@ -95,6 +95,25 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) {
}
}
// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ───
export async function previewSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { items, options } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" });
}
const data = await productionService.previewSchedule(companyCode, items, options || {});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("자동 스케줄 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 자동 스케줄 생성 ───
export async function generateSchedule(req: AuthenticatedRequest, res: Response) {
@ -141,6 +160,29 @@ export async function mergeSchedules(req: AuthenticatedRequest, res: Response) {
}
}
// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ───
export async function previewSemiSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { plan_ids, options } = req.body;
if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) {
return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" });
}
const data = await productionService.previewSemiSchedule(
companyCode,
plan_ids,
options || {}
);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("반제품 계획 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) {

View File

@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
});
});
// 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출
const v2RepeaterQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
comp->'overrides'->>'type' as component_type,
comp->'overrides'->>'selectedTable' as sub_table,
comp->'overrides'->>'foreignKey' as foreign_key,
comp->'overrides'->>'parentTable' as parent_table
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE sd.screen_id = ANY($1)
AND comp->'overrides'->>'type' = 'v2-repeater'
AND comp->'overrides'->>'selectedTable' IS NOT NULL
`;
const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]);
v2RepeaterResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: 'v2-repeater',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-repeater 서브 테이블 추출 완료", {
screenIds,
v2RepeaterCount: v2RepeaterResult.rows.length,
});
// 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등)
const v2DetailTableQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
inner_comp->>'type' as component_type,
inner_comp->'componentConfig'->>'detailTable' as sub_table,
inner_comp->'componentConfig'->>'foreignKey' as foreign_key
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp,
jsonb_array_elements(
COALESCE(
comp->'overrides'->'rightPanel'->'components',
comp->'overrides'->'leftPanel'->'components',
'[]'::jsonb
)
) as inner_comp
WHERE sd.screen_id = ANY($1)
AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL
`;
const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]);
v2DetailTableResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: row.component_type || 'v2-bom-tree',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", {
screenIds,
v2DetailTableCount: v2DetailTableResult.rows.length,
});
// ============================================================
// 저장 테이블 정보 추출
// ============================================================

View File

@ -21,12 +21,18 @@ router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);
router.delete("/plan/:id", productionController.deletePlan);
// 자동 스케줄 미리보기 (실제 변경 없이 예상 결과)
router.post("/generate-schedule/preview", productionController.previewSchedule);
// 자동 스케줄 생성
router.post("/generate-schedule", productionController.generateSchedule);
// 스케줄 병합
router.post("/merge-schedules", productionController.mergeSchedules);
// 반제품 계획 미리보기
router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule);
// 반제품 계획 자동 생성
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);

View File

@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
@ -74,16 +76,22 @@ class NumberingRuleService {
*/
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
@ -1078,22 +1086,30 @@ class NumberingRuleService {
* @param ruleId ID
* @param companyCode
* @param formData ( )
* @param manualInputValue ( )
*/
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
formData?: Record<string, any>,
manualInputValue?: string
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번 조회
const prefixKey = await this.buildPrefixKey(rule, formData);
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue;
const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool();
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
const currentSeq = skipSequenceLookup
? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq,
ruleId, prefixKey, currentSeq, skipSequenceLookup,
});
const parts = await Promise.all(rule.parts
@ -1108,7 +1124,8 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const nextSequence = currentSeq + 1;
const startFrom = autoConfig.startFrom || 1;
const nextSequence = currentSeq + startFrom;
return String(nextSequence).padStart(length, "0");
}
@ -1150,110 +1167,8 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
// 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
return true;
// 라벨로 매칭 (폴백)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[catTableName, catColumnName, selectedValueStr]
);
if (cvResult.rows.length > 0) {
const resolvedId = cvResult.rows[0].value_id;
const resolvedLabel = cvResult.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
valueCode: selectedValueStr,
resolvedId,
resolvedLabel,
format: mapping.format,
});
}
}
} catch (lookupError: any) {
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
}
}
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
@ -1302,11 +1217,29 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
const prefixKey = await this.buildPrefixKey(rule, formData);
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
}
}
// 2단계: prefix_key 빌드 (수동 값 포함)
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
// 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix(
@ -1320,136 +1253,15 @@ class NumberingRuleService {
}
logger.info("allocateCode: prefix_key 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence,
ruleId, prefixKey, allocatedSequence, extractedManualValues,
});
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category": {
const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || [];
if (!catKey2 || !formData) {
return "CATEGORY";
}
const colName2 = catKey2.includes(".")
? catKey2.split(".")[1]
: catKey2;
const selVal2 = formData[colName2];
if (!selVal2) {
return "CATEGORY";
}
const selValStr2 = String(selVal2);
let catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === selValStr2) return true;
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
if (m.categoryValueLabel === selValStr2) return true;
return false;
});
if (!catMapping2) {
try {
const pool2 = getPool();
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
const cvr2 = await pool2.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct2, cc2, selValStr2]
);
if (cvr2.rows.length > 0) {
const rid2 = cvr2.rows[0].value_id;
const rlabel2 = cvr2.rows[0].value_label;
catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid2)) return true;
if (m.categoryValueLabel === rlabel2) return true;
return false;
});
}
} catch { /* ignore */ }
}
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
);
}
let manualPartIndex = 0;
const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
const manualValue =
extractedManualValues[manualPartIndex] ||
part.manualConfig?.value ||
"";
const manualValue = extractedManualValues[manualPartIndex] || "";
manualPartIndex++;
return manualValue;
}
@ -1459,7 +1271,9 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return String(allocatedSequence).padStart(length, "0");
const startFrom = autoConfig.startFrom || 1;
const actualSequence = allocatedSequence + startFrom - 1;
return String(actualSequence).padStart(length, "0");
}
case "number": {
@ -1496,65 +1310,14 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "";
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "";
}
const selectedValueStr = String(selectedValue);
let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!allocMapping) {
try {
const pool3 = getPool();
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvr3 = await pool3.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct3, cc3, selectedValueStr]
);
if (cvr3.rows.length > 0) {
const rid3 = cvr3.rows[0].value_id;
const rlabel3 = cvr3.rows[0].value_label;
allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid3)) return true;
if (m.categoryValueLabel === rlabel3) return true;
return false;
});
}
} catch { /* ignore */ }
}
if (allocMapping) {
return allocMapping.format || "";
}
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
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 "";
}
@ -1593,6 +1356,139 @@ class NumberingRuleService {
return this.allocateCode(ruleId, companyCode);
}
/**
*
* 릿 ("____")
*/
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
const extractedValues: string[] = [];
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
);
return extractedValues;
}
/**
* format
* categoryKey + formData로 , format
*/
private async resolveCategoryFormat(
autoConfig: Record<string, any>,
formData?: Record<string, any>
): Promise<string> {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
if (!mapping) {
try {
const pool = getPool();
const [tableName, colName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const result = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[tableName, colName, selectedValueStr]
);
if (result.rows.length > 0) {
const resolvedId = result.rows[0].value_id;
const resolvedLabel = result.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
private formatDate(date: Date, format: string): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");

View File

@ -251,6 +251,101 @@ interface GenerateScheduleOptions {
product_type?: string;
}
/**
* (DB )
*/
export async function previewSchedule(
companyCode: string,
items: GenerateScheduleItem[],
options: GenerateScheduleOptions
) {
const pool = getPool();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
const previews: any[] = [];
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
);
keptSchedules.push(...keptResult.rows);
}
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
}
// 해당 품목의 수주 건수 확인
const orderCountResult = await pool.query(
`SELECT COUNT(*) AS cnt FROM sales_order_mng
WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`,
[companyCode, item.item_code]
);
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
previews.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
order_count: orderCount,
status: "planned",
});
}
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
kept_count: keptSchedules.length,
deleted_count: deletedSchedules.length,
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
companyCode: string,
items: GenerateScheduleItem[],
@ -317,14 +412,16 @@ export async function generateSchedule(
endDate.setDate(endDate.getDate() + productionDays);
}
// 계획번호 생성
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const nextNo = planNoResult.rows[0].next_no || 1;
const planNo = `PP-${String(nextNo).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@ -472,6 +569,123 @@ export async function mergeSchedules(
}
}
// ─── 반제품 BOM 소요량 조회 (공통) ───
async function getBomChildItems(
client: any,
companyCode: string,
itemCode: string
) {
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
WHERE b.company_code = $1
AND b.item_code = $2
AND COALESCE(b.status, 'active') = 'active'
`;
const result = await client.query(bomQuery, [companyCode, itemCode]);
return result.rows;
}
// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ───
export async function previewSemiSchedule(
companyCode: string,
planIds: number[],
options: { considerStock?: boolean; excludeUsed?: boolean }
) {
const pool = getPool();
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
const previews: any[] = [];
const existingSemiPlans: any[] = [];
for (const plan of plansResult.rows) {
// 이미 존재하는 반제품 계획 조회
const existingResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`,
[companyCode, plan.id]
);
existingSemiPlans.push(...existingResult.rows);
const bomItems = await getBomChildItems(pool, companyCode, plan.item_code);
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
if (options.considerStock) {
const stockResult = await pool.query(
`SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2`,
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
);
const stock = parseFloat(stockResult.rows[0].stock) || 0;
requiredQty = Math.max(requiredQty - stock, 0);
}
if (requiredQty <= 0) continue;
const semiDueDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
previews.push({
parent_plan_id: plan.id,
parent_plan_no: plan.plan_no,
parent_item_name: plan.item_name,
item_code: bomItem.child_item_code || bomItem.child_item_id,
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
}
}
// 기존 반제품 중 삭제 대상 (status = planned)
const deletedSchedules = existingSemiPlans.filter(
(s) => s.status === "planned"
);
// 기존 반제품 중 유지 대상 (진행중 등)
const keptSchedules = existingSemiPlans.filter(
(s) => s.status !== "planned" && s.status !== "completed"
);
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
deleted_count: deletedSchedules.length,
kept_count: keptSchedules.length,
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(
@ -486,41 +700,36 @@ export async function generateSemiSchedule(
try {
await client.query("BEGIN");
// 선택된 완제품 계획 조회
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await client.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})`,
WHERE company_code = $1 AND id IN (${placeholders})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
// 기존 planned 상태 반제품 삭제
for (const plan of plansResult.rows) {
await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2
AND product_type = '반제품' AND status = 'planned'`,
[companyCode, plan.id]
);
}
const newSemiPlans: any[] = [];
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
for (const plan of plansResult.rows) {
// BOM에서 해당 품목의 반제품 소요량 조회
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_code AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
WHERE b.company_code = $1
AND b.item_code = $2
AND COALESCE(b.status, 'active') = 'active'
`;
const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]);
const bomItems = await getBomChildItems(client, companyCode, plan.item_code);
for (const bomItem of bomResult.rows) {
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
// 재고 고려
if (options.considerStock) {
const stockResult = await client.query(
`SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock
`SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2`,
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
@ -531,18 +740,20 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품 납기일 = 완제품 시작일
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1));
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-S%`]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@ -560,8 +771,8 @@ export async function generateSemiSchedule(
bomItem.child_item_name || bomItem.child_item_id,
requiredQty,
semiStartDate.toISOString().split("T")[0],
typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0],
typeof semiEndDate === "string" ? semiEndDate.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(semiDueDate).toISOString().split("T")[0],
plan.id,
createdBy,
]

View File

@ -12,7 +12,7 @@ services:
environment:
- NODE_ENV=development
- PORT=8080
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:9771

View File

@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] 레지스트리 등록
- [x] 문서화 (README.md)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13)
- [x] 타입 정의 완료
- [x] 기본 구조 생성
@ -539,16 +539,20 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] TimelineGrid (배경)
- [x] ResourceColumn (리소스)
- [x] ScheduleBar 기본 렌더링
- [x] 드래그 이동 (기본)
- [x] 리사이즈 (기본)
- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast)
- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast)
- [x] 줌 레벨 전환
- [x] 날짜 네비게이션
- [ ] 충돌 감지 (향후)
- [ ] 가상 스크롤 (향후)
- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle)
- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커)
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
- [x] staticFilters 지원 (커스텀 테이블 필터링)
- [x] 가상 스크롤 (@tanstack/react-virtual, 30개 이상 리소스 시 자동 활성화)
- [x] 설정 패널 구현
- [x] API 연동
- [x] 레지스트리 등록
- [ ] 테스트 완료
- [x] 테스트 완료 (20개 테스트 전체 통과 - 충돌감지 11건 + 날짜계산 9건)
- [x] 문서화 (README.md)
---

View File

@ -892,3 +892,79 @@ if (process.env.NODE_ENV === "development") {
});
}
```
---
## 12. 생산기간(리드타임) 산출 - 현재 상태 및 개선 방안
> 작성일: 2026-03-16 | 상태: 검토 대기 (스키마 변경 전 상의 필요)
### 12.1 현재 구현 상태
**생산일수 계산 로직** (`productionPlanService.ts`):
```
생산일수 = ceil(계획수량 / 일생산능력)
종료일 = 납기일 - 안전리드타임
시작일 = 종료일 - 생산일수
```
**현재 기본값 (하드코딩):**
| 항목 | 현재값 | 위치 |
|------|--------|------|
| 일생산능력 (daily_capacity) | 800 EA/일 | `productionPlanService.ts` 기본값 |
| 시간당 능력 (hourly_capacity) | 100 EA/시간 | `productionPlanService.ts` 기본값 |
| 안전리드타임 (safety_lead_time) | 1일 | 옵션 기본값 |
| 반제품 리드타임 (lead_time) | 1일 | `production_plan_mng` 기본값 |
**문제점:**
- `item_info`에 생산 파라미터 컬럼이 없음
- 모든 품목이 동일한 기본값(800EA/일)으로 계산됨
- 업체별/품목별 생산능력 차이를 반영 불가
### 12.2 개선 방향 (상의 후 결정)
**1단계 (품목 마스터 기반) - 권장:**
`item_info` 테이블에 컬럼 추가:
- `lead_time_days`: 리드타임 (일)
- `daily_capacity`: 일생산능력
- `min_lot_size`: 최소 생산 단위 (선택)
- `setup_time`: 셋업시간 (선택)
자동 스케줄 생성 시 품목 마스터 조회 → 값 없으면 기본값 사용 (하위 호환)
**2단계 (설비별 능력) - 고객 요청 시:**
별도 테이블 `item_equipment_capacity`:
- 품목 + 설비 조합별 생산능력 관리
- 동일 품목이라도 설비에 따라 능력 다를 때
**3단계 (공정 라우팅) - 대기업 대응:**
공정 순서 + 공정별 소요시간 전체 관리
- 현재 시점에서는 불필요
### 12.3 반제품 계획 생성 현황
**구현 완료 항목:**
- API: `POST /production/generate-semi-schedule/preview` (미리보기)
- API: `POST /production/generate-semi-schedule` (실제 생성)
- BOM 기반 소요량 자동 계산
- 타임라인 컴포넌트 내 "반제품 계획 생성" 버튼 (완제품 탭에서만 표시)
- 반제품 탭: linkedFilter 제거, staticFilters만 사용 (전체 반제품 표시)
**반제품 생산기간 계산:**
- 반제품 납기일 = 완제품 시작일
- 반제품 시작일 = 완제품 시작일 - lead_time (기본 1일)
- BOM 소요량 = 완제품 계획수량 x BOM 수량
**테스트 BOM 데이터:**
| 완제품 | 반제품 | BOM 수량 |
|--------|--------|----------|
| ITEM-001 (탑씰 Type A) | SEMI-001 (탑씰 필름 A) | 2 EA/개 |
| ITEM-001 (탑씰 Type A) | SEMI-002 (탑씰 접착제) | 0.5 KG/개 |
| ITEM-002 (탑씰 Type B) | SEMI-003 (탑씰 필름 B) | 3 EA/개 |
| ITEM-002 (탑씰 Type B) | SEMI-004 (탑씰 코팅제) | 0.3 KG/개 |

View File

@ -1,6 +1,6 @@
# WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스
> **최종 업데이트**: 2026-03-13
> **최종 업데이트**: 2026-03-16
> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전
> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시)
@ -532,15 +532,20 @@ CREATE TABLE "{테이블명}" (
---
### 3.11 v2-timeline-scheduler (간트차트)
### 3.11 v2-timeline-scheduler (간트차트/타임라인)
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집.
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. 품목별 그룹 뷰, 자동 스케줄 생성, 반제품 계획 연동 지원.
**기본 설정**:
| 설정 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| selectedTable | string | - | 스케줄 데이터 테이블 |
| customTableName | string | - | selectedTable 대신 사용 (useCustomTable=true 시) |
| useCustomTable | boolean | `false` | customTableName 사용 여부 |
| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 |
| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` |
| viewMode | string | - | 뷰 모드: `"itemGrouped"` (품목별 카드 그룹) / 미설정 시 리소스 기반 |
| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` |
| editable | boolean | `true` | 편집 가능 |
| draggable | boolean | `true` | 드래그 이동 허용 |
@ -548,15 +553,16 @@ CREATE TABLE "{테이블명}" (
| rowHeight | number | `50` | 행 높이(px) |
| headerHeight | number | `60` | 헤더 높이(px) |
| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) |
| cellWidth.day | number | `60` | 일 단위 셀 너비 |
| cellWidth.week | number | `120` | 주 단위 셀 너비 |
| cellWidth.month | number | `40` | 월 단위 셀 너비 |
| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 |
| showProgress | boolean | `true` | 진행률 바 표시 |
| showTodayLine | boolean | `true` | 오늘 날짜 표시선 |
| showToolbar | boolean | `true` | 상단 툴바 표시 |
| showLegend | boolean | `true` | 범례(상태 색상 안내) 표시 |
| showNavigation | boolean | `true` | 날짜 네비게이션 버튼 표시 |
| showZoomControls | boolean | `true` | 줌 컨트롤 버튼 표시 |
| showAddButton | boolean | `true` | 추가 버튼 |
| height | number | `500` | 높이(px) |
| maxHeight | number | - | 최대 높이(px) |
**fieldMapping (필수)**:
@ -583,10 +589,74 @@ CREATE TABLE "{테이블명}" (
| 상태 | 기본 색상 |
|------|----------|
| planned | `"#3b82f6"` (파랑) |
| in_progress | `"#f59e0b"` (주황) |
| completed | `"#10b981"` (초록) |
| in_progress | `"#10b981"` (초록) |
| completed | `"#6b7280"` (회색) |
| delayed | `"#ef4444"` (빨강) |
| cancelled | `"#6b7280"` (회색) |
| cancelled | `"#9ca3af"` (연회색) |
**staticFilters (정적 필터)** - DB 조회 시 항상 적용되는 WHERE 조건:
| 설정 | 타입 | 설명 |
|------|------|------|
| product_type | string | `"완제품"` 또는 `"반제품"` 등 고정 필터 |
| status | string | 상태값 필터 |
| (임의 컬럼) | string | 해당 컬럼으로 필터링 |
```json
"staticFilters": {
"product_type": "완제품"
}
```
**linkedFilter (연결 필터)** - 다른 컴포넌트(주로 테이블)의 선택 이벤트와 연동:
| 설정 | 타입 | 설명 |
|------|------|------|
| sourceField | string | 소스 컴포넌트(좌측 테이블)의 필터 기준 컬럼 |
| targetField | string | 타임라인 스케줄 데이터에서 매칭할 컬럼 |
| sourceTableName | string | 이벤트 발신 테이블명 (이벤트 필터용) |
| sourceComponentId | string | 이벤트 발신 컴포넌트 ID (선택) |
| emptyMessage | string | 선택 전 빈 상태 메시지 |
| showEmptyWhenNoSelection | boolean | 선택 전 빈 상태 표시 여부 |
```json
"linkedFilter": {
"sourceField": "part_code",
"targetField": "item_code",
"sourceTableName": "sales_order_mng",
"emptyMessage": "좌측 수주 목록에서 품목을 선택하세요",
"showEmptyWhenNoSelection": true
}
```
> **linkedFilter 동작 원리**: v2EventBus의 `TABLE_SELECTION_CHANGE` 이벤트를 구독.
> 좌측 테이블에서 행을 선택하면 해당 행의 `sourceField` 값을 수집하여,
> 타임라인 데이터 중 `targetField`가 일치하는 스케줄만 클라이언트 측에서 필터링 표시.
> `staticFilters`는 서버 측 조회, `linkedFilter`는 클라이언트 측 필터링.
**viewMode: "itemGrouped" (품목별 그룹 뷰)**:
리소스(설비) 기반 간트차트 대신, 품목(item_code)별로 카드를 그룹화하여 표시하는 모드.
각 카드 안에 해당 품목의 스케줄 바가 미니 타임라인으로 표시됨.
설정 시 `viewMode: "itemGrouped"`만 추가하면 됨. 툴바에 자동으로:
- 날짜 네비게이션 (이전/오늘/다음)
- 줌 컨트롤
- 새로고침 버튼
- (완제품 탭일 때) **완제품 계획 생성** / **반제품 계획 생성** 버튼
**자동 스케줄 생성 (내장 기능)**:
`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 일 때 자동 활성화.
- **완제품 계획 생성**: linkedFilter로 선택된 수주 품목 기반, 미리보기 다이얼로그 → 확인 후 생성
- API: `POST /production/generate-schedule/preview``POST /production/generate-schedule`
- **반제품 계획 생성**: 현재 타임라인의 완제품 스케줄 기반, BOM 소요량으로 반제품 계획 미리보기 → 확인 후 생성
- API: `POST /production/generate-semi-schedule/preview``POST /production/generate-semi-schedule`
> **중요**: 반제품 전용 타임라인에는 `linkedFilter`를 걸지 않는다.
> 반제품 item_code가 수주 품목 코드와 다르므로 매칭 불가.
> `staticFilters: { product_type: "반제품" }`만 설정하여 전체 반제품 계획을 표시.
---
@ -923,16 +993,32 @@ CREATE TABLE "{테이블명}" (
## 4. 패턴 의사결정 트리
```
Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler
Q2. 다차원 피벗 분석? → v2-pivot-grid
Q3. 그룹별 접기/펼치기? → v2-table-grouped
Q4. 카드 형태 표시? → v2-card-display
Q5. 마스터-디테일?
Q1. 좌측 마스터 + 우측 탭(타임라인/테이블) 복합 구성?
→ 패턴 F → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler
Q2. 시간축 기반 일정/간트차트?
├ 품목별 카드 그룹 뷰? → 패턴 E-2 → v2-timeline-scheduler(viewMode:itemGrouped)
└ 리소스(설비) 기반? → 패턴 E → v2-timeline-scheduler
Q3. 다차원 피벗 분석? → v2-pivot-grid
Q4. 그룹별 접기/펼치기? → v2-table-grouped
Q5. 카드 형태 표시? → v2-card-display
Q6. 마스터-디테일?
├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs
└ 단일 디테일? → v2-split-panel-layout
Q6. 단일 테이블? → v2-table-search-widget + v2-table-list
Q7. 단일 테이블? → v2-table-search-widget + v2-table-list
```
### 패턴 요약표
| 패턴 | 대표 화면 | 핵심 컴포넌트 |
|------|----------|-------------|
| A | 거래처관리 | v2-table-search-widget + v2-table-list |
| B | 수주관리 | v2-split-panel-layout |
| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs |
| D | 재고현황 | v2-table-grouped |
| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) |
| E-2 | 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) |
| F | 생산계획 | split(custom) + tabs + timeline |
---
## 5. 관계(relation) 레퍼런스

View File

@ -1,6 +1,6 @@
# WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용)
> **최종 업데이트**: 2026-03-13
> **최종 업데이트**: 2026-03-16
> **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드
> **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다
@ -533,7 +533,9 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now();
}
```
### 8.5 패턴 E: 타임라인/간트차트
### 8.5 패턴 E: 타임라인/간트차트 (리소스 기반)
**사용 조건**: 설비/작업자 등 리소스 기준으로 스케줄을 시간축에 표시
```json
{
@ -575,6 +577,246 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now();
}
```
### 8.6 패턴 E-2: 타임라인 (품목 그룹 뷰 + 연결 필터)
**사용 조건**: 좌측 테이블에서 선택한 품목 기반으로 타임라인을 필터링 표시. 품목별 카드 그룹 뷰.
> 리소스(설비) 기반이 아닌, **품목(item_code)별로 카드 그룹** 형태로 스케줄을 표시한다.
> 좌측 테이블에서 행을 선택하면 `linkedFilter`로 해당 품목의 스케줄만 필터링.
> `staticFilters`로 완제품/반제품 등 데이터 유형을 고정 필터링.
```json
{
"id": "timeline_finished",
"url": "@/lib/registry/components/v2-timeline-scheduler",
"position": { "x": 0, "y": 0 },
"size": { "width": 1920, "height": 800 },
"displayOrder": 0,
"overrides": {
"label": "완제품 생산계획",
"selectedTable": "{스케줄_테이블}",
"viewMode": "itemGrouped",
"fieldMapping": {
"id": "id",
"resourceId": "item_code",
"title": "item_name",
"startDate": "start_date",
"endDate": "end_date",
"status": "status"
},
"defaultZoomLevel": "day",
"staticFilters": {
"product_type": "완제품"
},
"linkedFilter": {
"sourceField": "part_code",
"targetField": "item_code",
"sourceTableName": "{좌측_테이블명}",
"emptyMessage": "좌측 목록에서 품목을 선택하세요",
"showEmptyWhenNoSelection": true
}
}
}
```
**핵심 설정 설명**:
| 설정 | 용도 |
|------|------|
| `viewMode: "itemGrouped"` | 리소스 행이 아닌, 품목별 카드 그룹으로 표시 |
| `staticFilters` | DB 조회 시 항상 적용 (서버측 WHERE 조건) |
| `linkedFilter` | 다른 컴포넌트 선택 이벤트로 클라이언트 측 필터링 |
| `linkedFilter.sourceField` | 소스 테이블에서 가져올 값의 컬럼명 |
| `linkedFilter.targetField` | 타임라인 데이터에서 매칭할 컬럼명 |
> **주의**: `linkedFilter``staticFilters`의 차이
> - `staticFilters`: DB SELECT 쿼리의 WHERE 절에 포함 → 서버에서 필터링
> - `linkedFilter`: 전체 데이터를 불러온 후, 선택 이벤트에 따라 클라이언트에서 필터링
### 8.7 패턴 F: 복합 화면 (좌측 테이블 + 우측 탭 내 타임라인)
**사용 조건**: 생산계획처럼 좌측 마스터 테이블 + 우측에 탭으로 여러 타임라인/테이블을 표시하는 복합 화면.
`v2-split-panel-layout``rightPanel.displayMode: "custom"` + `v2-tabs-widget` + `v2-timeline-scheduler` 조합.
**구조 개요**:
```
┌──────────────────────────────────────────────────┐
│ v2-split-panel-layout │
│ ┌──────────┬─────────────────────────────────┐ │
│ │ leftPanel │ rightPanel (displayMode:custom)│ │
│ │ │ ┌─────────────────────────────┐│ │
│ │ v2-table- │ │ v2-tabs-widget ││ │
│ │ grouped │ │ ┌───────┬───────┬─────────┐ ││ │
│ │ (수주목록) │ │ │완제품 │반제품 │기타 탭 │ ││ │
│ │ │ │ └───────┴───────┴─────────┘ ││ │
│ │ │ │ ┌─────────────────────────┐ ││ │
│ │ │ │ │ v2-timeline-scheduler │ ││ │
│ │ │ │ │ (품목별 그룹 뷰) │ ││ │
│ │ │ │ └─────────────────────────┘ ││ │
│ │ │ └─────────────────────────────┘│ │
│ └──────────┴─────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
**실제 layout_data 예시** (생산계획 화면 참고):
```json
{
"version": "2.0",
"components": [
{
"id": "split_pp",
"url": "@/lib/registry/components/v2-split-panel-layout",
"position": { "x": 0, "y": 0 },
"size": { "width": 1920, "height": 850 },
"displayOrder": 0,
"overrides": {
"label": "생산계획",
"splitRatio": 25,
"resizable": true,
"autoLoad": true,
"syncSelection": true,
"leftPanel": {
"title": "수주 목록",
"displayMode": "custom",
"components": [
{
"id": "grouped_orders",
"componentType": "v2-table-grouped",
"label": "수주별 품목",
"position": { "x": 0, "y": 0 },
"size": { "width": 600, "height": 800 },
"componentConfig": {
"selectedTable": "sales_order_mng",
"groupConfig": {
"groupByColumn": "order_number",
"groupLabelFormat": "{value}",
"defaultExpanded": true,
"summary": { "showCount": true }
},
"columns": [
{ "columnName": "part_code", "displayName": "품번", "visible": true, "width": 100 },
{ "columnName": "part_name", "displayName": "품명", "visible": true, "width": 120 },
{ "columnName": "order_qty", "displayName": "수량", "visible": true, "width": 60 },
{ "columnName": "delivery_date", "displayName": "납기일", "visible": true, "width": 90 }
],
"showCheckbox": true,
"checkboxMode": "multi"
}
}
]
},
"rightPanel": {
"title": "생산 계획",
"displayMode": "custom",
"components": [
{
"id": "tabs_pp",
"componentType": "v2-tabs-widget",
"label": "생산계획 탭",
"position": { "x": 0, "y": 0 },
"size": { "width": 1400, "height": 800 },
"componentConfig": {
"tabs": [
{
"id": "tab_finished",
"label": "완제품",
"order": 1,
"components": [
{
"id": "timeline_finished",
"componentType": "v2-timeline-scheduler",
"label": "완제품 타임라인",
"position": { "x": 0, "y": 0 },
"size": { "width": 1380, "height": 750 },
"componentConfig": {
"selectedTable": "production_plan_mng",
"viewMode": "itemGrouped",
"fieldMapping": {
"id": "id",
"resourceId": "item_code",
"title": "item_name",
"startDate": "start_date",
"endDate": "end_date",
"status": "status"
},
"defaultZoomLevel": "day",
"staticFilters": {
"product_type": "완제품"
},
"linkedFilter": {
"sourceField": "part_code",
"targetField": "item_code",
"sourceTableName": "sales_order_mng",
"emptyMessage": "좌측 수주 목록에서 품목을 선택하세요",
"showEmptyWhenNoSelection": true
}
}
}
]
},
{
"id": "tab_semi",
"label": "반제품",
"order": 2,
"components": [
{
"id": "timeline_semi",
"componentType": "v2-timeline-scheduler",
"label": "반제품 타임라인",
"position": { "x": 0, "y": 0 },
"size": { "width": 1380, "height": 750 },
"componentConfig": {
"selectedTable": "production_plan_mng",
"viewMode": "itemGrouped",
"fieldMapping": {
"id": "id",
"resourceId": "item_code",
"title": "item_name",
"startDate": "start_date",
"endDate": "end_date",
"status": "status"
},
"defaultZoomLevel": "day",
"staticFilters": {
"product_type": "반제품"
}
}
}
]
}
],
"defaultTab": "tab_finished"
}
}
]
}
}
}
],
"gridSettings": { "columns": 12, "gap": 16, "padding": 16 },
"screenResolution": { "width": 1920, "height": 1080 }
}
```
**패턴 F 핵심 포인트**:
| 포인트 | 설명 |
|--------|------|
| `leftPanel.displayMode: "custom"` | 좌측에 v2-table-grouped 등 자유 배치 |
| `rightPanel.displayMode: "custom"` | 우측에 v2-tabs-widget 등 자유 배치 |
| `componentConfig` | custom 내부 컴포넌트는 overrides 대신 componentConfig 사용 |
| `componentType` | custom 내부에서는 url 대신 componentType 사용 |
| 완제품 탭에만 `linkedFilter` | 좌측 테이블과 연동 필터링 |
| 반제품 탭에는 `linkedFilter` 없음 | 반제품 item_code가 수주 품목과 다르므로 전체 표시 |
| 자동 스케줄 생성 버튼 | `staticFilters.product_type === "완제품"` 일 때 자동 표시 |
> **displayMode: "custom" 내부 컴포넌트 규칙**:
> - `url` 대신 `componentType` 사용 (예: `"v2-timeline-scheduler"`, `"v2-table-grouped"`)
> - `overrides` 대신 `componentConfig` 사용
> - `position`, `size`는 동일하게 사용
---
## 9. Step 7: menu_info INSERT
@ -696,29 +938,47 @@ VALUES
사용자가 화면을 요청하면 이 트리로 패턴을 결정한다.
```
Q1. 시간축 기반 일정/간트차트가 필요한가?
├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler
Q1. 좌측 마스터 + 우측에 탭으로 타임라인/테이블 등 복합 구성이 필요한가?
├─ YES → 패턴 F (복합 화면) → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler
└─ NO ↓
Q2. 다차원 집계/피벗 분석이 필요한가?
Q2. 시간축 기반 일정/간트차트가 필요한가?
├─ YES → Q2-1. 품목별 카드 그룹 뷰인가?
│ ├─ YES → 패턴 E-2 (품목 그룹 타임라인) → v2-timeline-scheduler(viewMode:itemGrouped)
│ └─ NO → 패턴 E (리소스 기반 타임라인) → v2-timeline-scheduler
└─ NO ↓
Q3. 다차원 집계/피벗 분석이 필요한가?
├─ YES → 피벗 → v2-pivot-grid
└─ NO ↓
Q3. 데이터를 그룹별로 접기/펼치기가 필요한가?
Q4. 데이터를 그룹별로 접기/펼치기가 필요한가?
├─ YES → 패턴 D (그룹화) → v2-table-grouped
└─ NO ↓
Q4. 이미지+정보를 카드 형태로 표시하는가?
Q5. 이미지+정보를 카드 형태로 표시하는가?
├─ YES → 카드뷰 → v2-card-display
└─ NO ↓
Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가?
├─ YES → Q5-1. 디테일에 여러 탭이 필요한가?
Q6. 마스터 테이블 선택 시 연관 디테일이 필요한가?
├─ YES → Q6-1. 디테일에 여러 탭이 필요한가?
│ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs
│ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout
└─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list
```
### 패턴 선택 빠른 참조
| 패턴 | 대표 화면 | 핵심 컴포넌트 |
|------|----------|-------------|
| A | 거래처관리, 코드관리 | v2-table-search-widget + v2-table-list |
| B | 수주관리, 발주관리 | v2-split-panel-layout |
| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs |
| D | 재고현황, 그룹별조회 | v2-table-grouped |
| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) |
| E-2 | 단독 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) |
| F | 생산계획, 작업지시 | v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler |
---
## 13. 화면 간 연결 관계 정의
@ -1119,7 +1379,8 @@ VALUES (
| 검색 바 | v2-table-search-widget | `autoSelectFirstTable` |
| 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` |
| 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` |
| 간트차트 | v2-timeline-scheduler | `fieldMapping`, `resourceTable` |
| 간트차트 (리소스 기반) | v2-timeline-scheduler | `fieldMapping`, `resourceTable` |
| 타임라인 (품목 그룹) | v2-timeline-scheduler | `viewMode:"itemGrouped"`, `staticFilters`, `linkedFilter` |
| 피벗 분석 | v2-pivot-grid | `fields(area, summaryType)` |
| 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` |
| 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` |
@ -1144,3 +1405,97 @@ VALUES (
| 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` |
| 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` |
| 품목 라우팅 | v2-item-routing | `dataSource.itemTable`, `dataSource.routingDetailTable` |
---
## 17. v2-timeline-scheduler 고급 설정 가이드
### 17.1 viewMode 선택 기준
| viewMode | 용도 | Y축 |
|----------|------|-----|
| (미설정) | 설비별 작업일정, 보전계획 | 설비/작업자 행 |
| `"itemGrouped"` | 생산계획, 출하계획 | 품목별 카드 그룹 |
### 17.2 staticFilters vs linkedFilter 비교
| 구분 | staticFilters | linkedFilter |
|------|--------------|-------------|
| **적용 시점** | DB SELECT 쿼리 시 | 클라이언트 렌더링 시 |
| **위치** | 서버 측 (WHERE 절) | 프론트 측 (JS 필터링) |
| **변경 가능** | 고정 (layout에 하드코딩) | 동적 (이벤트 기반) |
| **용도** | 완제품/반제품 구분 등 | 좌측 테이블 선택 연동 |
**조합 예시**:
```
staticFilters: { product_type: "완제품" } → DB에서 완제품만 조회
linkedFilter: { sourceField: "part_code", targetField: "item_code" }
→ 완제품 중 좌측에서 선택한 품목만 표시
```
### 17.3 자동 스케줄 생성 (내장 기능)
`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 조건 충족 시,
타임라인 툴바에 **완제품 계획 생성** / **반제품 계획 생성** 버튼이 자동 표시됨.
**완제품 계획 생성 플로우**:
```
1. linkedFilter로 선택된 수주 품목 수집
2. POST /production/generate-schedule/preview → 미리보기 다이얼로그
3. 사용자 확인 → POST /production/generate-schedule → 실제 생성
4. 타임라인 자동 새로고침
```
**반제품 계획 생성 플로우**:
```
1. 현재 타임라인의 완제품 스케줄 ID 수집
2. POST /production/generate-semi-schedule/preview → BOM 기반 소요량 계산
3. 미리보기 다이얼로그 (기존 반제품 계획 삭제/유지 정보 포함)
4. 사용자 확인 → POST /production/generate-semi-schedule → 실제 생성
5. 반제품 탭으로 전환 시 새 데이터 표시
```
### 17.4 반제품 탭 주의사항
반제품 전용 타임라인에는 `linkedFilter`**걸지 않는다**.
이유: 반제품의 `item_code`(예: `SEMI-001`)와 수주 품목의 `part_code`(예: `ITEM-001`)가
서로 다른 값이므로 매칭이 불가능하다. `staticFilters: { product_type: "반제품" }`만 설정.
```json
{
"id": "timeline_semi",
"componentType": "v2-timeline-scheduler",
"componentConfig": {
"selectedTable": "production_plan_mng",
"viewMode": "itemGrouped",
"staticFilters": { "product_type": "반제품" },
"fieldMapping": { "..." : "..." }
}
}
```
### 17.5 이벤트 연동 (v2EventBus)
타임라인 컴포넌트는 `v2EventBus`를 통해 다른 컴포넌트와 통신한다.
| 이벤트 | 방향 | 설명 |
|--------|------|------|
| `TABLE_SELECTION_CHANGE` | 수신 | 좌측 테이블 행 선택 시 linkedFilter 적용 |
| `TIMELINE_REFRESH` | 발신/수신 | 타임라인 데이터 새로고침 |
**연결 필터 이벤트 페이로드**:
```typescript
{
eventType: "TABLE_SELECTION_CHANGE",
source: "grouped_orders",
tableName: "sales_order_mng",
selectedRows: [
{ id: "...", part_code: "ITEM-001", ... },
{ id: "...", part_code: "ITEM-002", ... }
]
}
```
타임라인은 `selectedRows`에서 `linkedFilter.sourceField` 값을 추출하여,
자신의 데이터 중 `linkedFilter.targetField`가 일치하는 항목만 표시.

View File

@ -0,0 +1,451 @@
# 생산계획 화면 (TOPSEAL_PP_MAIN) 테스트 시나리오
> **화면 URL**: `http://localhost:9771/screens/3985`
> **로그인 정보**: `topseal_admin` / `qlalfqjsgh11`
> **작성일**: 2026-03-16
---
## 사전 조건
- 백엔드 서버 (포트 8080) 실행 중
- 프론트엔드 서버 (포트 9771) 실행 중
- `topseal_admin` 계정으로 로그인 완료
- 사이드바 > 생산관리 > 생산계획 메뉴 클릭하여 화면 진입
### 현재 테스트 데이터 현황
| 구분 | 건수 | 상세 |
|------|:----:|------|
| 완제품 생산계획 | 7건 | planned(3), in_progress(3), completed(1) |
| 반제품 생산계획 | 6건 | planned(2), in_progress(2), completed(1) |
| 설비(리소스) | 10개 | CNC밀링#1~#2, 머시닝센터#1, 레이저절단기, 프레스기#1, 용접기#1, 도장설비#1, 조립라인#1, 검사대#1~#2 |
| 수주 데이터 | 10건 | sales_order_mng |
---
## TC-01. 화면 레이아웃 확인
### 목적
화면이 설계대로 좌/우 분할 패널로 렌더링되는지 확인
### 테스트 단계
1. 생산계획 화면 진입
2. 좌측 패널에 "수주 데이터" 탭이 보이는지 확인
3. 우측 패널에 "완제품" / "반제품" 탭이 보이는지 확인
4. 분할 패널 비율이 약 45:55인지 확인
### 예상 결과
- [ ] 좌측: "수주데이터" 탭 + "안전재고 부족분" 탭
- [ ] 우측: "완제품" 탭 + "반제품" 탭
- [ ] 하단에 버튼들 (새로고침, 자동 스케줄, 병합, 반제품계획, 저장) 표시
- [ ] 좌측 하단에 "선택 품목 불러오기" 버튼 표시
---
## TC-02. 좌측 패널 - 수주데이터 그룹 테이블
### 목적
v2-table-grouped 컴포넌트의 그룹화 및 접기/펼치기 기능 확인
### 테스트 단계
1. "수주데이터" 탭 선택
2. 데이터가 품목코드(part_code) 기준으로 그룹화되었는지 확인
3. 그룹 헤더 행에 품명, 품목코드가 표시되는지 확인
4. 그룹 헤더 클릭하여 접기/펼치기 토글
5. "전체 펼치기" / "전체 접기" 버튼 동작 확인
6. 그룹별 합계(수주량, 출고량, 잔량) 표시 확인
### 예상 결과
- [ ] 데이터가 part_code 기준으로 그룹화되어 표시
- [ ] 그룹 헤더에 `{품명} ({품목코드})` 형식으로 표시
- [ ] 그룹 헤더 클릭 시 하위 행 접기/펼치기 동작
- [ ] 전체 펼치기/접기 버튼 정상 동작
- [ ] 그룹별 수주량/출고량/잔량 합계 표시
---
## TC-03. 좌측 패널 - 체크박스 선택
### 목적
그룹 테이블에서 체크박스 선택이 정상 동작하는지 확인
### 테스트 단계
1. 개별 행 체크박스 선택/해제
2. 그룹 헤더 체크박스로 그룹 전체 선택/해제
3. 다른 그룹의 행도 동시 선택 가능한지 확인
4. 선택된 행이 하이라이트되는지 확인
### 예상 결과
- [ ] 개별 행 체크박스 선택/해제 정상
- [ ] 그룹 체크박스로 하위 전체 선택/해제
- [ ] 여러 그룹에서 동시 선택 가능
- [ ] 선택된 행 시각적 구분 (하이라이트)
---
## TC-04. 우측 패널 - 완제품 타임라인 기본 표시
### 목적
v2-timeline-scheduler의 기본 렌더링 및 데이터 표시 확인
### 테스트 단계
1. "완제품" 탭 선택 (기본 선택)
2. 타임라인 헤더에 날짜가 표시되는지 확인
3. 리소스(설비) 목록이 좌측에 표시되는지 확인
4. 스케줄 바가 해당 설비/날짜에 표시되는지 확인
5. 스케줄 바에 품명이 표시되는지 확인
6. 오늘 날짜 라인(빨간 세로선)이 표시되는지 확인
### 예상 결과
- [ ] 타임라인 헤더에 날짜 표시 (월 그룹 + 일별)
- [ ] 좌측 리소스 열에 설비명 표시 (프레스기#1, CNC밀링머신#1 등)
- [ ] 7건의 완제품 스케줄 바가 올바른 위치에 표시
- [ ] 스케줄 바에 item_name 표시
- [ ] 오늘 날짜 (2026-03-16) 위치에 빨간 세로선 표시
- [ ] "반제품" 데이터는 보이지 않음 (staticFilters 적용 확인)
---
## TC-05. 타임라인 - 상태별 색상 표시
### 목적
스케줄 상태에 따른 색상 구분 확인
### 테스트 단계
1. 완제품 탭에서 스케줄 바 색상 확인
2. 각 상태별 색상이 다른지 확인
### 예상 결과
- [ ] `planned` (계획): 파란색 (#3b82f6)
- [ ] `in_progress` (진행): 초록색 (#10b981)
- [ ] `completed` (완료): 회색 (#6b7280)
- [ ] `delayed` (지연): 빨간색 (#ef4444) - 해당 데이터 있으면
- [ ] 상태별 색상이 명확히 구분됨
---
## TC-06. 타임라인 - 진행률 표시
### 목적
스케줄 바 내부에 진행률이 시각적으로 표시되는지 확인
### 테스트 단계
1. 진행률이 있는 스케줄 바 확인
2. 바 내부에 진행률 비율만큼 채워진 영역 확인
3. 진행률 퍼센트 텍스트 표시 확인
### 예상 결과
- [ ] `탑씰 Type A` (id:103): 40% 진행률 표시
- [ ] `탑씰 Type B` (id:2): 25% 진행률 표시
- [ ] `탑씰 Type C` (id:105): 25% 진행률 표시
- [ ] `탑씰 Type A` (id:4): 100% 진행률 표시 (완료)
- [ ] 바 내부에 진행 영역이 색이 다르게 채워짐
---
## TC-07. 타임라인 - 줌 레벨 전환
### 목적
일/주/월 줌 레벨 전환이 정상 동작하는지 확인
### 테스트 단계
1. 툴바에서 "주" (기본) 줌 레벨 확인
2. "일" 줌 레벨로 전환 -> 날짜 간격 변화 확인
3. "월" 줌 레벨로 전환 -> 날짜 간격 변화 확인
4. 다시 "주" 줌 레벨로 복귀
### 예상 결과
- [ ] "일" 모드: 날짜 셀이 넓어지고, 하루 단위로 상세 표시
- [ ] "주" 모드: 기본 크기, 주 단위 표시
- [ ] "월" 모드: 날짜 셀이 좁아지고, 월 단위로 축소 표시
- [ ] 줌 레벨 전환 시 스케줄 바 위치/크기가 자동 조정
---
## TC-08. 타임라인 - 날짜 네비게이션
### 목적
이전/다음/오늘 버튼으로 타임라인 이동이 정상 동작하는지 확인
### 테스트 단계
1. 툴바에서 현재 표시 날짜 확인
2. "다음" 버튼 클릭 -> 다음 주(또는 기간)로 이동
3. "이전" 버튼 클릭 -> 이전 주로 이동
4. "오늘" 버튼 클릭 -> 현재 날짜 영역으로 이동
5. 2월 초 데이터가 있으므로 충분히 이전으로 이동하여 과거 데이터 확인
### 예상 결과
- [ ] "다음" 클릭 시 타임라인이 오른쪽(미래)으로 이동
- [ ] "이전" 클릭 시 타임라인이 왼쪽(과거)으로 이동
- [ ] "오늘" 클릭 시 2026-03-16 부근으로 이동
- [ ] 날짜 헤더의 표시 날짜가 변경됨
- [ ] 이동 후에도 스케줄 바가 올바른 위치에 표시
---
## TC-09. 타임라인 - 드래그 이동
### 목적
스케줄 바를 드래그하여 날짜를 변경하는 기능 확인
### 테스트 단계
1. 완제품 탭에서 `planned` 상태의 스케줄 바 선택 (예: 탑씰 Type A, id:106)
2. 스케줄 바를 마우스로 클릭하고 좌/우로 드래그
3. 드래그 중 바가 마우스를 따라 이동하는지 확인 (시각적 피드백)
4. 마우스 놓기 후 결과 확인
5. 성공 시 토스트 알림 확인
6. DB에 start_date/end_date가 변경되었는지 확인
### 예상 결과
- [ ] 스케줄 바 드래그 시 시각적으로 이동 (opacity 변화)
- [ ] 드래그 완료 후 "스케줄이 이동되었습니다" 토스트 표시
- [ ] 날짜가 드래그 거리만큼 변경 (시작일/종료일 동일 간격 유지)
- [ ] 실패 시 "스케줄 이동 실패" 에러 토스트 표시 후 원래 위치로 복귀
---
## TC-10. 타임라인 - 리사이즈 (기간 조정)
### 목적
스케줄 바의 시작/종료 핸들을 드래그하여 기간을 변경하는 기능 확인
### 테스트 단계
1. 완제품 탭에서 스케줄 바에 마우스 호버
2. 바 좌측/우측에 리사이즈 핸들이 나타나는지 확인
3. 우측 핸들을 오른쪽으로 드래그 -> 종료일 연장
4. 좌측 핸들을 오른쪽으로 드래그 -> 시작일 변경
5. 성공 시 토스트 알림 확인
### 예상 결과
- [ ] 바 호버 시 좌/우측에 리사이즈 핸들(세로 바) 표시
- [ ] 우측 핸들 드래그 시 종료일만 변경 (시작일 유지)
- [ ] 좌측 핸들 드래그 시 시작일만 변경 (종료일 유지)
- [ ] 리사이즈 완료 후 "스케줄 기간이 변경되었습니다" 토스트 표시
- [ ] 바 크기가 변경된 기간에 맞게 조정
---
## TC-11. 타임라인 - 충돌 감지
### 목적
같은 설비에 시간이 겹치는 스케줄이 있을 때 충돌 표시가 되는지 확인
### 테스트 단계
1. 충돌 데이터 확인:
- 프레스기#1 (equipment_id=11): id:103 (03/10~03/17), id:4 (01/28~01/30) → 겹치지 않아서 충돌 없음
- 조립라인#1 (equipment_id=14): id:5 (02/01~02/02), id:6 (02/01~02/02) → 기간 겹침! (반제품)
2. 반제품 탭으로 이동하여 조립라인#1의 충돌 확인
3. 또는 드래그로 충돌 상황을 만들어서 확인
### 예상 결과
- [ ] 충돌 스케줄 바에 빨간 외곽선 (`ring-destructive`) 표시
- [ ] 충돌 스케줄 바에 경고 아이콘 (AlertTriangle) 표시
- [ ] 툴바에 "충돌 N건" 배지 표시 (빨간색)
- [ ] 충돌이 없는 경우 배지 미표시
---
## TC-12. 타임라인 - 범례 (Legend)
### 목적
하단 범례가 정상 표시되는지 확인
### 테스트 단계
1. 타임라인 하단에 범례 영역이 표시되는지 확인
2. 상태별 색상 스와치가 표시되는지 확인
3. 마일스톤 아이콘이 표시되는지 확인
4. 충돌 표시 범례가 표시되는지 확인
### 예상 결과
- [ ] "계획" (파란색), "진행" (초록색), "완료" (회색), "지연" (빨간색), "취소" (연회색) 표시
- [ ] "마일스톤" 다이아몬드 아이콘 표시
- [ ] "충돌" 빨간 테두리 아이콘 표시 (showConflicts 설정 시)
- [ ] 범례가 타임라인 하단에 깔끔하게 배치
---
## TC-13. 반제품 탭 전환
### 목적
반제품 탭으로 전환 시 반제품 데이터만 필터링되어 표시되는지 확인 (staticFilters)
### 테스트 단계
1. 우측 패널에서 "반제품" 탭 클릭
2. 표시되는 스케줄이 반제품만인지 확인
3. 완제품 데이터가 보이지 않는지 확인
4. 다시 "완제품" 탭 클릭하여 전환 확인
### 예상 결과
- [ ] "반제품" 탭 클릭 시 반제품 스케줄만 표시 (4건)
- [ ] 반제품 리소스: 조립라인#1, 용접기#1, 레이저절단기
- [ ] 완제품 데이터는 표시되지 않음
- [ ] "완제품" 탭 복귀 시 완제품 데이터만 표시
---
## TC-14. 버튼 - 새로고침
### 목적
"새로고침" 버튼 클릭 시 데이터가 다시 로드되는지 확인
### 테스트 단계
1. 우측 패널 하단의 "새로고침" 버튼 클릭
2. 타임라인 데이터가 다시 로드되는지 확인
3. 토스트 알림 확인
### 예상 결과
- [ ] 클릭 시 API 호출 (GET /api/production/order-summary)
- [ ] 성공 시 "데이터를 새로고침했습니다." 토스트 표시
- [ ] 타임라인 데이터 갱신
---
## TC-15. 버튼 - 자동 스케줄
### 목적
좌측 테이블에서 수주 데이터를 선택한 후 자동 스케줄 생성이 되는지 확인
### 테스트 단계
1. 좌측 패널에서 수주 데이터 행 1개 이상 체크박스 선택
2. "자동 스케줄" 버튼 클릭
3. 확인 다이얼로그 표시 확인 ("선택한 품목의 자동 스케줄을 생성하시겠습니까?")
4. "확인" 클릭
5. 결과 확인
### 예상 결과
- [ ] 확인 다이얼로그 표시
- [ ] 성공 시 "자동 스케줄이 생성되었습니다." 토스트 표시
- [ ] 우측 타임라인에 새로운 스케줄 바 추가
- [ ] 실패 시 에러 메시지 표시
- [ ] 선택 없이 클릭 시 적절한 안내 메시지
---
## TC-16. 버튼 - 선택 품목 불러오기
### 목적
좌측 수주 데이터에서 선택한 품목을 생산계획으로 불러오는 기능 확인
### 테스트 단계
1. 좌측 수주데이터 탭에서 품목 선택 (체크박스)
2. "선택 품목 불러오기" 버튼 클릭
3. 확인 다이얼로그 ("선택한 품목의 생산계획을 생성하시겠습니까?")
4. 결과 확인
### 예상 결과
- [ ] 확인 다이얼로그 표시
- [ ] 성공 시 "선택 품목이 불러와졌습니다." 토스트 표시
- [ ] 타임라인 자동 새로고침
---
## TC-17. 버튼 - 저장
### 목적
변경된 생산계획 데이터가 저장되는지 확인
### 테스트 단계
1. 타임라인에서 스케줄 바 드래그 또는 리사이즈로 데이터 변경
2. "저장" 버튼 클릭
3. 저장 결과 확인
### 예상 결과
- [ ] 성공 시 "생산계획이 저장되었습니다." 토스트 표시
- [ ] 변경 사항이 DB에 반영
---
## TC-18. 반응형 CSS 확인
### 목적
공통 반응형 CSS가 올바르게 적용되었는지 확인
### 테스트 단계
1. 브라우저 창 너비를 640px 이하로 줄이기 (모바일)
2. 텍스트 크기, 버튼 크기, 패딩 변화 확인
3. 브라우저 창 너비를 1280px 이상으로 늘리기 (데스크톱)
4. 원래 크기로 복귀 확인
### 예상 결과
- [ ] 모바일(~640px): 텍스트 `text-[10px]`, 작은 버튼, 좁은 패딩
- [ ] 데스크톱(640px~): 텍스트 `text-sm`, 기본 버튼, 넓은 패딩
- [ ] 줌 버튼, 네비게이션 버튼, 리소스명, 날짜 헤더 모두 반응형 적용
- [ ] 스케줄 바 내부 텍스트도 반응형 (text-[10px] sm:text-xs)
- [ ] 범례 텍스트도 반응형
---
## TC-19. 마일스톤 표시
### 목적
시작일과 종료일이 같은 스케줄이 마일스톤(다이아몬드)으로 표시되는지 확인
### 테스트 단계
1. DB에 마일스톤 테스트 데이터 추가:
```sql
INSERT INTO production_plan_mng (id, item_name, product_type, status, start_date, end_date, equipment_id, progress_rate, company_code)
VALUES (200, '마일스톤 테스트', '완제품', 'planned', '2026-03-20', '2026-03-20', 9, '0', 'COMPANY_7');
```
2. 새로고침 후 해당 날짜에 다이아몬드 마커가 표시되는지 확인
3. 호버 시 정보 표시 확인
### 예상 결과
- [ ] 시작일 = 종료일인 스케줄은 바 대신 다이아몬드 마커로 표시
- [ ] 다이아몬드가 45도 회전된 정사각형으로 표시
- [ ] 호버 시 효과 적용
---
## TC-20. 안전재고 부족분 탭
### 목적
좌측 패널의 "안전재고 부족분" 탭이 정상 동작하는지 확인
### 테스트 단계
1. 좌측 패널에서 "안전재고 부족분" 탭 클릭
2. inventory_stock 테이블 데이터가 표시되는지 확인
3. 빈 데이터인 경우 빈 상태 메시지 확인
### 예상 결과
- [ ] 탭 전환 정상 동작
- [ ] 데이터 있으면: 품목코드, 현재고, 안전재고, 창고, 최근입고일 표시
- [ ] 데이터 없으면: "안전재고 부족분 데이터가 없습니다" 메시지
---
## 알려진 이슈 / 참고 사항
| 번호 | 내용 | 심각도 |
|:----:|------|:------:|
| 1 | "1 Issue" 배지가 화면 좌측 하단에 표시됨 (원인 미확인) | 낮음 |
| 2 | 생산계획 화면 URL 직접 접근 시 회사정보 화면(138)이 먼저 보일 수 있음 → 사이드바 메뉴를 통해 접근 권장 | 중간 |
| 3 | 설비(equipment_info)의 equipment_group이 null → 리소스 그룹핑 미표시 | 낮음 |
| 4 | 가상 스크롤은 리소스(설비) 30개 이상일 때 자동 활성화 (현재 10개라 비활성) | 참고 |
---
## 테스트 결과 요약
| TC | 항목 | 결과 | 비고 |
|:--:|------|:----:|------|
| 01 | 화면 레이아웃 | | |
| 02 | 수주데이터 그룹 테이블 | | |
| 03 | 체크박스 선택 | | |
| 04 | 완제품 타임라인 기본 표시 | | |
| 05 | 상태별 색상 | | |
| 06 | 진행률 표시 | | |
| 07 | 줌 레벨 전환 | | |
| 08 | 날짜 네비게이션 | | |
| 09 | 드래그 이동 | | |
| 10 | 리사이즈 | | |
| 11 | 충돌 감지 | | |
| 12 | 범례 | | |
| 13 | 반제품 탭 전환 | | |
| 14 | 새로고침 버튼 | | |
| 15 | 자동 스케줄 버튼 | | |
| 16 | 선택 품목 불러오기 | | |
| 17 | 저장 버튼 | | |
| 18 | 반응형 CSS | | |
| 19 | 마일스톤 표시 | | |
| 20 | 안전재고 부족분 탭 | | |

View File

@ -323,7 +323,7 @@ interface ButtonComponentConfig {
| 파일 | 내용 |
|------|------|
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
| `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
---
@ -338,3 +338,52 @@ interface ButtonComponentConfig {
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨
- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정
---
## [미구현] 커스텀 아이콘 전역 관리
### 현재 문제
- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임
- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임
- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함
### 변경 후 동작
- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리
- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시
- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출
### DB 테이블 (신규)
```sql
CREATE TABLE custom_icon_registry (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
icon_name VARCHAR(500) NOT NULL,
icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg'
svg_data TEXT, -- SVG일 경우 원본 데이터
created_date TIMESTAMP DEFAULT now(),
updated_date TIMESTAMP DEFAULT now(),
writer VARCHAR(500)
);
CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code);
```
### 백엔드 API (신규)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) |
| POST | `/api/custom-icons` | 커스텀 아이콘 추가 |
| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 |
### 프론트엔드 변경
- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경
- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션)
- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관

View File

@ -145,8 +145,24 @@
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
- **구현**: `lucide-react``icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링
- **주의**: `allLucideIcons``button-icon-map.tsx`에서 re-export하여 import를 중앙화
### 18. 커스텀 아이콘 전역 관리 (미구현)
- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경
- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음
- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합
- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장
- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션
### 19. 동적 아이콘 로딩 (getLucideIcon fallback)
- **결정**: `getLucideIcon(name)``iconMap`에 없는 아이콘을 `lucide-react``icons` 전체 객체에서 동적으로 조회 후 캐싱
- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링**
- **구현**: `button-icon-map.tsx``import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱
- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요
- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험)
---
@ -159,7 +175,7 @@
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback |
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
---
@ -169,17 +185,21 @@
### lucide-react 아이콘 동적 렌더링
```typescript
// button-icon-map.ts
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
// button-icon-map.tsx
import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react";
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Check, Save, Trash2, Pencil, ...
};
// 추천 아이콘은 명시적 import, 나머지는 동적 조회
const iconMap: Record<string, LucideIcon> = { Check, Save, ... };
export function renderButtonIcon(name: string, size: string | number) {
const IconComponent = iconMap[name];
if (!IconComponent) return null;
return <IconComponent style={getIconSizeStyle(size)} />;
export function getLucideIcon(name: string): LucideIcon | undefined {
if (iconMap[name]) return iconMap[name];
// iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱
const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
}
```

View File

@ -125,12 +125,30 @@
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
### 6단계: 정리
### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정)
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
- [x] `button-icon-map.tsx``icons as allLucideIcons` import 추가
- [x] `getLucideIcon()``iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱
- [x] `allLucideIcons``button-icon-map.tsx`에서 re-export (import 중앙화)
- [x] `ButtonConfigPanel.tsx``lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합
- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인
### 7단계: 정리
- [x] TypeScript 컴파일 에러 없음 확인
- [x] 불필요한 import 없음 확인
- [x] 문서 3개 최신화 (동적 로딩 반영)
- [x] 이 체크리스트 완료 표시 업데이트
### 8단계: 커스텀 아이콘 전역 관리 (미구현)
- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭)
- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`)
- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`)
- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경
- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리
- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인
---
## 변경 이력
@ -156,3 +174,6 @@
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 |
| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) |
| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 |

View File

@ -0,0 +1,171 @@
# BTN - 버튼 UI 스타일 기준정보
## 1. 스타일 기준
### 공통 스타일
| 항목 | 값 |
|---|---|
| 높이 | 40px |
| 표시모드 | 아이콘 + 텍스트 (icon-text) |
| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) |
| 아이콘 크기 비율 | 보통 |
| 아이콘-텍스트 간격 | 6px |
| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) |
| 테두리 모서리 | 8px |
| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) |
| 텍스트 색상 | #FFFFFF (흰색) |
| 텍스트 크기 | 12px |
| 텍스트 굵기 | normal (보통) |
| 텍스트 정렬 | 왼쪽 |
### 배경색 (액션별)
| 액션 타입 | 배경색 | 비고 |
|---|---|---|
| `delete` | `#F04544` | 빨간색 |
| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 |
| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) |
배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다.
### 너비 (텍스트 글자수별)
| 글자수 | 너비 |
|---|---|
| 6글자 이하 | 140px |
| 7글자 이상 | 160px |
### 액션별 기본 아이콘
디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다.
소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap`
| action.type | 기본 아이콘 |
|---|---|
| `save` | Check |
| `delete` | Trash2 |
| `edit` | Pencil |
| `navigate` | ArrowRight |
| `modal` | Maximize2 |
| `transferData` | SendHorizontal |
| `excel_download` | Download |
| `excel_upload` | Upload |
| `quickInsert` | Zap |
| `control` | Settings |
| `barcode_scan` | ScanLine |
| `operation_control` | Truck |
| `event` | Send |
| `copy` | Copy |
| (그 외/없음) | SquareMousePointer |
---
## 2. 코드 반영 현황
### 컴포넌트 기본값 (신규 버튼 생성 시 적용)
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) |
| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig |
### 액션 변경 시 배경색 자동 변경
| 파일 | 내용 |
|---|---|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 |
### 렌더링 배경색 우선순위
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 |
배경색 결정 순서:
1. `webTypeConfig.backgroundColor`
2. `componentConfig.backgroundColor`
3. `component.style.backgroundColor`
4. `componentConfig.style.backgroundColor`
5. `component.style.labelColor` (레거시 호환)
6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`)
### 미반영 (추후 작업)
- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼)
---
## 3. DB 데이터 매핑 (layout_data JSON)
버튼은 `layout_data.components[]` 배열 안에 `url``v2-button-primary`인 컴포넌트로 저장된다.
| 항목 | JSON 위치 | 값 |
|---|---|---|
| 높이 | `size.height` | `40` |
| 너비 | `size.width` | `140` 또는 `160` |
| 표시모드 | `overrides.displayMode` | `"icon-text"` |
| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 |
| 아이콘 타입 | `overrides.icon.type` | `"lucide"` |
| 아이콘 크기 | `overrides.icon.size` | `"보통"` |
| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` |
| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` |
| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` |
| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` |
| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` |
| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` |
| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` |
| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 |
버튼이 위치하는 구조별 경로:
- 일반 버튼: `layout_data.components[]`
- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]`
- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]`
---
## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록
### 대상
- **회사**: 탑씰 (company_code = 'COMPANY_7')
- **테이블**: screen_layouts_v2 (배포서버)
- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts`
- **백업 테이블**: `screen_layouts_v2_backup_company7`
### 작업 이력
| 날짜 | 작업 내용 | 비고 |
|---|---|---|
| 2026-03-13 | 백업 테이블 생성 | |
| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 |
| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 |
| 2026-03-13 | fontWeight "400" → "normal" 보정 | |
| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 |
| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | |
| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | |
| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | |
| 2026-03-13 | 전체 버튼 너비 140px 통일 | |
| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | |
| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 |
### 스킵 항목
- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976)
### 알려진 이슈
- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨.
### 원복 (필요 시)
```sql
UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data
FROM screen_layouts_v2_backup_company7 AS backup
WHERE target.layout_id = backup.layout_id;
```
### 백업 테이블 정리
```sql
DROP TABLE screen_layouts_v2_backup_company7;
```

View File

@ -0,0 +1,420 @@
# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
## 개요
기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다.
1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐
2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함
3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨
---
## 현재 동작
### 채번 규칙 구성 (옵션설정 > 코드설정)
```
규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5)
```
### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시)
1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128)
2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"`
3. 저장 클릭 → `buttonActions.ts``_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출
4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치)
5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨**
6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용
7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터)
### 문제 1: 순번 공유 (buildPrefixKey)
**위치**: `numberingRuleService.ts` L85-88
```typescript
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
continue; // ← 접두어별 순번 분리를 막는 원인
}
```
`continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다.
"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다.
### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백)
**발생 흐름**:
1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출
2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442)
3. 템플릿 기반 수동 값 추출 시도 (L1411-1436):
```
템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번)
사용자 입력: "ㅁㅁㅁ"
```
4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패**`extractedManualValues = []`
5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작:
```typescript
const manualValue =
extractedManualValues[0] || // undefined (추출 실패)
part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐
"";
```
6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨)
**DB 숨은 값 원인**:
- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨
- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음**
- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨
### 문제 3: 연속 구분자(--) 문제
**발생 흐름**:
1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열)
2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생
3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`)
4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용)
5. 입력 `-제발-015``CATEGORY-`로 시작하지 않음 → 추출 실패
6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨
7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003`
### 정상 동작 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 |
| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) |
| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 |
### 비정상 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 |
| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 |
| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 |
---
## 변경 후 동작
### prefix_key에 수동 파트 값 포함
```
현재: prefix_key = 카테고리값만 (수동 파트 무시)
변경: prefix_key = 카테고리값 + "|" + 수동입력값
```
### allocateCode 실행 순서 변경
```
현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합
변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### 순번 동작
```
"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001
"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002
"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001
"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003
```
### BULK1 폴백 제거 (코드 + DB 이중 조치)
```
코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용
DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리
```
### 연속 구분자 방지 + 템플릿 정합성 복원
```
joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신)
→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지
```
---
## 시각적 예시
| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 |
|------------|----------|------|-------------|
| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` |
| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` |
| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` |
| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) |
| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` |
---
## 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant BA as buttonActions.ts
participant API as allocateNumberingCode API
participant NRS as numberingRuleService
participant DB as numbering_rule_sequences
User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ")
BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData)
API->>NRS: allocateCode()
Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행)
NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ")
Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용
NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"]
Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함)
NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"])
Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ"
Note over NRS: 3단계: 시퀀스 할당
NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ")
DB-->>NRS: current_sequence = 1
Note over NRS: 4단계: 코드 조합
NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001"
NRS-->>API: "카테고리값-ㅁㅁㅁ-001"
API-->>BA: generatedCode
BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001"
```
---
## 변경 대상 파일
| 파일 | 변경 내용 | 규모 |
|------|----------|------|
| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey``manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode``manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 |
| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 |
| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode``manualInputValue` 파라미터 추가 | ~3줄 |
| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 |
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |
### buildPrefixKey 호출부 영향 분석
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|--------|------|---------------------|------|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
### 멀티테넌시 체크
| 항목 | 상태 | 근거 |
|------|------|------|
| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 |
| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 |
| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 |
| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 |
---
## 코드 설계
### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지
**위치**: L36-48
**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
```typescript
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
}
```
### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함
**위치**: L75-88
**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함.
```typescript
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
// ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ...
}
return prefixParts.join("|");
}
```
**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음.
### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리
**위치**: L1290-1584
**핵심 변경 2가지**:
(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경.
(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거.
```typescript
async allocateCode(ruleId, companyCode, formData?, userInputCode?) {
// ... 규칙 조회 ...
// 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행)
const manualParts = rule.parts.filter(p => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
}
}
// 2단계: 수동 값을 포함하여 prefix_key 빌드
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
// 3단계: 시퀀스 할당 (기존 로직 그대로)
// 4단계: 코드 조합 (manualConfig.value 폴백 제거)
// 기존: extractedManualValues[i] || part.manualConfig?.value || ""
// 변경: extractedManualValues[i] || ""
}
```
### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원
기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출.
로직 자체는 변경 없음, 위치만 이동.
카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴.
```typescript
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
// 기존 L1332-1442의 로직을 그대로 이동
// 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환
// → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상
}
```
### 5. DB 마이그레이션 - BULK1 유령 기본값 제거
**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql`
`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다.
```sql
-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리)
UPDATE numbering_rule_parts
SET manual_config = manual_config - 'value'
WHERE generation_method = 'manual'
AND manual_config ? 'value'
AND manual_config->>'value' = 'BULK1';
```
> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거.
> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨.
> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보.
---
## 설계 원칙
- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건
- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리
- `buildPrefixKey``manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음
- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님
- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음
- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지
- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름)
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지
- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상
---
## 실시간 순번 미리보기 (추가 기능)
### 배경
품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함.
### 목표 동작
```
모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번)
"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건)
저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건)
```
### 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant V2 as V2Input
participant API as previewNumberingCode
participant BE as numberingRuleService.previewCode
participant DB as numbering_rule_sequences
User->>V2: 수동 입력 "ㅇㅇ"
Note over V2: 디바운스 300ms
V2->>API: preview(ruleId, formData, "ㅇㅇ")
API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ")
BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"])
Note over BE: prefix_key = "카테고리|ㅇㅇ"
BE->>DB: getSequenceForPrefix(prefix_key)
DB-->>BE: currentSeq = 0
Note over BE: nextSequence = 0 + startFrom(5) = 5
BE-->>API: "-____-005"
API-->>V2: generatedCode
V2->>V2: suffix = "-005" 갱신
Note over V2: 화면 표시: -[ㅇㅇ]-005
```
### 변경 내용
1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신
2. **백엔드 서비스**: `previewCode``manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회
3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시
4. **프론트엔드 API**: `previewNumberingCode``manualInputValue` 파라미터 추가
5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트
6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영
7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소)

View File

@ -0,0 +1,161 @@
# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
---
## 왜 이 작업을 하는가
- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨
- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함
- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함
- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함
---
## 핵심 결정 사항과 근거
### 1. 수동 값 추출을 buildPrefixKey 전으로 이동
- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경
- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함
- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향)
### 2. buildPrefixKey에 수동 파트 값 포함
- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함
- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함
- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음
### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용
- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용
- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임
- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음)
### 4. 코드 조합에서 manualConfig.value 폴백 제거
- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""``extractedManualValues[i] || ""`
- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx``value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨
- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리
### 5. DB 마이그레이션은 BULK1만 타겟팅
- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정
- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지
- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요)
### 6. extractManualValuesFromInput 헬퍼 분리
- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출
- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움
- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리)
### 7. 프론트엔드 변경 불필요
- **결정**: 프론트엔드 코드 수정 없음
- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음
### 8. joinPartsWithSeparators 연속 구분자 방지
- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음
- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지
- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵
### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경
- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경
- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]`
- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) |
| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 |
| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 |
| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 |
| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 |
| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 |
---
## 기술 참고
### allocateCode 실행 순서 (변경 전 → 후)
```
변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합
변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### prefix_key 구성 (변경 전 → 후)
```
변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키)
변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키)
```
### 폴백 체인 (변경 전 → 후)
```
변경 전: extractedManualValues[i] || manualConfig.value || ""
변경 후: extractedManualValues[i] || ""
```
### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후)
```
변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ"
변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ"
```
### 템플릿 정합성 (변경 전 → 후)
```
변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패
변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공
```
### 10. 실시간 순번 미리보기 구현 방식
- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신
- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함
- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합)
- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일
### 11. previewCode에 manualInputValue 전달
- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey``[manualInputValue]`로 전달
- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함
- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음
### 12. 초기 상태에서 레거시 시퀀스 조회 방지
- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용
- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함
- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5``-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회
### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회
- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달
- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨
- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동
### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합
- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출
- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험
- **원칙**: 구조적 변경만 수행 (로직 변경 없음)
### BULK1이 DB에 남아있는 이유
```
ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음)
플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue }
→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨
→ UI에서 제거 불가능한 유령 값
```

View File

@ -0,0 +1,100 @@
# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md)
---
## 공정 상태
- 전체 진행률: **100%** (전체 완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 구조적 변경 (행위 변경 없음)
- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리
- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체
- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음)
### 2단계: buildPrefixKey 수정
- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가
- [x] 수동 파트 처리 로직 변경: `continue``manualValues`에서 값 꺼내 `prefixParts`에 추가
- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터)
### 3단계: allocateCode 순서 변경 + 폴백 정리
- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동
- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가
- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달
- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거
### 4단계: DB 마이그레이션
- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성
- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거
- [x] 마이그레이션 실행 (9건 정리 완료)
### 5단계: 연속 구분자(--) 방지
- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가
- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성)
### 6단계: 검증
- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인
- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님)
- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인
- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인
- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인
- [x] previewCode (미리보기) 동작 영향 없음 확인
- [x] BULK1이 더 이상 생성되지 않음 확인
### 7단계: 실시간 순번 미리보기
- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가
- [x] 백엔드 서비스: `previewCode``manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달
- [x] 프론트엔드 API: `previewNumberingCode``manualInputValue` 파라미터 추가
- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신
- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시
- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영
- [x] 린트 에러 없음 확인
### 8단계: 코드 정리
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
- [x] 린트 에러 없음 확인
### 9단계: 정리
- [x] 계획서/맥락노트/체크리스트 최신화
---
## 알려진 이슈 (보류)
| 이슈 | 설명 | 상태 |
|------|------|------|
| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 |
| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 |
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-11 | 1-4단계 구현 완료 |
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |
| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) |
| 2026-03-12 | 계맥체 최신화 완료 |
| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 |
| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 |
| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 |
| 2026-03-12 | 6단계 검증 완료. 전체 완료 |

View File

@ -1,11 +1,10 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
@ -15,11 +14,19 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template" | "v2-test";
type ViewMode = "tree" | "table";
type ViewMode = "flow" | "card";
export default function ScreenManagementPage() {
const searchParams = useSearchParams();
@ -28,11 +35,15 @@ export default function ScreenManagementPage() {
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [viewMode, setViewMode] = useState<ViewMode>("flow");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
@ -102,6 +113,7 @@ export default function ScreenManagementPage() {
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setIsDetailOpen(true);
setSelectedGroup(null); // 그룹 선택 해제
};
@ -159,96 +171,126 @@ export default function ScreenManagementPage() {
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
<div className="flex-shrink-0 border-b border-border/50 bg-background/95 backdrop-blur-md px-6 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold tracking-tight"> </h1>
<Badge variant="secondary" className="text-xs">{screens.length} </Badge>
</div>
<div className="flex items-center gap-2">
{/* V2 컴포넌트 테스트 버튼 */}
<Button
variant="outline"
onClick={() => goToNextStep("v2-test")}
className="gap-2"
>
<TestTube2 className="h-4 w-4" />
V2
</Button>
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<TabsList className="h-9 bg-muted/50 border border-border/50">
<TabsTrigger value="flow" className="gap-1.5 px-3 text-xs">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<TabsTrigger value="card" className="gap-1.5 px-3 text-xs">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Button onClick={() => setIsCreateOpen(true)} className="gap-2 shadow-sm hover:shadow-md transition-shadow">
<Plus className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => goToNextStep("v2-test")}>
<TestTube2 className="h-4 w-4 mr-2" />
V2
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{viewMode === "tree" ? (
{viewMode === "flow" ? (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
{/* 왼쪽: 트리 구조 (접기/펼기 지원) */}
<div className={`flex flex-col border-r border-border/50 bg-background/80 backdrop-blur-sm transition-all duration-300 ease-in-out ${
sidebarCollapsed ? "w-[48px] min-w-[48px]" : "w-[320px] min-w-[280px] max-w-[400px]"
}`}>
{/* 사이드바 헤더 */}
<div className="flex-shrink-0 flex items-center justify-between p-2 border-b border-border/50">
{!sidebarCollapsed && <span className="text-xs font-medium text-muted-foreground px-1"></span>}
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 ${sidebarCollapsed ? "mx-auto" : "ml-auto"}`}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{/* 사이드바 접힘 시 아이콘 컬럼 */}
{sidebarCollapsed && (
<div className="flex-1 flex flex-col items-center gap-2 py-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarCollapsed(false)}>
<Search className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="mt-auto pb-2">
<Badge variant="secondary" className="text-[10px] px-1.5">{screens.length}</Badge>
</div>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null); // 화면 선택 해제
setFocusedScreenIdInGroup(null); // 포커스 초기화
}}
onScreenSelectInGroup={(group, screenId) => {
// 그룹 내 화면 클릭 시
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
)}
{/* 사이드바 펼침 시 전체 UI */}
{!sidebarCollapsed && (
<>
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b border-border/50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors"
/>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null);
setFocusedScreenIdInGroup(null);
}}
onScreenSelectInGroup={(group, screenId) => {
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
</>
)}
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<div className="flex-1 overflow-hidden bg-muted/10">
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
@ -257,21 +299,150 @@ export default function ScreenManagementPage() {
</div>
</div>
) : (
// 테이블 뷰 (기존 ScreenList 사용)
<div className="flex-1 overflow-auto p-6">
<ScreenList
onScreenSelect={handleScreenSelect}
selectedScreen={selectedScreen}
onDesignScreen={handleDesignScreen}
/>
<div className="flex-1 overflow-auto p-6 bg-muted/30 dark:bg-background">
{/* 카드 뷰 상단: 검색 + 카운트 */}
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 rounded-xl bg-card dark:bg-card border-border/50 shadow-sm focus:bg-card focus:ring-2 focus:ring-primary/30 transition-colors"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="검색어 지우기"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<span className="text-xs text-muted-foreground">{filteredScreens.length} </span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{filteredScreens.map((screen) => {
const screenType = (screen as { screenType?: string }).screenType || "form";
const isSelected = selectedScreen?.screenId === screen.screenId;
const isRecentlyModified = screen.updatedDate && (Date.now() - new Date(screen.updatedDate).getTime()) < 7 * 24 * 60 * 60 * 1000;
const typeColorClass = screenType === "grid"
? "from-primary to-primary/20"
: screenType === "dashboard"
? "from-warning to-warning/20"
: "from-success to-success/20";
const glowClass = screenType === "grid"
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--primary)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--primary)/0.15)]"
: screenType === "dashboard"
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--warning)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--warning)/0.12)]"
: "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--success)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--success)/0.12)]";
const badgeBgClass = screenType === "grid"
? "bg-primary/8 dark:bg-primary/15 text-primary"
: screenType === "dashboard"
? "bg-warning/8 dark:bg-warning/15 text-warning"
: "bg-success/8 dark:bg-success/15 text-success";
return (
<div
key={screen.screenId}
className={`group relative overflow-hidden rounded-[12px] cursor-pointer transition-all duration-250 ease-[cubic-bezier(0.4,0,0.2,1)] ${
isSelected
? "border border-primary bg-primary/5 dark:bg-primary/8 shadow-[0_0_0_2px_hsl(var(--primary)/0.22),0_1px_3px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_2px_hsl(var(--primary)/0.3),0_1px_4px_rgba(0,0,0,0.3)]"
: `border border-transparent bg-card shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-[0_1px_4px_rgba(0,0,0,0.35),0_0_1px_rgba(0,0,0,0.2)] hover:-translate-y-[2px] ${glowClass}`
}`}
onClick={() => handleScreenSelect(screen)}
onDoubleClick={() => handleDesignScreen(screen)}
>
{/* 좌측 그라데이션 액센트 바 */}
<div className={`absolute left-0 top-3 bottom-3 w-[3px] rounded-r-full bg-gradient-to-b ${typeColorClass} transition-all duration-250 group-hover:top-1 group-hover:bottom-1 group-hover:w-[4px]`} />
{isSelected && (
<div className={`absolute left-0 top-0 bottom-0 w-[4px] bg-gradient-to-b ${typeColorClass}`} />
)}
<div className="pl-[14px] pr-4 py-4">
{/* Row 1: 이름 + 타입 뱃지 */}
<div className="flex items-center gap-2 mb-1">
<div className="text-[15px] font-bold leading-snug truncate flex-1 min-w-0 tracking-[-0.3px]">{screen.screenName}</div>
<span className={`text-[11px] font-semibold px-2.5 py-[3px] rounded-md flex-shrink-0 ${badgeBgClass}`}>
{screenType === "grid" ? "그리드" : screenType === "dashboard" ? "대시보드" : "폼"}
</span>
</div>
{/* Row 2: 스크린 코드 */}
<div className="text-[12px] font-mono text-muted-foreground tracking-[-0.3px] truncate mb-3">{screen.screenCode}</div>
{/* Row 3: 테이블 칩 + 메타 */}
<div className="flex items-center gap-1.5 flex-wrap">
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-foreground/80 dark:text-foreground/70 px-2.5 py-1 rounded-md bg-muted/60 dark:bg-muted/40">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-[11px]">{screen.tableLabel || screen.tableName || "—"}</span>
</span>
</div>
{/* Row 4: 날짜 + 수정 상태 */}
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-border/20 dark:border-border/10">
<span className="text-[12px] font-mono text-muted-foreground">
{screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : ""}
</span>
{isRecentlyModified && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="relative inline-block w-[6px] h-[6px] rounded-full bg-success screen-card-pulse-dot" />
</span>
)}
</div>
</div>
</div>
);
})}
</div>
{filteredScreens.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Search className="h-8 w-8 mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
)}
</div>
)}
{/* 화면 디테일 Sheet */}
<Sheet open={isDetailOpen} onOpenChange={setIsDetailOpen}>
<SheetContent className="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<SheetTitle className="text-base">{selectedScreen?.screenName || "화면 상세"}</SheetTitle>
<SheetDescription className="text-xs font-mono">{selectedScreen?.screenCode}</SheetDescription>
</SheetHeader>
{selectedScreen && (
<div className="mt-6 space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono">{selectedScreen.tableName || "없음"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> ID</span>
<span className="text-xs font-mono">{selectedScreen.screenId}</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-border/50">
<Button className="flex-1" onClick={() => { handleDesignScreen(selectedScreen); setIsDetailOpen(false); }}>
</Button>
<Button variant="outline" onClick={() => setIsDetailOpen(false)}>
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 화면 생성 모달 */}
<CreateScreenModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={() => {
setIsCreateOpen(false);
loadScreens();
}}

View File

@ -418,6 +418,21 @@ select {
border-spacing: 0 !important;
}
/* ===== 카드 펄스 도트 애니메이션 ===== */
@keyframes screen-card-pulse {
0%, 100% { opacity: 0; transform: scale(1); }
50% { opacity: 0.35; transform: scale(2); }
}
.screen-card-pulse-dot::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: hsl(var(--success));
opacity: 0;
animation: screen-card-pulse 2.5s ease-in-out infinite;
}
/* ===== 저장 테이블 막대기 애니메이션 ===== */
@keyframes saveBarDrop {
0% {

View File

@ -0,0 +1,70 @@
"use client";
import React from "react";
import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
// 커스텀 애니메이션 엣지 — bezier 곡선 + 흐르는 파티클 + 글로우 레이어
export function AnimatedFlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const strokeColor = (style?.stroke as string) || "hsl(var(--primary))";
const strokeW = (style?.strokeWidth as number) || 2;
const isActive = data?.active !== false;
const duration = data?.duration || "3s";
const filterId = `edge-glow-${id}`;
return (
<>
{/* 글로우용 SVG 필터 정의 (엣지별 고유 ID) */}
<defs>
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* 글로우 레이어 */}
<path
d={edgePath}
fill="none"
stroke={strokeColor}
strokeWidth={strokeW + 4}
strokeOpacity={0.12}
filter={`url(#${filterId})`}
/>
{/* 메인 엣지 */}
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
{/* 흐르는 파티클 */}
{isActive && (
<>
<circle r="3" fill={strokeColor} filter={`url(#${filterId})`}>
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
</circle>
<circle r="1.5" fill="white" opacity="0.85">
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
</circle>
</>
)}
</>
);
}

View File

@ -37,7 +37,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { getCompanyList, Company } from "@/lib/api/company";
import { getCompanyList } from "@/lib/api/company";
import type { Company } from "@/types/company";
import {
DropdownMenu,
DropdownMenuContent,
@ -1106,7 +1107,7 @@ export function ScreenGroupTreeView({
{/* 그룹 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-sm font-medium group/item",
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
)}
@ -1119,12 +1120,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><FolderOpen className="h-3.5 w-3.5 text-warning" /></span>
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><Folder className="h-3.5 w-3.5 text-warning" /></span>
)}
<span className={cn("truncate flex-1", isMatching && "font-medium text-primary/80")}>{group.group_name}</span>
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="text-xs font-mono">
{groupScreens.length}
</Badge>
{/* 그룹 메뉴 버튼 */}
@ -1157,7 +1158,8 @@ export function ScreenGroupTreeView({
{/* 그룹 내 하위 그룹들 */}
{isExpanded && childGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
<div className="relative ml-6 mt-1 space-y-0.5">
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/40" />
{childGroups.map((childGroup) => {
const childGroupId = String(childGroup.id);
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
@ -1172,7 +1174,7 @@ export function ScreenGroupTreeView({
{/* 중분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-xs font-medium group/item",
isChildMatching && "bg-primary/5 dark:bg-primary/10"
)}
@ -1185,12 +1187,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isChildExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-primary" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><FolderOpen className="h-3.5 w-3.5 text-primary" /></span>
) : (
<Folder className="h-3 w-3 shrink-0 text-primary" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><Folder className="h-3.5 w-3.5 text-primary" /></span>
)}
<span className={cn("truncate flex-1", isChildMatching && "font-medium text-primary/80")}>{childGroup.group_name}</span>
<Badge variant="secondary" className="text-[10px] h-4">
<Badge variant="secondary" className="text-[10px] h-4 font-mono">
{childScreens.length}
</Badge>
<DropdownMenu>
@ -1222,7 +1224,8 @@ export function ScreenGroupTreeView({
{/* 중분류 내 손자 그룹들 (소분류) */}
{isChildExpanded && grandChildGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
<div className="relative ml-6 mt-1 space-y-0.5">
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/30" />
{grandChildGroups.map((grandChild) => {
const grandChildId = String(grandChild.id);
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
@ -1234,7 +1237,7 @@ export function ScreenGroupTreeView({
{/* 소분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-xs group/item",
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
)}
@ -1247,12 +1250,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isGrandExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><FolderOpen className="h-3.5 w-3.5 text-success" /></span>
) : (
<Folder className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><Folder className="h-3.5 w-3.5 text-success" /></span>
)}
<span className={cn("truncate flex-1", isGrandMatching && "font-medium text-primary/80")}>{grandChild.group_name}</span>
<Badge variant="outline" className="text-[10px] h-4">
<Badge variant="outline" className="text-[10px] h-4 font-mono">
{grandScreens.length}
</Badge>
<DropdownMenu>
@ -1294,9 +1297,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-xs hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, grandChild)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -1330,9 +1333,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-xs hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, childGroup)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -1366,9 +1369,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent group/screen",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-sm hover:bg-muted/60 group/screen",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, group)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -1393,7 +1396,7 @@ export function ScreenGroupTreeView({
<div className="mb-1">
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-sm font-medium text-muted-foreground"
)}
onClick={() => toggleGroup("ungrouped")}
@ -1405,7 +1408,7 @@ export function ScreenGroupTreeView({
)}
<Folder className="h-4 w-4 shrink-0" />
<span className="truncate flex-1"></span>
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-xs font-mono">
{ungroupedScreens.length}
</Badge>
</div>
@ -1416,9 +1419,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-sm hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -2096,15 +2099,15 @@ export function ScreenGroupTreeView({
onClick={() => handleSync("menu-to-screen")}
disabled={isSyncing}
variant="outline"
className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300"
className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30"
>
{isSyncing && syncDirection === "menu-to-screen" ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
<Loader2 className="h-4 w-4 animate-spin text-success" />
) : (
<FolderInput className="h-4 w-4 text-emerald-600" />
<FolderInput className="h-4 w-4 text-success" />
)}
<span className="flex-1 text-left text-emerald-700"> </span>
<span className="text-xs text-emerald-500/70">
<span className="flex-1 text-left text-success"> </span>
<span className="text-xs text-success/70">
</span>
</Button>

View File

@ -11,10 +11,25 @@ import {
MousePointer2,
Key,
Link2,
Columns3,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
// 글로우 펄스 애니메이션 CSS 주입
if (typeof document !== "undefined") {
const styleId = "glow-pulse-animation";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
@keyframes glow-pulse {
from { filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.25)) drop-shadow(0 0 10px hsl(var(--primary) / 0.12)); }
to { filter: drop-shadow(0 0 6px hsl(var(--primary) / 0.35)) drop-shadow(0 0 16px hsl(var(--primary) / 0.18)); }
}
`;
document.head.appendChild(style);
}
}
// ========== 타입 정의 ==========
// 화면 노드 데이터 인터페이스
@ -107,42 +122,14 @@ const getScreenTypeIcon = (screenType?: string) => {
}
};
// 화면 타입별 색상 (헤더)
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
if (!isMain) return "bg-slate-400";
switch (screenType) {
case "grid":
return "bg-violet-500";
case "dashboard":
return "bg-amber-500";
case "action":
return "bg-rose-500";
default:
return "bg-primary";
}
// 화면 타입별 색상 (헤더) - 더 이상 그라데이션 미사용
const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => {
return "";
};
// 화면 역할(screenRole)에 따른 색상
const getScreenRoleColor = (screenRole?: string) => {
if (!screenRole) return "bg-slate-400";
// 역할명에 포함된 키워드로 색상 결정
const role = screenRole.toLowerCase();
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
return "bg-violet-500"; // 보라색 - 메인 그리드
}
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
return "bg-primary"; // 파란색 - 등록 폼
}
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
return "bg-rose-500"; // 빨간색 - 액션/이벤트
}
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
return "bg-amber-500"; // 주황색 - 상세/팝업
}
return "bg-slate-400"; // 기본 회색
// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용
const getScreenRoleColor = (_screenRole?: string) => {
return "";
};
// 화면 타입별 라벨
@ -161,36 +148,26 @@ const getScreenTypeLabel = (screenType?: string) => {
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
const { label, isMain, tableName, layoutSummary, isFocused, isFaded } = data;
const screenType = layoutSummary?.screenType || "form";
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
// isFocused일 때 색상 활성화, isFaded일 때 회색
let headerColor: string;
if (isInGroup) {
if (isFaded) {
headerColor = "bg-muted/60"; // 흑백 처리 - 더 확실한 회색
} else {
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
headerColor = getScreenRoleColor(screenRole);
}
} else {
headerColor = getScreenTypeColor(screenType, isMain);
}
return (
<div
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
className={`group relative flex h-[240px] w-[240px] flex-col overflow-hidden rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm transition-all cursor-pointer ${
isFocused
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
? "border-primary/40 shadow-[0_0_0_1px_hsl(var(--primary)/0.4)] scale-[1.03]"
: isFaded
? "border-border opacity-50"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
? "opacity-40 border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)]"
: "border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:border-border/50 dark:hover:border-border/20 hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:-translate-y-0.5"
}`}
style={{
filter: isFaded ? "grayscale(100%)" : "none",
filter: isFaded
? "grayscale(100%)"
: isFocused
? "drop-shadow(0 0 8px hsl(var(--primary) / 0.5)) drop-shadow(0 0 20px hsl(var(--primary) / 0.25))"
: "none",
transition: "all 0.3s ease",
transform: isFocused ? "scale(1.02)" : "scale(1)",
animation: isFocused ? "glow-pulse 2s ease-in-out infinite alternate" : "none",
}}
>
{/* Handles */}
@ -198,78 +175,49 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
{/* 헤더: 그라디언트 제거, 모노크롬 */}
<div className="flex items-center gap-2 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 px-3 py-2 transition-colors duration-300">
<div className="flex h-6 w-6 items-center justify-center rounded bg-primary/10 text-primary">
<Monitor className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-xs font-bold text-foreground">{label}</div>
{tableName && <div className="truncate text-[9px] text-muted-foreground font-mono">{tableName}</div>}
</div>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-foreground/[0.12] dark:bg-foreground/8 animate-pulse" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
<div className="h-[140px] overflow-hidden bg-muted/50 p-2">
<div className="h-[110px] overflow-hidden p-2.5">
{layoutSummary ? (
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<div className="flex h-full flex-col items-center justify-center text-muted-foreground/70 dark:text-muted-foreground/40">
{getScreenTypeIcon(screenType)}
<span className="mt-1 text-[10px]">: {label}</span>
</div>
)}
</div>
{/* 필드 매핑 영역 */}
<div className="flex-1 overflow-hidden border-t border-border bg-card px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-muted-foreground">
<Columns3 className="h-3 w-3" />
<span> </span>
<span className="ml-auto text-[8px] text-muted-foreground/70">
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
{layoutSummary?.layoutItems
?.filter(item => item.label && !item.componentKind?.includes('button'))
?.slice(0, 6)
?.map((item, idx) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Database className="h-3 w-3" />
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
{getScreenTypeLabel(screenType)}
</span>
{/* 푸터 (타입 칩 + 컴포넌트 수) */}
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 bg-background dark:bg-background/50 px-3 py-1.5">
<span className="text-[9px] font-medium px-[7px] py-[2px] rounded bg-primary/10 text-primary">{getScreenTypeLabel(screenType)}</span>
<span className="text-[9px] text-muted-foreground">{layoutSummary?.totalComponents ?? 0} </span>
</div>
</div>
);
@ -280,33 +228,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
return "bg-violet-200 border-violet-400";
return "bg-primary/20 border-primary/40";
}
// 검색 필터
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
return "bg-pink-200 border-pink-400";
return "bg-destructive/20 border-destructive/40";
}
// 버튼 관련
if (componentKind?.includes("button")) {
return "bg-blue-300 border-primary";
return "bg-primary/30 border-primary";
}
// 입력 필드
if (componentKind?.includes("input") || componentKind?.includes("text")) {
return "bg-slate-200 border-slate-400";
return "bg-muted border-border";
}
// 셀렉트/드롭다운
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
return "bg-amber-200 border-amber-400";
return "bg-warning/20 border-warning/40";
}
// 차트
if (componentKind?.includes("chart")) {
return "bg-emerald-200 border-emerald-400";
return "bg-success/20 border-success/40";
}
// 커스텀 위젯
if (componentKind === "custom") {
return "bg-pink-200 border-pink-400";
return "bg-destructive/20 border-destructive/40";
}
return "bg-slate-100 border-slate-300";
return "bg-muted/50 border-border";
};
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
@ -316,130 +264,114 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
}) => {
const { totalComponents, widgetCounts } = layoutSummary;
// 그리드 화면 일러스트
// 그리드 화면 일러스트 (모노크롬)
if (screenType === "grid") {
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="h-4 w-16 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-4 w-8 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
<div className="flex gap-1 rounded-t-md bg-foreground/[0.18] dark:bg-foreground/12 px-2 py-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
<div key={i} className="h-2.5 flex-1 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
))}
</div>
{/* 테이블 행들 */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{[...Array(7)].map((_, i) => (
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted" : "bg-card"}`}>
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted/30 dark:bg-muted/10" : "bg-card"}`}>
{[...Array(5)].map((_, j) => (
<div key={j} className="h-2 flex-1 rounded bg-muted-foreground/30" />
<div key={j} className="h-2 flex-1 rounded bg-foreground/[0.1] dark:bg-foreground/6" />
))}
</div>
))}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-2 pt-1">
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
<div className="h-2.5 w-4 rounded bg-primary" />
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
</div>
);
}
// 폼 화면 일러스트
// 폼 화면 일러스트 (모노크롬)
if (screenType === "form") {
return (
<div className="flex h-full flex-col gap-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex h-full flex-col gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 폼 필드들 */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-2.5 w-14 rounded bg-muted-foreground/50" />
<div className="h-5 flex-1 rounded-md border border-border bg-card shadow-sm" />
<div className="h-2.5 w-14 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-5 flex-1 rounded-md border border-border/30 dark:border-border/5 bg-card" />
</div>
))}
{/* 버튼 영역 */}
<div className="mt-auto flex justify-end gap-2 border-t border-border pt-3">
<div className="h-5 w-14 rounded-md bg-muted-foreground/40 shadow-sm" />
<div className="h-5 w-14 rounded-md bg-primary shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
<div className="mt-auto flex justify-end gap-2 border-t border-border/30 dark:border-border/5 pt-3">
<div className="h-5 w-14 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-5 w-14 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
</div>
</div>
);
}
// 대시보드 화면 일러스트
// 대시보드 화면 일러스트 (모노크롬)
if (screenType === "dashboard") {
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
<div className="col-span-2 rounded-lg bg-primary/10 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-primary/70" />
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="col-span-2 rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-12 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="flex h-14 items-end gap-1">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-primary/70/80"
className="flex-1 rounded-t bg-foreground/[0.15] dark:bg-foreground/10"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 액션 화면 일러스트 (버튼 중심)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="rounded-full bg-muted p-4 text-muted-foreground">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-primary shadow-sm" />
<div className="h-7 w-16 rounded-md bg-muted-foreground/40 shadow-sm" />
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 기본 (알 수 없는 타입)
// 액션 화면 일러스트 (모노크롬)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4 text-muted-foreground">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-7 w-16 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
</div>
);
}
// 기본 (알 수 없는 타입, 모노크롬)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-muted/30 text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 text-muted-foreground">
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
@ -574,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
className={`group relative flex w-[260px] flex-col overflow-visible rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블)
isFilterTable
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3)]"
// 2. 필터 관련 테이블 포커스 시
: (hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 3. 순수 포커스 (필터 관계 없음): 초록색
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)]"
// 3. 순수 포커스
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)] bg-card"
// 4. 흐리게 처리
: isFaded
? "border-border opacity-60 bg-card"
? "opacity-60 bg-card border-border/40 dark:border-border/10"
// 5. 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
: "border-border/40 dark:border-border/10 hover:border-border/50 dark:hover:border-border/20"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
@ -602,7 +534,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
style={{
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`,
opacity: hasSaveTarget ? 1 : 0,
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'top',
@ -616,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
@ -624,25 +556,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
position={Position.Top}
id="top_source"
style={{ top: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
@ -650,18 +582,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
position={Position.Bottom}
id="bottom_target"
style={{ bottom: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
{/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 rounded-t-[10px] transition-colors duration-700 ease-in-out">
<div className="flex h-7 w-7 items-center justify-center rounded-[7px] bg-cyan-500/10 shrink-0">
<Database className="h-3.5 w-3.5 text-cyan-400" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-[11px] font-semibold">{label}</div>
<div className="truncate text-[11px] font-semibold text-foreground font-mono">{label}</div>
{/* 필터 관계에 따른 문구 변경 */}
<div className="truncate text-[9px] opacity-80">
<div className="truncate text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 tracking-[-0.3px]">
{isFilterSource
? "마스터 테이블 (필터 소스)"
: hasFilterRelation
@ -670,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</div>
</div>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
{displayColumns.length}
<span className="text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 px-1.5 py-0.5 rounded bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/10 tracking-[-0.3px] shrink-0">
{displayColumns.length} ref
</span>
)}
</div>
@ -679,7 +611,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
style={{
height: `${debouncedHeight}px`,
maxHeight: `${MAX_HEIGHT}px`,
@ -699,7 +631,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 필터 뱃지 */}
{filterRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-primary px-2 py-px text-primary-foreground font-semibold shadow-sm"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-3 w-3" />
@ -707,14 +639,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-700 font-medium truncate">
<span className="text-primary font-medium truncate">
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{/* 참조 뱃지 */}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-warning px-2 py-px text-warning-foreground font-semibold shadow-sm"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable}${r.toColumn}`).join('\n')}`}
>
{lookupRefs.length}
@ -745,33 +677,37 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
key={col.name}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
isJoinColumn
? "bg-amber-100 border border-orange-300 shadow-sm"
? "bg-warning/10 border border-warning/20 shadow-sm"
: isFilterColumn || isFilterSourceColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스
: isHighlighted
? "bg-primary/10 border border-primary/40 shadow-sm"
: hasActiveColumns
? "bg-slate-100"
: "bg-slate-50 hover:bg-slate-100"
? "bg-muted"
: "bg-muted/50 hover:bg-muted/80 transition-colors"
}`}
style={{
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
opacity: hasActiveColumns ? 0 : 1,
}}
>
{/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-amber-500" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 3px 세로 마커 (PK/FK/조인/필터) */}
<div
className={`w-[3px] h-[14px] rounded-sm flex-shrink-0 ${
isJoinColumn ? "bg-amber-400"
: (isFilterColumn || isFilterSourceColumn) ? "bg-primary opacity-80"
: col.isPrimaryKey ? "bg-amber-400"
: col.isForeignKey ? "bg-primary opacity-80"
: "bg-muted-foreground/20"
}`}
/>
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
: isHighlighted ? "text-primary"
: "text-slate-700"
isJoinColumn ? "text-amber-400"
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
: isHighlighted ? "text-primary"
: "text-foreground"
}`}>
{col.name}
</span>
@ -781,63 +717,74 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<>
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
)}
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning"></span>
</>
)}
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 타입 */}
<span className="text-[8px] text-slate-400">{col.type}</span>
<span className="text-[8px] text-muted-foreground/60 dark:text-muted-foreground/30 font-mono tracking-[-0.3px]">{col.type}</span>
</div>
);
})}
{/* 더 많은 컬럼이 있을 경우 표시 */}
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">
<div className="text-center text-[8px] text-muted-foreground py-0.5">
+ {remainingCount}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
<Database className="h-4 w-4 text-muted-foreground" />
<span className="mt-0.5 text-[8px] text-muted-foreground"> </span>
</div>
)}
</div>
{/* 푸터 (컴팩트) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[9px] text-muted-foreground">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}
</span>
)}
{/* 푸터: cols + PK/FK 카운트 */}
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 px-3.5 py-1.5 bg-background dark:bg-background/50">
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono tracking-[-0.3px]">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols
</span>
<div className="flex gap-2.5 text-[9px] font-mono tracking-[-0.3px]">
{columns?.some(c => c.isPrimaryKey) && (
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-400" />
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">PK {columns.filter(c => c.isPrimaryKey).length}</span>
</span>
)}
{columns?.some(c => c.isForeignKey) && (
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-primary" />
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">FK {columns.filter(c => c.isForeignKey).length}</span>
</span>
)}
</div>
</div>
{/* CSS 애니메이션 정의 */}
@ -861,10 +808,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
export const LegacyScreenNode = ScreenNode;
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
return (
<div className="rounded-lg border-2 border-purple-300 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<div className="rounded-lg border-2 border-primary/40 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
<div className="flex items-center gap-2 text-primary">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>

View File

@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react";
import {
ReactFlow,
Controls,
MiniMap,
Background,
BackgroundVariant,
Node,
@ -34,22 +35,31 @@ import {
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ScreenSettingModal } from "./ScreenSettingModal";
import { TableSettingModal } from "./TableSettingModal";
import { AnimatedFlowEdge } from "./AnimatedFlowEdge";
import { Monitor, Database, FolderOpen } from "lucide-react";
// 관계 유형별 색상 정의
// 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응)
const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색
hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' },
hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' },
lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' },
mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' },
join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' },
};
// 엣지 필터 카테고리 (UI 토글용)
type EdgeCategory = 'main' | 'filter' | 'join' | 'lookup' | 'flow';
// 노드 타입 등록
const nodeTypes = {
screenNode: ScreenNode,
tableNode: TableNode,
};
const edgeTypes = {
animatedFlow: AnimatedFlowEdge,
};
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
@ -89,6 +99,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
// 엣지 필터 상태 (유형별 표시/숨김)
const [edgeFilterState, setEdgeFilterState] = useState<Record<EdgeCategory, boolean>>({
main: true,
filter: true,
join: true,
lookup: false,
flow: true,
});
// 노드 설정 모달 상태
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [settingModalNode, setSettingModalNode] = useState<{
@ -414,7 +433,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
isFaded = focusedScreenId !== null && !isFocused;
} else {
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
isFocused = isMain;
isFocused = !!isMain;
isFaded = !isMain && screenList.length > 1;
}
@ -426,7 +445,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
label: scr.screenName,
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
type: "screen",
isMain: selectedGroup ? idx === 0 : isMain,
isMain: selectedGroup ? idx === 0 : !!isMain,
tableName: scr.tableName,
layoutSummary: summary,
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
@ -687,14 +706,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `screen-${nextScreen.screenId}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
type: "animatedFlow",
label: `${i + 1}`,
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" },
animated: true,
style: { stroke: "#0ea5e9", strokeWidth: 2 },
style: { stroke: "hsl(var(--info))", strokeWidth: 2 },
data: { edgeCategory: 'flow' as EdgeCategory },
});
}
}
@ -709,12 +729,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${scr.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
animated: true, // 모든 메인 테이블 연결은 애니메이션
style: {
stroke: "#3b82f6",
stroke: "hsl(var(--primary))",
strokeWidth: 2,
},
data: { edgeCategory: 'main' as EdgeCategory },
});
}
});
@ -748,15 +769,16 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: targetNodeId,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
animated: true,
style: {
stroke: "#3b82f6",
stroke: "hsl(var(--primary))",
strokeWidth: 2,
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
},
data: {
sourceScreenId,
edgeCategory: 'filter' as EdgeCategory,
},
});
@ -793,7 +815,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: refTargetNodeId,
sourceHandle: "bottom",
targetHandle: "bottom_target",
type: "smoothstep",
type: "animatedFlow",
animated: false,
style: {
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
@ -809,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
sourceScreenId,
isFilterJoin: true,
visualRelationType: 'join',
edgeCategory: 'join' as EdgeCategory,
},
});
});
@ -901,7 +924,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${referencedTable}`, // 참조당하는 테이블
sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
targetHandle: "bottom_target", // 하단으로 들어감
type: "smoothstep",
type: "animatedFlow",
animated: false,
style: {
stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
@ -919,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
referrerTable,
referencedTable,
visualRelationType, // 관계 유형 저장
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
},
});
}
@ -944,7 +968,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `subtable-${subTable.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
markerEnd: {
type: MarkerType.ArrowClosed,
color: relationColor.strokeLight
@ -959,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: {
sourceScreenId,
visualRelationType,
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
},
});
});
@ -973,7 +998,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${join.join_table}`,
sourceHandle: "bottom",
targetHandle: "bottom_target",
type: "smoothstep",
type: "animatedFlow",
markerEnd: {
type: MarkerType.ArrowClosed,
color: RELATION_COLORS.join.strokeLight
@ -985,31 +1010,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
strokeDasharray: "8,4",
opacity: 0.5,
},
data: { visualRelationType: 'join' },
data: { visualRelationType: 'join', edgeCategory: 'join' as EdgeCategory },
});
}
});
// 테이블 관계 엣지 (추가 관계)
// 테이블 관계 엣지 (추가 관계) - 참조용 화면(개별 모드: screen, 그룹 모드: screenList[0])
const refScreen = screen ?? screenList[0];
relations.forEach((rel: any, idx: number) => {
if (rel.table_name && rel.table_name !== screen.tableName) {
if (rel.table_name && rel.table_name !== refScreen.tableName) {
// 화면 → 연결 테이블
const edgeExists = newEdges.some(
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
(e) => e.source === `screen-${refScreen.screenId}` && e.target === `table-${rel.table_name}`
);
if (!edgeExists) {
newEdges.push({
id: `edge-rel-${idx}`,
source: `screen-${screen.screenId}`,
source: `screen-${refScreen.screenId}`,
target: `table-${rel.table_name}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
labelStyle: { fontSize: 9, fill: "#10b981" },
labelStyle: { fontSize: 9, fill: "hsl(var(--success))" },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
style: { stroke: "#10b981", strokeWidth: 1.5 },
style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 },
data: { edgeCategory: (rel.relation_type === 'lookup' ? 'lookup' : 'join') as EdgeCategory },
});
}
}
@ -1017,23 +1044,24 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 데이터 흐름 엣지 (화면 간)
flows
.filter((flow: any) => flow.source_screen_id === screen.screenId)
.filter((flow: any) => flow.source_screen_id === refScreen.screenId)
.forEach((flow: any, idx: number) => {
if (flow.target_screen_id) {
newEdges.push({
id: `edge-flow-${idx}`,
source: `screen-${screen.screenId}`,
source: `screen-${refScreen.screenId}`,
target: `screen-${flow.target_screen_id}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
type: "animatedFlow",
animated: true,
label: flow.flow_label || flow.flow_type || "이동",
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
style: { stroke: "#8b5cf6", strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" },
style: { stroke: "hsl(var(--primary))", strokeWidth: 2 },
data: { edgeCategory: 'flow' as EdgeCategory },
});
}
});
@ -1134,7 +1162,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 화면 노드 우클릭
if (node.id.startsWith("screen-")) {
const screenId = parseInt(node.id.replace("screen-", ""));
const nodeData = node.data as ScreenNodeData;
const nodeData = node.data as unknown as ScreenNodeData;
const mainTable = screenTableMap[screenId];
// 해당 화면의 서브 테이블 (필터 테이블) 정보
@ -1248,7 +1276,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 메인 테이블 노드 더블클릭
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
const tableName = node.id.replace("table-", "");
const nodeData = node.data as TableNodeData;
const nodeData = node.data as unknown as TableNodeData;
// 이 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenTableMap).find(
@ -1293,7 +1321,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 서브 테이블 노드 더블클릭
if (node.id.startsWith("subtable-")) {
const tableName = node.id.replace("subtable-", "");
const nodeData = node.data as TableNodeData;
const nodeData = node.data as unknown as TableNodeData;
// 이 서브 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenSubTableMap).find(
@ -1460,6 +1488,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
}
// lookup 필터 OFF일 때: lookup 연결만 있는 테이블 노드를 dim 처리
const lookupOnlyNodes = new Set<string>();
if (!edgeFilterState.lookup) {
const nodeEdgeCategories = new Map<string, Set<EdgeCategory>>();
edges.forEach((edge) => {
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
if (!category) return;
[edge.source, edge.target].forEach((nodeId) => {
if (!nodeEdgeCategories.has(nodeId)) {
nodeEdgeCategories.set(nodeId, new Set());
}
nodeEdgeCategories.get(nodeId)!.add(category);
});
});
nodeEdgeCategories.forEach((categories, nodeId) => {
if (nodeId.startsWith("table-") || nodeId.startsWith("subtable-")) {
const hasVisibleCategory = Array.from(categories).some(
(cat) => cat !== "lookup" && edgeFilterState[cat]
);
if (!hasVisibleCategory) {
lookupOnlyNodes.add(nodeId);
}
}
});
}
return nodes.map((node) => {
// 화면 노드 스타일링 (포커스가 있을 때만)
if (node.id.startsWith("screen-")) {
@ -1755,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
...node.data,
isFocused: isFocusedTable,
isRelated: isRelatedTable,
isFaded: focusedScreenId !== null && !isActiveTable,
isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id),
highlightedColumns: isActiveTable ? highlightedColumns : [],
joinColumns: isActiveTable ? joinColumns : [],
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
@ -1798,12 +1852,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
});
}
// 디버깅 로그
console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, {
fieldMappings: subTableInfo?.fieldMappings,
extractedJoinColumns: subTableJoinColumns
});
}
// 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
@ -1872,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: {
...node.data,
isFocused: isActiveSubTable,
isFaded: !isActiveSubTable,
isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id),
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
@ -1883,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
return node;
});
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]);
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
const styledEdges = React.useMemo(() => {
@ -1903,9 +1951,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isConnected ? 2.5 : 1,
opacity: isConnected ? 1 : 0.2,
},
};
}
@ -1920,10 +1968,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isMyConnection ? 2.5 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
opacity: isMyConnection ? 1 : 0.2,
},
};
}
@ -1998,11 +2046,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: targetNodeId,
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
type: 'smoothstep',
type: "animatedFlow",
animated: true,
style: {
stroke: relationColor.stroke, // 관계 유형별 색상
strokeWidth: 2,
strokeWidth: 2.5,
strokeDasharray: '8,4',
},
markerEnd: {
@ -2040,9 +2088,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isConnected ? 2.5 : 1,
opacity: isConnected ? 1 : 0.2,
},
};
}
@ -2076,8 +2124,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: true,
style: {
...edge.style,
stroke: "#3b82f6",
strokeWidth: 2,
stroke: "hsl(var(--primary))",
strokeWidth: 2.5,
strokeDasharray: "5,5",
opacity: 1,
},
@ -2095,10 +2143,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isMyConnection ? 2.5 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
opacity: isMyConnection ? 1 : 0.2,
},
};
}
@ -2155,7 +2203,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
strokeWidth: isActive ? 2.5 : 1.5,
strokeDasharray: "8,4",
opacity: isActive ? 1 : 0.3,
opacity: isActive ? 1 : 0.2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
@ -2179,7 +2227,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
stroke: RELATION_COLORS.join.strokeLight,
strokeWidth: 1.5,
strokeDasharray: "6,4",
opacity: 0.3,
opacity: 0.2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
@ -2206,7 +2254,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
style: {
...edge.style,
stroke: RELATION_COLORS.join.stroke,
strokeWidth: 2,
strokeWidth: 2.5,
strokeDasharray: "6,4",
opacity: 1,
},
@ -2282,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
// 기존 엣지 + 조인 관계 엣지 합치기
return [...styledOriginalEdges, ...joinEdges];
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
const allEdges = [...styledOriginalEdges, ...joinEdges];
// 엣지 필터 적용 (edgeFilterState에 따라 숨김)
return allEdges.map((edge) => {
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
if (category && !edgeFilterState[category]) {
return {
...edge,
hidden: true,
};
}
return edge;
});
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap, edgeFilterState]);
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
const groupScreensList = React.useMemo(() => {
@ -2300,10 +2359,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
if (!screen && !selectedGroup) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
<div className="flex h-full flex-col items-center justify-center gap-6 p-8">
<div className="relative">
<div className="flex items-center gap-4 opacity-30">
<div className="h-16 w-24 rounded-lg border-2 border-dashed border-primary/40 flex items-center justify-center">
<Monitor className="h-6 w-6 text-primary/60" />
</div>
<div className="h-px w-12 border-t-2 border-dashed border-border" />
<div className="h-12 w-20 rounded-lg border-2 border-dashed border-info/40 flex items-center justify-center">
<Database className="h-5 w-5 text-info/60" />
</div>
</div>
</div>
<div className="text-center max-w-sm">
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<br/>
.
</p>
</div>
<div className="flex gap-8 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">1</span>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">2</span>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">3</span>
<span> </span>
</div>
</div>
</div>
);
@ -2318,10 +2405,60 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
return (
<div className="h-full w-full">
<div className="relative h-full w-full">
{/* 선택 정보 바 (캔버스 상단) */}
{(screen || selectedGroup) && (
<div className="absolute top-0 left-0 right-0 z-10 flex items-center gap-3 border-b bg-card dark:bg-card/80 backdrop-blur-sm px-4 py-2">
{selectedGroup && (
<>
<FolderOpen className="h-4 w-4 text-warning" />
<span className="text-sm font-medium">{selectedGroup.name}</span>
</>
)}
{screen && !selectedGroup && (
<>
<Monitor className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{screen.screenName}</span>
<span className="text-xs text-muted-foreground/80 dark:text-muted-foreground/50 font-mono">{screen.screenCode}</span>
</>
)}
<div className="h-4 w-px bg-border/50 dark:bg-border/30 mx-1" />
<span className="text-[10px] font-medium text-muted-foreground/80 dark:text-muted-foreground/50"></span>
{(
[
{ key: "main" as EdgeCategory, label: "메인", color: "bg-primary", defaultOn: true },
{ key: "filter" as EdgeCategory, label: "마스터-디테일", color: "bg-[hsl(var(--info))]", defaultOn: true },
{ key: "join" as EdgeCategory, label: "엔티티 조인", color: "bg-amber-400", defaultOn: true },
{ key: "lookup" as EdgeCategory, label: "코드 참조", color: "bg-warning", defaultOn: false },
] as const
).map(({ key, label, color, defaultOn }) => {
const isOn = edgeFilterState[key];
const count = edges.filter((e) => (e.data as any)?.edgeCategory === key).length;
return (
<button
key={key}
type="button"
onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${
isOn
? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80"
: `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}`
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${color} transition-opacity ${isOn ? "opacity-100 shadow-sm" : "opacity-50 dark:opacity-30"}`} />
{label}
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono">{count}</span>
</button>
);
})}
</div>
)}
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
<ReactFlow
className="[&_.react-flow__node]:transition-all [&_.react-flow__node]:duration-300"
nodes={styledNodes}
edges={styledEdges}
onNodesChange={onNodesChange}
@ -2329,12 +2466,42 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
minZoom={0.3}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
<Controls position="bottom-right" />
<svg style={{ position: "absolute", width: 0, height: 0 }}>
<defs>
<filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
<Background id="bg-dots" variant={BackgroundVariant.Dots} gap={16} size={0.5} color="hsl(var(--border) / 0.3)" />
<Background id="bg-lines" variant={BackgroundVariant.Lines} gap={120} color="hsl(var(--border) / 0.08)" />
<Controls position="top-right" />
<MiniMap
position="bottom-right"
nodeColor={(node) => {
if (node.type === "screenNode") return "hsl(var(--primary))";
if (node.type === "tableNode") return "hsl(var(--warning))";
return "hsl(var(--muted-foreground))";
}}
nodeStrokeWidth={2}
zoomable
pannable
style={{
background: "hsl(var(--card) / 0.8)",
border: "1px solid hsl(var(--border) / 0.5)",
borderRadius: "8px",
marginBottom: "8px",
}}
/>
</ReactFlow>
</div>
@ -2353,7 +2520,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
componentCount={0}
onSaveSuccess={handleRefreshVisualization}
isPop={isPop}
/>
)}
@ -2367,7 +2533,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
screenId={settingModalNode.screenId}
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
referencedBy={settingModalNode.existingConfig?.referencedBy}
columns={settingModalNode.existingConfig?.columns}
columns={settingModalNode.existingConfig?.columns?.map((col) => ({
column: col.originalName ?? col.name,
label: col.name,
type: col.type,
isPK: col.isPrimaryKey,
isFK: col.isForeignKey,
}))}
filterColumns={settingModalNode.existingConfig?.filterColumns}
onSaveSuccess={handleRefreshVisualization}
/>

View File

@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import { Slider } from "@/components/ui/slider";
@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
};
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 현재 화면의 테이블명 가져오기
const currentTableName = tables?.[0]?.tableName;
// DB input_type 가져오기 (columnMetaCache에서 최신값 조회)
const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined;
const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 컴포넌트별 추가 props
const extraProps: Record<string, any> = {};
if (componentId === "v2-select") {
const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
if (componentId === "v2-input" || componentId === "v2-select") {
extraProps.inputType = inputType;
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
extraProps.tableName = resolvedTableName;
extraProps.columnName = resolvedColumnName;
extraProps.screenTableName = resolvedTableName;
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
}
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
extraProps.screenTableName = resolvedTableName;
}
return (

View File

@ -764,7 +764,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const currentFormData = formDataRef.current;
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode;
@ -852,6 +852,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return;
if (!numberingTemplateRef.current?.includes("____")) return;
const ruleId = numberingRuleIdRef.current;
if (!ruleId) return;
// 사용자가 한 번도 입력하지 않은 초기 상태면 스킵
if (!userEditedNumberingRef.current) return;
const debounceTimer = setTimeout(async () => {
try {
const currentFormData = formDataRef.current;
const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined);
if (resp.success && resp.data?.generatedCode) {
const newTemplate = resp.data.generatedCode;
if (newTemplate.includes("____")) {
numberingTemplateRef.current = newTemplate;
const parts = newTemplate.split("____");
const prefix = parts[0] || "";
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
const combined = prefix + manualInputValue + suffix;
setAutoGeneratedValue(combined);
onChange?.(combined);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, combined);
}
}
}
} catch {
/* 미리보기 실패 시 기존 suffix 유지 */
}
}, 300);
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;

View File

@ -78,7 +78,15 @@ interface CategoryValueOption {
}
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
function resolveFieldType(config: Record<string, any>, componentType?: string): FieldType {
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType {
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
const dbType = metaInputType as FieldType;
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
return dbType;
}
}
if (config.fieldType) return config.fieldType as FieldType;
// v2-select 계열
@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
inputType: metaInputType,
componentType,
}) => {
const fieldType = resolveFieldType(config, componentType);
const fieldType = resolveFieldType(config, componentType, metaInputType);
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
// ─── 채번 관련 상태 (테이블 기반) ───

View File

@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react";
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
interface V2TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
const [resourceTableOpen, setResourceTableOpen] = useState(false);
const [customTableOpen, setCustomTableOpen] = useState(false);
const [scheduleDataOpen, setScheduleDataOpen] = useState(true);
const [filterLinkOpen, setFilterLinkOpen] = useState(false);
const [sourceDataOpen, setSourceDataOpen] = useState(true);
const [resourceOpen, setResourceOpen] = useState(true);
const [displayOpen, setDisplayOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [newFilterKey, setNewFilterKey] = useState("");
const [newFilterValue, setNewFilterValue] = useState("");
const [linkedFilterTableOpen, setLinkedFilterTableOpen] = useState(false);
const [expandedActionId, setExpandedActionId] = useState<string | null>(null);
useEffect(() => {
const loadTables = async () => {
@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</Select>
</div>
{/* 뷰 모드 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs text-muted-foreground truncate"> </p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description}
</p>
</div>
<Select
value={config.viewMode || "resource"}
onValueChange={(v) => updateConfig({ viewMode: v as any })}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{viewModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 커스텀 테이블 사용 여부 */}
<div className="flex items-center justify-between py-1">
<div>
@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</CollapsibleContent>
</Collapsible>
{/* ─── 필터 & 연동 설정 ─── */}
<Collapsible open={filterLinkOpen} onOpenChange={setFilterLinkOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> & </span>
<Badge variant="secondary" className="text-[10px] h-5">
{Object.keys(config.staticFilters || {}).length + (config.linkedFilter ? 1 : 0)}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterLinkOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
{/* 정적 필터 */}
<div className="space-y-2">
<p className="text-xs font-medium text-primary"> (staticFilters)</p>
<p className="text-[10px] text-muted-foreground"> </p>
{Object.entries(config.staticFilters || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input value={key} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<span className="text-xs text-muted-foreground">=</span>
<Input value={value} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = { ...config.staticFilters };
delete updated[key];
updateConfig({ staticFilters: Object.keys(updated).length > 0 ? updated : undefined });
}}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={newFilterKey}
onChange={(e) => setNewFilterKey(e.target.value)}
placeholder="필드명 (예: product_type)"
className="h-7 flex-1 text-xs"
/>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
placeholder="값 (예: 완제품)"
className="h-7 flex-1 text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!newFilterKey.trim()) return;
updateConfig({
staticFilters: {
...(config.staticFilters || {}),
[newFilterKey.trim()]: newFilterValue.trim(),
},
});
setNewFilterKey("");
setNewFilterValue("");
}}
disabled={!newFilterKey.trim()}
className="h-7 w-7 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 구분선 */}
<div className="border-t" />
{/* 연결 필터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-primary flex items-center gap-1">
<Link className="h-3 w-3" />
(linkedFilter)
</p>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Switch
checked={!!config.linkedFilter}
onCheckedChange={(v) => {
if (v) {
updateConfig({
linkedFilter: {
sourceField: "",
targetField: "",
showEmptyWhenNoSelection: true,
emptyMessage: "좌측 목록에서 항목을 선택하세요",
},
});
} else {
updateConfig({ linkedFilter: undefined });
}
}}
/>
</div>
{config.linkedFilter && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> tableName </p>
</div>
<Popover open={linkedFilterTableOpen} onOpenChange={setLinkedFilterTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-[140px] justify-between text-xs"
disabled={loading}
>
{config.linkedFilter.sourceTableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="end">
<Command filter={(value, search) => value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs p-2"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({
linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName },
});
setLinkedFilterTableOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.linkedFilter?.sourceTableName === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (sourceField) *</span>
<Input
value={config.linkedFilter.sourceField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })}
placeholder="예: part_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (targetField) *</span>
<Input
value={config.linkedFilter.targetField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })}
placeholder="예: item_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.linkedFilter.emptyMessage || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })}
placeholder="선택 안내 문구"
className="h-7 w-[180px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Switch
checked={config.linkedFilter.showEmptyWhenNoSelection ?? true}
onCheckedChange={(v) => updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })}
/>
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 소스 데이터 설정 ─── */}
<Collapsible open={sourceDataOpen} onOpenChange={setSourceDataOpen}>
<CollapsibleTrigger asChild>
@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
onCheckedChange={(v) => updateConfig({ showAddButton: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showLegend ?? true}
onCheckedChange={(v) => updateConfig({ showLegend: v })}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 6단계: 툴바 액션 설정 ─── */}
<Collapsible open={actionsOpen} onOpenChange={setActionsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{(config.toolbarActions || []).length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", actionsOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<p className="text-[10px] text-muted-foreground">
API ( )
</p>
{/* 기존 액션 목록 */}
{(config.toolbarActions || []).map((action, index) => (
<Collapsible
key={action.id}
open={expandedActionId === action.id}
onOpenChange={(open) => setExpandedActionId(open ? action.id : null)}
>
<div className="rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted/30"
>
<div className="flex items-center gap-2">
<GripVertical className="h-3 w-3 text-muted-foreground/50" />
<div className={cn("h-3 w-3 rounded-sm", action.color?.split(" ")[0] || "bg-primary")} />
<span className="text-xs font-medium">{action.label || "새 액션"}</span>
<Badge variant="outline" className="text-[9px] h-4">
{action.dataSource === "linkedSelection" ? "연결선택" : "스케줄"}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
const updated = (config.toolbarActions || []).filter((_, i) => i !== index);
updateConfig({ toolbarActions: updated.length > 0 ? updated : undefined });
}}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground transition-transform", expandedActionId === action.id && "rotate-180")} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-3 py-3 space-y-2.5">
{/* 기본 설정 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.label}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], label: e.target.value };
updateConfig({ toolbarActions: updated });
}}
className="h-7 text-xs"
/>
</div>
<div className="w-[110px]">
<span className="text-[10px] text-muted-foreground"></span>
<Select
value={action.icon || "Zap"}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], icon: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{toolbarIconOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> (Tailwind )</span>
<Input
value={action.color || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], color: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="예: bg-emerald-600 hover:bg-emerald-700"
className="h-7 text-xs"
/>
</div>
{/* API 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5">API </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.previewApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], previewApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule/preview"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.applyApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], applyApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 다이얼로그 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"></p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogTitle || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogTitle: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="자동 생성"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogDescription || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogDescription: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="미리보기 후 확인하여 적용합니다"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 데이터 소스 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> *</span>
<Select
value={action.dataSource}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dataSource: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{dataSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<div>
<span>{opt.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({opt.description})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{action.dataSource === "linkedSelection" && (
<div className="ml-2 border-l-2 border-blue-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.groupByField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="linkedFilter.sourceField 사용"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.quantityField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="balance_qty"
className="h-7 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.dueDateField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="due_date"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.nameField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="part_name"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{action.dataSource === "currentSchedules" && (
<div className="ml-2 border-l-2 border-amber-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="product_type"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterValue || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="완제품"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* 표시 조건 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> (showWhen)</p>
<p className="text-[9px] text-muted-foreground mb-1">staticFilters </p>
{Object.entries(action.showWhen || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-1 mb-1">
<Input value={key} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<span className="text-[10px]">=</span>
<Input value={value} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = [...(config.toolbarActions || [])];
const newShowWhen = { ...updated[index].showWhen };
delete newShowWhen[key];
updated[index] = { ...updated[index], showWhen: Object.keys(newShowWhen).length > 0 ? newShowWhen : undefined };
updateConfig({ toolbarActions: updated });
}}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input
id={`showWhen-key-${index}`}
placeholder="필드명"
className="h-6 flex-1 text-[10px]"
/>
<span className="text-[10px]">=</span>
<Input
id={`showWhen-val-${index}`}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
const keyEl = document.getElementById(`showWhen-key-${index}`) as HTMLInputElement;
const valEl = document.getElementById(`showWhen-val-${index}`) as HTMLInputElement;
if (!keyEl?.value?.trim()) return;
const updated = [...(config.toolbarActions || [])];
updated[index] = {
...updated[index],
showWhen: { ...(updated[index].showWhen || {}), [keyEl.value.trim()]: valEl?.value?.trim() || "" },
};
updateConfig({ toolbarActions: updated });
keyEl.value = "";
if (valEl) valEl.value = "";
}}
className="h-6 w-6 p-0"
>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
{/* 액션 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newAction: ToolbarAction = {
id: `action_${Date.now()}`,
label: "새 액션",
icon: "Zap",
color: "bg-primary hover:bg-primary/90",
previewApi: "",
applyApi: "",
dataSource: "linkedSelection",
};
updateConfig({
toolbarActions: [...(config.toolbarActions || []), newAction],
});
setExpandedActionId(newAction.id);
}}
className="w-full h-8 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View File

@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
export async function previewNumberingCode(
ruleId: string,
formData?: Record<string, unknown>,
manualInputValue?: string,
): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
@ -114,6 +115,7 @@ export async function previewNumberingCode(
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
formData: formData || {},
manualInputValue,
});
if (!response.data) {
return { success: false, error: "서버 응답이 비어있습니다" };

View File

@ -19,11 +19,12 @@ import {
Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer,
icons as allLucideIcons,
type LucideIcon,
} from "lucide-react";
// ---------------------------------------------------------------------------
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘은 명시적 import, 나머지는 동적 조회)
// ---------------------------------------------------------------------------
export const iconMap: Record<string, LucideIcon> = {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
@ -109,15 +110,27 @@ export function getIconSizeStyle(size: string | number): React.CSSProperties {
// ---------------------------------------------------------------------------
// 아이콘 조회 / 동적 등록
// iconMap에 없으면 lucide-react 전체 아이콘에서 동적 조회 후 캐싱
// ---------------------------------------------------------------------------
export function getLucideIcon(name: string): LucideIcon | undefined {
return iconMap[name];
if (iconMap[name]) return iconMap[name];
const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
}
export function addToIconMap(name: string, component: LucideIcon): void {
iconMap[name] = component;
}
// ButtonConfigPanel 등에서 전체 아이콘 검색용으로 사용
export { allLucideIcons };
// ---------------------------------------------------------------------------
// SVG 정화
// ---------------------------------------------------------------------------

View File

@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
const columnMetaCache: Record<string, Record<string, any>> = {};
export const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
const columnMetaTimestamp: Record<string, number> = {};
const CACHE_TTL_MS = 5000;
async function loadColumnMeta(tableName: string): Promise<void> {
if (columnMetaCache[tableName]) return;
export function invalidateColumnMetaCache(tableName?: string): void {
if (tableName) {
delete columnMetaCache[tableName];
delete columnMetaLoading[tableName];
delete columnMetaTimestamp[tableName];
} else {
for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key];
for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key];
for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key];
}
}
async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
const now = Date.now();
const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS);
if (!forceReload && !isStale && columnMetaCache[tableName]) return;
if (forceReload || isStale) {
delete columnMetaCache[tableName];
delete columnMetaLoading[tableName];
}
// 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지)
if (columnMetaLoading[tableName]) {
await columnMetaLoading[tableName];
return;
@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise<void> {
if (name) map[name] = col;
}
columnMetaCache[tableName] = map;
columnMetaTimestamp[tableName] = Date.now();
} catch (e) {
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
columnMetaCache[tableName] = {};
@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string):
return nullable === "NO" || nullable === "N";
}
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return componentConfig;
const inputType = meta.input_type || meta.inputType;
if (!inputType) return componentConfig;
// 이미 source가 올바르게 설정된 경우 건드리지 않음
const existingSource = componentConfig?.source;
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
return componentConfig;
}
const rawType = meta.input_type || meta.inputType;
const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType;
if (!dbInputType) return componentConfig;
const merged = { ...componentConfig };
const savedFieldType = merged.fieldType;
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
if (inputType === "entity") {
// savedFieldType이 있고 DB와 같으면 변경 불필요
if (savedFieldType && savedFieldType === dbInputType) return merged;
// savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중
if (savedFieldType) return merged;
// savedFieldType이 없으면: DB input_type 기준으로 동기화
// 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀
if (dbInputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
if (refTable && !merged.entityTable) {
if (refTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
merged.fieldType = "entity";
merged.inputType = "entity";
}
} else if (inputType === "category" && !existingSource) {
} else if (dbInputType === "category") {
merged.source = "category";
} else if (inputType === "select" && !existingSource) {
merged.fieldType = "category";
merged.inputType = "category";
} else if (dbInputType === "select") {
if (!merged.source || merged.source === "category" || merged.source === "entity") {
merged.source = "static";
}
const detail =
typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {};
if (detail.options && !merged.options?.length) {
merged.options = detail.options;
}
merged.fieldType = "select";
merged.inputType = "select";
} else {
// text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거
merged.fieldType = dbInputType;
merged.inputType = dbInputType;
delete merged.source;
}
return merged;
@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
children,
...props
}) => {
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
// 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신)
const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0);
const [metaVersion, forceUpdate] = React.useState(0);
React.useEffect(() => {
if (screenTableName) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
}
}, [screenTableName]);
// table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드
React.useEffect(() => {
const handler = () => {
if (screenTableName) {
invalidateColumnMetaCache(screenTableName);
loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1));
}
};
window.addEventListener("table-columns-refresh", handler);
return () => window.removeEventListener("table-columns-refresh", handler);
}, [screenTableName]);
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const mappedComponentType = mapToV2ComponentType(rawComponentType);
// fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값)
// fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값)
const componentType = (() => {
const ft = (component as any).componentConfig?.fieldType;
if (!ft) return mappedComponentType;
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input";
if (["select", "category", "entity"].includes(ft)) return "v2-select";
const configFieldType = (component as any).componentConfig?.fieldType;
const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName;
const isEntityJoin = fieldName?.includes(".");
const baseCol = isEntityJoin ? undefined : fieldName;
const rawDbType = baseCol && screenTableName
? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType)
: undefined;
const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType;
// 디버그 (division, unit 필드만) - 문제 확인 후 제거
if (baseCol && (baseCol === "division" || baseCol === "unit")) {
const result = configFieldType
? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select")
: dbInputType
? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select")
: mappedComponentType;
const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType);
console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`);
}
// 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선
if (configFieldType) {
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input";
if (["select", "category", "entity"].includes(configFieldType)) return "v2-select";
}
// componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시)
if (dbInputType) {
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input";
if (["select", "category", "entity"].includes(dbInputType)) return "v2-select";
}
return mappedComponentType;
})();
@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
// DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀
const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType;
const webType = (component as any).componentConfig?.webType;
const tableName = (component as any).tableName;
const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
// DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선
const dbMetaForField = columnName && screenTableName && !columnName.includes(".")
? columnMetaCache[screenTableName]?.[columnName]
: undefined;
const dbFieldInputType = dbMetaForField
? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })()
: undefined;
// DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용
const inputType = dbFieldInputType || savedInputType;
// webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음)
const effectiveWebType = dbFieldInputType || webType;
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const isMultipleSelect = (component as any).componentConfig?.multiple;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const shouldUseV2Select =
componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) {
// DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵
// dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지
const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType);
if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) {
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
} catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
} else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) {
try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id;

View File

@ -502,15 +502,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (component.style?.backgroundColor) {
return component.style.backgroundColor;
}
// 4순위: style.labelColor (레거시)
// 4순위: componentConfig.style.backgroundColor
if (componentConfig.style?.backgroundColor) {
return componentConfig.style.backgroundColor;
}
// 5순위: style.labelColor (레거시 호환)
if (component.style?.labelColor) {
return component.style.labelColor;
}
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑
if (isDeleteAction()) {
return "#ef4444"; // 빨간색 (Tailwind red-500)
}
return "#3b82f6"; // 파란색 (Tailwind blue-500)
// 6순위: 액션별 기본 배경색
const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"];
const actionType = typeof componentConfig.action === "string"
? componentConfig.action
: componentConfig.action?.type || "";
if (actionType === "delete") return "#F04544";
if (excelActions.includes(actionType)) return "#212121";
return "#3B83F6";
};
const getButtonTextColor = () => {

View File

@ -6,16 +6,29 @@ import { ButtonPrimaryConfig } from "./types";
* ButtonPrimary
*/
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
text: "버튼",
text: "저장",
actionType: "button",
variant: "primary",
// 공통 기본값
variant: "default",
size: "md",
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
displayMode: "icon-text",
icon: {
name: "Check",
type: "lucide",
size: "보통",
},
iconTextPosition: "right",
iconGap: 6,
style: {
borderRadius: "8px",
labelColor: "#FFFFFF",
fontSize: "12px",
fontWeight: "normal",
labelTextAlign: "left",
backgroundColor: "#3B83F6",
},
};
/**

View File

@ -26,8 +26,24 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.",
},
displayMode: "icon-text",
icon: {
name: "Check",
type: "lucide",
size: "보통",
},
iconTextPosition: "right",
iconGap: 6,
style: {
borderRadius: "8px",
labelColor: "#FFFFFF",
fontSize: "12px",
fontWeight: "normal",
labelTextAlign: "left",
backgroundColor: "#3B83F6",
},
},
defaultSize: { width: 120, height: 40 },
defaultSize: { width: 100, height: 40 },
configPanel: V2ButtonConfigPanel,
icon: "MousePointer",
tags: ["버튼", "액션", "클릭"],

View File

@ -153,15 +153,37 @@ export function useGroupedData(
}
);
const responseData = response.data?.data?.data || response.data?.data || [];
setRawData(Array.isArray(responseData) ? responseData : []);
let responseData = response.data?.data?.data || response.data?.data || [];
responseData = Array.isArray(responseData) ? responseData : [];
// dataFilter 적용 (클라이언트 사이드 필터링)
if (config.dataFilter && config.dataFilter.length > 0) {
responseData = responseData.filter((item: any) => {
return config.dataFilter!.every((f) => {
const val = item[f.column];
switch (f.operator) {
case "eq": return val === f.value;
case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value;
case "gt": return Number(val) > Number(f.value);
case "lt": return Number(val) < Number(f.value);
case "gte": return Number(val) >= Number(f.value);
case "lte": return Number(val) <= Number(f.value);
case "like": return String(val ?? "").includes(String(f.value));
case "in": return Array.isArray(f.value) ? f.value.includes(val) : false;
default: return true;
}
});
});
}
setRawData(responseData);
} catch (err: any) {
setError(err.message || "데이터 로드 중 오류 발생");
setRawData([]);
} finally {
setIsLoading(false);
}
}, [tableName, externalData, searchFilters]);
}, [tableName, externalData, searchFilters, config.dataFilter]);
// 초기 데이터 로드
useEffect(() => {

View File

@ -0,0 +1,297 @@
"use client";
import React, { useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { Flame } from "lucide-react";
import { ScheduleItem, TimelineSchedulerConfig, ZoomLevel } from "../types";
import { statusOptions, dayLabels } from "../config";
interface ItemScheduleGroup {
itemCode: string;
itemName: string;
hourlyCapacity: number;
dailyCapacity: number;
schedules: ScheduleItem[];
totalPlanQty: number;
totalCompletedQty: number;
remainingQty: number;
dueDates: { date: string; isUrgent: boolean }[];
}
interface ItemTimelineCardProps {
group: ItemScheduleGroup;
viewStartDate: Date;
viewEndDate: Date;
zoomLevel: ZoomLevel;
cellWidth: number;
config: TimelineSchedulerConfig;
onScheduleClick?: (schedule: ScheduleItem) => void;
}
const toDateString = (d: Date) => d.toISOString().split("T")[0];
const addDays = (d: Date, n: number) => {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
};
const diffDays = (a: Date, b: Date) =>
Math.round((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24));
function generateDateCells(start: Date, end: Date) {
const cells: { date: Date; label: string; dayLabel: string; isWeekend: boolean; isToday: boolean; dateStr: string }[] = [];
const today = toDateString(new Date());
let cur = new Date(start);
while (cur <= end) {
const d = new Date(cur);
const dow = d.getDay();
cells.push({
date: d,
label: String(d.getDate()),
dayLabel: dayLabels[dow],
isWeekend: dow === 0 || dow === 6,
isToday: toDateString(d) === today,
dateStr: toDateString(d),
});
cur = addDays(cur, 1);
}
return cells;
}
export function ItemTimelineCard({
group,
viewStartDate,
viewEndDate,
zoomLevel,
cellWidth,
config,
onScheduleClick,
}: ItemTimelineCardProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const dateCells = useMemo(
() => generateDateCells(viewStartDate, viewEndDate),
[viewStartDate, viewEndDate]
);
const totalWidth = dateCells.length * cellWidth;
const dueDateSet = useMemo(() => {
const set = new Set<string>();
group.dueDates.forEach((d) => set.add(d.date));
return set;
}, [group.dueDates]);
const urgentDateSet = useMemo(() => {
const set = new Set<string>();
group.dueDates.filter((d) => d.isUrgent).forEach((d) => set.add(d.date));
return set;
}, [group.dueDates]);
const statusColor = (status: string) =>
config.statusColors?.[status as keyof typeof config.statusColors] ||
statusOptions.find((s) => s.value === status)?.color ||
"#3b82f6";
const isUrgentItem = group.dueDates.some((d) => d.isUrgent);
const hasRemaining = group.remainingQty > 0;
return (
<div className="rounded-lg border bg-background">
{/* 품목 헤더 */}
<div className="flex items-start justify-between border-b px-3 py-2 sm:px-4 sm:py-3">
<div className="flex items-start gap-2">
<input type="checkbox" className="mt-1 h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
<div>
<p className="text-[10px] text-muted-foreground sm:text-xs">{group.itemCode}</p>
<p className="text-xs font-semibold sm:text-sm">{group.itemName}</p>
</div>
</div>
<div className="text-right text-[10px] text-muted-foreground sm:text-xs">
<p>
: <span className="font-semibold text-foreground">{group.hourlyCapacity.toLocaleString()}</span> EA
</p>
<p>
: <span className="font-semibold text-foreground">{group.dailyCapacity.toLocaleString()}</span> EA
</p>
</div>
</div>
{/* 타임라인 영역 */}
<div ref={scrollRef} className="overflow-x-auto">
<div style={{ width: totalWidth, minWidth: "100%" }}>
{/* 날짜 헤더 */}
<div className="flex border-b">
{dateCells.map((cell) => {
const isDueDate = dueDateSet.has(cell.dateStr);
const isUrgentDate = urgentDateSet.has(cell.dateStr);
return (
<div
key={cell.dateStr}
className={cn(
"flex shrink-0 flex-col items-center justify-center border-r py-1",
cell.isWeekend && "bg-muted/30",
cell.isToday && "bg-primary/5",
isDueDate && "ring-2 ring-inset ring-destructive",
isUrgentDate && "bg-destructive/5"
)}
style={{ width: cellWidth }}
>
<span className={cn(
"text-[10px] font-medium sm:text-xs",
cell.isToday && "text-primary",
cell.isWeekend && "text-destructive/70"
)}>
{cell.label}
</span>
<span className={cn(
"text-[8px] sm:text-[10px]",
cell.isToday && "text-primary",
cell.isWeekend && "text-destructive/50",
!cell.isToday && !cell.isWeekend && "text-muted-foreground"
)}>
{cell.dayLabel}
</span>
</div>
);
})}
</div>
{/* 스케줄 바 영역 */}
<div className="relative" style={{ height: 48 }}>
{group.schedules.map((schedule) => {
const schedStart = new Date(schedule.startDate);
const schedEnd = new Date(schedule.endDate);
const startOffset = diffDays(schedStart, viewStartDate);
const endOffset = diffDays(schedEnd, viewStartDate);
const left = Math.max(0, startOffset * cellWidth);
const right = Math.min(totalWidth, (endOffset + 1) * cellWidth);
const width = Math.max(cellWidth * 0.5, right - left);
if (right < 0 || left > totalWidth) return null;
const qty = Number(schedule.data?.plan_qty) || 0;
const color = statusColor(schedule.status);
return (
<div
key={schedule.id}
className="absolute cursor-pointer rounded-md shadow-sm transition-shadow hover:shadow-md"
style={{
left,
top: 8,
width,
height: 32,
backgroundColor: color,
}}
onClick={() => onScheduleClick?.(schedule)}
title={`${schedule.title} (${schedule.startDate} ~ ${schedule.endDate})`}
>
<div className="flex h-full items-center justify-center truncate px-1 text-[10px] font-medium text-white sm:text-xs">
{qty > 0 ? `${qty.toLocaleString()} EA` : schedule.title}
</div>
</div>
);
})}
{/* 납기일 마커 */}
{group.dueDates.map((dueDate, idx) => {
const d = new Date(dueDate.date);
const offset = diffDays(d, viewStartDate);
if (offset < 0 || offset > dateCells.length) return null;
const left = offset * cellWidth + cellWidth / 2;
return (
<div
key={`due-${idx}`}
className="absolute top-0 bottom-0"
style={{ left, width: 0 }}
>
<div className={cn(
"absolute top-0 h-full w-px",
dueDate.isUrgent ? "bg-destructive" : "bg-destructive/40"
)} />
</div>
);
})}
</div>
</div>
</div>
{/* 하단 잔량 영역 */}
<div className="flex items-center gap-2 border-t px-3 py-1.5 sm:px-4 sm:py-2">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
{hasRemaining && (
<div className={cn(
"flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-semibold sm:text-xs",
isUrgentItem
? "bg-destructive/10 text-destructive"
: "bg-warning/10 text-warning"
)}>
{isUrgentItem && <Flame className="h-3 w-3 sm:h-3.5 sm:w-3.5" />}
{group.remainingQty.toLocaleString()} EA
</div>
)}
{/* 스크롤 인디케이터 */}
<div className="ml-auto flex-1">
<div className="h-1 w-16 rounded-full bg-muted" />
</div>
</div>
</div>
);
}
/**
*
*/
export function groupSchedulesByItem(schedules: ScheduleItem[]): ItemScheduleGroup[] {
const grouped = new Map<string, ScheduleItem[]>();
schedules.forEach((s) => {
const key = s.data?.item_code || "unknown";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(s);
});
const result: ItemScheduleGroup[] = [];
grouped.forEach((items, itemCode) => {
const first = items[0];
const hourlyCapacity = Number(first.data?.hourly_capacity) || 0;
const dailyCapacity = Number(first.data?.daily_capacity) || 0;
const totalPlanQty = items.reduce((sum, s) => sum + (Number(s.data?.plan_qty) || 0), 0);
const totalCompletedQty = items.reduce((sum, s) => sum + (Number(s.data?.completed_qty) || 0), 0);
const dueDates: { date: string; isUrgent: boolean }[] = [];
const seenDueDates = new Set<string>();
items.forEach((s) => {
const dd = s.data?.due_date;
if (dd) {
const dateStr = typeof dd === "string" ? dd.split("T")[0] : "";
if (dateStr && !seenDueDates.has(dateStr)) {
seenDueDates.add(dateStr);
const isUrgent = s.data?.priority === "urgent" || s.data?.priority === "high";
dueDates.push({ date: dateStr, isUrgent });
}
}
});
result.push({
itemCode,
itemName: first.data?.item_name || first.title || itemCode,
hourlyCapacity,
dailyCapacity,
schedules: items.sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
totalPlanQty,
totalCompletedQty,
remainingQty: totalPlanQty - totalCompletedQty,
dueDates: dueDates.sort((a, b) => a.date.localeCompare(b.date)),
});
});
return result.sort((a, b) => a.itemCode.localeCompare(b.itemCode));
}

View File

@ -2,54 +2,44 @@
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
import {
Resource,
ScheduleItem,
ZoomLevel,
TimelineSchedulerConfig,
} from "../types";
import { ScheduleBar } from "./ScheduleBar";
interface ResourceRowProps {
/** 리소스 */
resource: Resource;
/** 해당 리소스의 스케줄 목록 */
schedules: ScheduleItem[];
/** 시작 날짜 */
startDate: Date;
/** 종료 날짜 */
endDate: Date;
/** 줌 레벨 */
zoomLevel: ZoomLevel;
/** 행 높이 */
rowHeight: number;
/** 셀 너비 */
cellWidth: number;
/** 리소스 컬럼 너비 */
resourceColumnWidth: number;
/** 설정 */
config: TimelineSchedulerConfig;
/** 스케줄 클릭 */
/** 충돌 스케줄 ID 목록 */
conflictIds?: Set<string>;
onScheduleClick?: (schedule: ScheduleItem) => void;
/** 빈 셀 클릭 */
onCellClick?: (resourceId: string, date: Date) => void;
/** 드래그 시작 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
/** 드래그 종료 */
onDragEnd?: () => void;
/** 리사이즈 시작 */
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
/** 드래그 완료: deltaX(픽셀) 전달 */
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
/** 리사이즈 완료: direction + deltaX(픽셀) 전달 */
onResizeComplete?: (
schedule: ScheduleItem,
direction: "start" | "end",
deltaX: number
) => void;
}
/**
* ()
*/
const getDaysDiff = (start: Date, end: Date): number => {
const startTime = new Date(start).setHours(0, 0, 0, 0);
const endTime = new Date(end).setHours(0, 0, 0, 0);
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
};
/**
*
*/
const getCellCount = (startDate: Date, endDate: Date): number => {
return getDaysDiff(startDate, endDate) + 1;
};
@ -64,20 +54,18 @@ export function ResourceRow({
cellWidth,
resourceColumnWidth,
config,
conflictIds,
onScheduleClick,
onCellClick,
onDragStart,
onDragEnd,
onResizeStart,
onResizeEnd,
onDragComplete,
onResizeComplete,
}: ResourceRowProps) {
// 총 셀 개수
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
// 총 그리드 너비
const totalCells = useMemo(
() => getCellCount(startDate, endDate),
[startDate, endDate]
);
const gridWidth = totalCells * cellWidth;
// 오늘 날짜
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
@ -92,21 +80,26 @@ export function ResourceRow({
scheduleStart.setHours(0, 0, 0, 0);
scheduleEnd.setHours(0, 0, 0, 0);
// 시작 위치 계산
const startOffset = getDaysDiff(startDate, scheduleStart);
const left = Math.max(0, startOffset * cellWidth);
// 너비 계산
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
const visibleStartOffset = Math.max(0, startOffset);
const visibleEndOffset = Math.min(
totalCells,
startOffset + durationDays
);
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
const width = Math.max(
cellWidth,
(visibleEndOffset - visibleStartOffset) * cellWidth
);
// 시작일 = 종료일이면 마일스톤
const isMilestone = schedule.startDate === schedule.endDate;
return {
schedule,
isMilestone,
position: {
left: resourceColumnWidth + left,
top: 0,
@ -115,9 +108,15 @@ export function ResourceRow({
},
};
});
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
}, [
schedules,
startDate,
cellWidth,
resourceColumnWidth,
rowHeight,
totalCells,
]);
// 그리드 셀 클릭 핸들러
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onCellClick) return;
@ -142,7 +141,9 @@ export function ResourceRow({
style={{ width: resourceColumnWidth }}
>
<div className="truncate">
<div className="truncate text-[10px] font-medium sm:text-sm">{resource.name}</div>
<div className="truncate text-[10px] font-medium sm:text-sm">
{resource.name}
</div>
{resource.group && (
<div className="truncate text-[9px] text-muted-foreground sm:text-xs">
{resource.group}
@ -162,7 +163,8 @@ export function ResourceRow({
{Array.from({ length: totalCells }).map((_, idx) => {
const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + idx);
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
const isWeekend =
cellDate.getDay() === 0 || cellDate.getDay() === 6;
const isToday = cellDate.getTime() === today.getTime();
const isMonthStart = cellDate.getDate() === 1;
@ -182,22 +184,22 @@ export function ResourceRow({
</div>
{/* 스케줄 바들 */}
{schedulePositions.map(({ schedule, position }) => (
{schedulePositions.map(({ schedule, position, isMilestone }) => (
<ScheduleBar
key={schedule.id}
schedule={schedule}
position={{
...position,
left: position.left - resourceColumnWidth, // 상대 위치
left: position.left - resourceColumnWidth,
}}
config={config}
draggable={config.draggable}
resizable={config.resizable}
hasConflict={conflictIds?.has(schedule.id) ?? false}
isMilestone={isMilestone}
onClick={() => onScheduleClick?.(schedule)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
onDragComplete={onDragComplete}
onResizeComplete={onResizeComplete}
/>
))}
</div>

View File

@ -2,79 +2,99 @@
import React, { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
import { AlertTriangle } from "lucide-react";
import {
ScheduleItem,
ScheduleBarPosition,
TimelineSchedulerConfig,
} from "../types";
import { statusOptions } from "../config";
interface ScheduleBarProps {
/** 스케줄 항목 */
schedule: ScheduleItem;
/** 위치 정보 */
position: ScheduleBarPosition;
/** 설정 */
config: TimelineSchedulerConfig;
/** 드래그 가능 여부 */
draggable?: boolean;
/** 리사이즈 가능 여부 */
resizable?: boolean;
/** 클릭 이벤트 */
hasConflict?: boolean;
isMilestone?: boolean;
onClick?: (schedule: ScheduleItem) => void;
/** 드래그 시작 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
/** 드래그 중 */
onDrag?: (deltaX: number, deltaY: number) => void;
/** 드래그 종료 */
onDragEnd?: () => void;
/** 리사이즈 시작 */
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
/** 리사이즈 중 */
onResize?: (deltaX: number, direction: "start" | "end") => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
/** 드래그 완료 시 deltaX(픽셀) 전달 */
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
/** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */
onResizeComplete?: (
schedule: ScheduleItem,
direction: "start" | "end",
deltaX: number
) => void;
}
// 드래그/리사이즈 판정 최소 이동 거리 (px)
const MIN_MOVE_THRESHOLD = 5;
export function ScheduleBar({
schedule,
position,
config,
draggable = true,
resizable = true,
hasConflict = false,
isMilestone = false,
onClick,
onDragStart,
onDragEnd,
onResizeStart,
onResizeEnd,
onDragComplete,
onResizeComplete,
}: ScheduleBarProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const [resizeOffset, setResizeOffset] = useState(0);
const [resizeDir, setResizeDir] = useState<"start" | "end">("end");
const barRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0);
const movedRef = useRef(false);
// 상태에 따른 색상
const statusColor = schedule.color ||
const statusColor =
schedule.color ||
config.statusColors?.[schedule.status] ||
statusOptions.find((s) => s.value === schedule.status)?.color ||
"#3b82f6";
// 진행률 바 너비
const progressWidth = config.showProgress && schedule.progress !== undefined
? `${schedule.progress}%`
: "0%";
const progressWidth =
config.showProgress && schedule.progress !== undefined
? `${schedule.progress}%`
: "0%";
// 드래그 시작 핸들러
const isEditable = config.editable !== false;
// ────────── 드래그 핸들러 ──────────
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!draggable || isResizing) return;
if (!draggable || isResizing || !isEditable) return;
e.preventDefault();
e.stopPropagation();
startXRef.current = e.clientX;
movedRef.current = false;
setIsDragging(true);
onDragStart?.(schedule, e);
setDragOffset(0);
const handleMouseMove = (moveEvent: MouseEvent) => {
// 드래그 중 로직은 부모에서 처리
const delta = moveEvent.clientX - startXRef.current;
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
movedRef.current = true;
}
setDragOffset(delta);
};
const handleMouseUp = () => {
const handleMouseUp = (upEvent: MouseEvent) => {
const finalDelta = upEvent.clientX - startXRef.current;
setIsDragging(false);
onDragEnd?.();
setDragOffset(0);
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
onDragComplete?.(schedule, finalDelta);
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
@ -82,25 +102,39 @@ export function ScheduleBar({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[draggable, isResizing, schedule, onDragStart, onDragEnd]
[draggable, isResizing, isEditable, schedule, onDragComplete]
);
// 리사이즈 시작 핸들러
const handleResizeStart = useCallback(
// ────────── 리사이즈 핸들러 ──────────
const handleResizeMouseDown = useCallback(
(direction: "start" | "end", e: React.MouseEvent) => {
if (!resizable) return;
if (!resizable || !isEditable) return;
e.preventDefault();
e.stopPropagation();
startXRef.current = e.clientX;
movedRef.current = false;
setIsResizing(true);
onResizeStart?.(schedule, direction, e);
setResizeOffset(0);
setResizeDir(direction);
const handleMouseMove = (moveEvent: MouseEvent) => {
// 리사이즈 중 로직은 부모에서 처리
const delta = moveEvent.clientX - startXRef.current;
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
movedRef.current = true;
}
setResizeOffset(delta);
};
const handleMouseUp = () => {
const handleMouseUp = (upEvent: MouseEvent) => {
const finalDelta = upEvent.clientX - startXRef.current;
setIsResizing(false);
onResizeEnd?.();
setResizeOffset(0);
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
onResizeComplete?.(schedule, direction, finalDelta);
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
@ -108,19 +142,62 @@ export function ScheduleBar({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[resizable, schedule, onResizeStart, onResizeEnd]
[resizable, isEditable, schedule, onResizeComplete]
);
// 클릭 핸들러
// ────────── 클릭 핸들러 ──────────
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isDragging || isResizing) return;
if (movedRef.current) return;
e.stopPropagation();
onClick?.(schedule);
},
[isDragging, isResizing, onClick, schedule]
[onClick, schedule]
);
// ────────── 드래그/리사이즈 중 시각적 위치 계산 ──────────
let visualLeft = position.left;
let visualWidth = position.width;
if (isDragging) {
visualLeft += dragOffset;
}
if (isResizing) {
if (resizeDir === "start") {
visualLeft += resizeOffset;
visualWidth -= resizeOffset;
} else {
visualWidth += resizeOffset;
}
}
visualWidth = Math.max(10, visualWidth);
// ────────── 마일스톤 렌더링 (단일 날짜 마커) ──────────
if (isMilestone) {
return (
<div
ref={barRef}
className="absolute flex cursor-pointer items-center justify-center"
style={{
left: visualLeft + position.width / 2 - 8,
top: position.top + position.height / 2 - 8,
width: 16,
height: 16,
}}
onClick={handleClick}
title={schedule.title}
>
<div
className="h-2.5 w-2.5 rotate-45 shadow-sm transition-transform hover:scale-125 sm:h-3 sm:w-3"
style={{ backgroundColor: statusColor }}
/>
</div>
);
}
// ────────── 일반 스케줄 바 렌더링 ──────────
return (
<div
ref={barRef}
@ -128,19 +205,21 @@ export function ScheduleBar({
"absolute cursor-pointer rounded-md shadow-sm transition-shadow",
"hover:z-10 hover:shadow-md",
isDragging && "z-20 opacity-70 shadow-lg",
isResizing && "z-20",
draggable && "cursor-grab",
isDragging && "cursor-grabbing"
isResizing && "z-20 opacity-80",
draggable && isEditable && "cursor-grab",
isDragging && "cursor-grabbing",
hasConflict && "ring-2 ring-destructive ring-offset-1"
)}
style={{
left: position.left,
left: visualLeft,
top: position.top + 4,
width: position.width,
width: visualWidth,
height: position.height - 8,
backgroundColor: statusColor,
}}
onClick={handleClick}
onMouseDown={handleMouseDown}
title={schedule.title}
>
{/* 진행률 바 */}
{config.showProgress && schedule.progress !== undefined && (
@ -162,19 +241,26 @@ export function ScheduleBar({
</div>
)}
{/* 충돌 인디케이터 */}
{hasConflict && (
<div className="absolute -right-0.5 -top-0.5 sm:-right-1 sm:-top-1">
<AlertTriangle className="h-2.5 w-2.5 fill-destructive text-white sm:h-3 sm:w-3" />
</div>
)}
{/* 리사이즈 핸들 - 왼쪽 */}
{resizable && (
{resizable && isEditable && (
<div
className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2"
onMouseDown={(e) => handleResizeStart("start", e)}
onMouseDown={(e) => handleResizeMouseDown("start", e)}
/>
)}
{/* 리사이즈 핸들 - 오른쪽 */}
{resizable && (
{resizable && isEditable && (
<div
className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2"
onMouseDown={(e) => handleResizeStart("end", e)}
onMouseDown={(e) => handleResizeMouseDown("end", e)}
/>
)}
</div>

View File

@ -0,0 +1,282 @@
"use client";
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, AlertTriangle, Check, X, Trash2, Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { statusOptions } from "../config";
interface PreviewItem {
item_code: string;
item_name: string;
required_qty: number;
daily_capacity: number;
hourly_capacity: number;
production_days: number;
start_date: string;
end_date: string;
due_date: string;
order_count: number;
status: string;
}
interface ExistingSchedule {
id: string;
plan_no: string;
item_code: string;
item_name: string;
plan_qty: string;
start_date: string;
end_date: string;
status: string;
completed_qty?: string;
}
interface PreviewSummary {
total: number;
new_count: number;
kept_count: number;
deleted_count: number;
}
interface SchedulePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isLoading: boolean;
summary: PreviewSummary | null;
previews: PreviewItem[];
deletedSchedules: ExistingSchedule[];
keptSchedules: ExistingSchedule[];
onConfirm: () => void;
isApplying: boolean;
title?: string;
description?: string;
}
const summaryCards = [
{ key: "total", label: "총 계획", color: "bg-primary/10 text-primary" },
{ key: "new_count", label: "신규 입력", color: "bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400" },
{ key: "deleted_count", label: "삭제될", color: "bg-destructive/10 text-destructive" },
{ key: "kept_count", label: "유지(진행중)", color: "bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400" },
];
function formatDate(d: string | null | undefined): string {
if (!d) return "-";
const s = typeof d === "string" ? d : String(d);
return s.split("T")[0];
}
export function SchedulePreviewDialog({
open,
onOpenChange,
isLoading,
summary,
previews,
deletedSchedules,
keptSchedules,
onConfirm,
isApplying,
title,
description,
}: SchedulePreviewDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[640px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{title || "생산계획 변경사항 확인"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{description || "변경사항을 확인해주세요"}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
) : summary ? (
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
{/* 경고 배너 */}
<div className="flex items-start gap-2 rounded-md bg-amber-50 px-3 py-2 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="text-xs sm:text-sm">
<p className="font-medium"> </p>
<p className="mt-0.5 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
.
</p>
</div>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-4 gap-2">
{summaryCards.map((card) => (
<div
key={card.key}
className={cn("rounded-lg px-3 py-3 text-center", card.color)}
>
<p className="text-lg font-bold sm:text-xl">
{(summary as any)[card.key] ?? 0}
</p>
<p className="text-[10px] sm:text-xs">{card.label}</p>
</div>
))}
</div>
{/* 신규 생성 목록 */}
{previews.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-emerald-600 sm:text-sm">
<Check className="h-3.5 w-3.5" />
({previews.length})
</p>
<div className="space-y-2">
{previews.map((item, idx) => {
const statusInfo = statusOptions.find((s) => s.value === item.status);
return (
<div key={idx} className="rounded-md border border-emerald-200 bg-emerald-50/50 px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950/30">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold sm:text-sm">
{item.item_code} - {item.item_name}
</p>
<span
className="rounded-md px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs"
style={{ backgroundColor: statusInfo?.color || "#3b82f6" }}
>
{statusInfo?.label || item.status}
</span>
</div>
<p className="mt-1 text-xs text-primary sm:text-sm">
: <span className="font-semibold">{(item.required_qty || (item as any).plan_qty || 0).toLocaleString()}</span> EA
</p>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[10px] text-muted-foreground sm:text-xs">
<span>: {formatDate(item.start_date)}</span>
<span>: {formatDate(item.end_date)}</span>
</div>
{item.order_count ? (
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
{item.order_count} ( {item.required_qty.toLocaleString()} EA)
</p>
) : (item as any).parent_item_name ? (
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
: {(item as any).parent_plan_no} ({(item as any).parent_item_name}) | BOM : {(item as any).bom_qty || 1}
</p>
) : null}
</div>
);
})}
</div>
</div>
)}
{/* 삭제될 목록 */}
{deletedSchedules.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-destructive sm:text-sm">
<Trash2 className="h-3.5 w-3.5" />
({deletedSchedules.length})
</p>
<div className="space-y-2">
{deletedSchedules.map((item, idx) => (
<div key={idx} className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold sm:text-sm">
{item.item_code} - {item.item_name}
</p>
<span className="rounded-md bg-destructive/20 px-2 py-0.5 text-[10px] font-medium text-destructive sm:text-xs">
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
{item.plan_no} | : {Number(item.plan_qty || 0).toLocaleString()} EA
</p>
<div className="mt-0.5 flex flex-wrap gap-x-4 text-[10px] text-muted-foreground sm:text-xs">
<span>: {formatDate(item.start_date)}</span>
<span>: {formatDate(item.end_date)}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* 유지될 목록 (진행중) */}
{keptSchedules.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-600 sm:text-sm">
<Play className="h-3.5 w-3.5" />
({keptSchedules.length})
</p>
<div className="space-y-2">
{keptSchedules.map((item, idx) => {
const statusInfo = statusOptions.find((s) => s.value === item.status);
return (
<div key={idx} className="rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold sm:text-sm">
{item.item_code} - {item.item_name}
</p>
<span
className="rounded-md px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs"
style={{ backgroundColor: statusInfo?.color || "#f59e0b" }}
>
{statusInfo?.label || item.status}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
{item.plan_no} | : {Number(item.plan_qty || 0).toLocaleString()} EA
{item.completed_qty ? ` (완료: ${Number(item.completed_qty).toLocaleString()} EA)` : ""}
</p>
<div className="mt-0.5 flex flex-wrap gap-x-4 text-[10px] text-muted-foreground sm:text-xs">
<span>: {formatDate(item.start_date)}</span>
<span>: {formatDate(item.end_date)}</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isApplying}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<X className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
onClick={onConfirm}
disabled={isLoading || isApplying || !summary || previews.length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isApplying ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Check className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import React from "react";
import { TimelineSchedulerConfig } from "../types";
import { statusOptions } from "../config";
interface TimelineLegendProps {
config: TimelineSchedulerConfig;
}
export function TimelineLegend({ config }: TimelineLegendProps) {
const colors = config.statusColors || {};
return (
<div className="flex flex-wrap items-center gap-2 border-t bg-muted/20 px-2 py-1 sm:gap-3 sm:px-3 sm:py-1.5">
<span className="text-[10px] font-medium text-muted-foreground sm:text-xs">
:
</span>
{statusOptions.map((status) => (
<div key={status.value} className="flex items-center gap-1">
<div
className="h-2 w-4 rounded-sm sm:h-2.5 sm:w-5"
style={{
backgroundColor:
colors[status.value as keyof typeof colors] || status.color,
}}
/>
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
{status.label}
</span>
</div>
))}
{/* 마일스톤 범례 */}
<div className="flex items-center gap-1">
<div className="flex h-2.5 w-4 items-center justify-center sm:h-3 sm:w-5">
<div className="h-1.5 w-1.5 rotate-45 bg-foreground/60 sm:h-2 sm:w-2" />
</div>
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
</span>
</div>
{/* 충돌 범례 */}
{config.showConflicts && (
<div className="flex items-center gap-1">
<div className="h-2 w-4 rounded-sm ring-1.5 ring-destructive sm:h-2.5 sm:w-5" />
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
</span>
</div>
)}
</div>
);
}

View File

@ -1,3 +1,6 @@
export { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow";
export { TimelineLegend } from "./TimelineLegend";
export { ItemTimelineCard, groupSchedulesByItem } from "./ItemTimelineCard";
export { SchedulePreviewDialog } from "./SchedulePreviewDialog";

View File

@ -1,6 +1,6 @@
"use client";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types";
/**
*
@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [
{ value: "WORK_ASSIGN", label: "작업배정" },
];
/**
*
*/
export const viewModeOptions: { value: string; label: string; description: string }[] = [
{ value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" },
{ value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" },
];
/**
*
*/
export const dataSourceOptions: { value: string; label: string; description: string }[] = [
{ value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" },
{ value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" },
];
/**
*
*/
export const toolbarIconOptions: { value: string; label: string }[] = [
{ value: "Zap", label: "Zap (번개)" },
{ value: "Package", label: "Package (박스)" },
{ value: "Plus", label: "Plus (추가)" },
{ value: "Download", label: "Download (다운로드)" },
{ value: "Upload", label: "Upload (업로드)" },
{ value: "RefreshCw", label: "RefreshCw (새로고침)" },
{ value: "Play", label: "Play (재생)" },
{ value: "FileText", label: "FileText (문서)" },
{ value: "Send", label: "Send (전송)" },
{ value: "Sparkles", label: "Sparkles (반짝)" },
{ value: "Wand2", label: "Wand2 (마법봉)" },
];
/**
*
*/

View File

@ -128,6 +128,58 @@ export interface SourceDataConfig {
groupNameField?: string;
}
/**
* ( )
*
* preview -> confirm -> apply
*/
export interface ToolbarAction {
/** 고유 ID */
id: string;
/** 버튼 텍스트 */
label: string;
/** lucide-react 아이콘명 */
icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2";
/** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */
color?: string;
/** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */
previewApi: string;
/** 적용 API 엔드포인트 (예: "/production/generate-schedule") */
applyApi: string;
/** 다이얼로그 제목 */
dialogTitle?: string;
/** 다이얼로그 설명 */
dialogDescription?: string;
/**
*
* - linkedSelection: 연결 ( )
* - currentSchedules: 현재 ID
*/
dataSource: "linkedSelection" | "currentSchedules";
/** 페이로드 구성 설정 */
payloadConfig?: {
/** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */
groupByField?: string;
/** linkedSelection: 수량 합계 필드 (예: "balance_qty") */
quantityField?: string;
/** linkedSelection: 기준일 필드 (예: "due_date") */
dueDateField?: string;
/** linkedSelection: 표시명 필드 (예: "part_name") */
nameField?: string;
/** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */
scheduleFilterField?: string;
/** currentSchedules: 스케줄 필터 값 (예: "완제품") */
scheduleFilterValue?: string;
/** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */
extraOptions?: Record<string, any>;
};
/**
* 조건: staticFilters와
* : { "product_type": "완제품" } staticFilters.product_type === "완제품"
*/
showWhen?: Record<string, string>;
}
/**
*
*/
@ -225,6 +277,38 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
/** 최대 높이 */
maxHeight?: number | string;
/**
*
* - "resource": () ()
* - "itemGrouped": ( )
*/
viewMode?: "resource" | "itemGrouped";
/** 범례 표시 여부 */
showLegend?: boolean;
/**
* 설정: 다른
* ,
*/
linkedFilter?: {
/** 소스 컴포넌트 ID (선택 이벤트를 발생시키는 컴포넌트) */
sourceComponentId?: string;
/** 소스 테이블명 (이벤트의 tableName과 매칭) */
sourceTableName?: string;
/** 소스 필드 (선택된 행에서 추출할 필드) */
sourceField: string;
/** 타겟 필드 (타임라인 데이터에서 필터링할 필드) */
targetField: string;
/** 선택 없을 때 빈 상태 표시 여부 (기본: true) */
showEmptyWhenNoSelection?: boolean;
/** 빈 상태 메시지 */
emptyMessage?: string;
};
/** 툴바 커스텀 액션 버튼 설정 */
toolbarActions?: ToolbarAction[];
}
/**

View File

@ -0,0 +1,58 @@
"use client";
import { ScheduleItem } from "../types";
/**
*
* @returns ID Set
*/
export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
const conflictIds = new Set<string>();
// 리소스별로 그룹화
const byResource = new Map<string, ScheduleItem[]>();
for (const schedule of schedules) {
if (!byResource.has(schedule.resourceId)) {
byResource.set(schedule.resourceId, []);
}
byResource.get(schedule.resourceId)!.push(schedule);
}
// 리소스별 충돌 검사
for (const [, resourceSchedules] of byResource) {
if (resourceSchedules.length < 2) continue;
// 시작일 기준 정렬
const sorted = [...resourceSchedules].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
for (let i = 0; i < sorted.length; i++) {
const aEnd = new Date(sorted[i].endDate).getTime();
for (let j = i + 1; j < sorted.length; j++) {
const bStart = new Date(sorted[j].startDate).getTime();
// 정렬되어 있으므로 aStart <= bStart
// 겹치는 조건: aEnd > bStart
if (aEnd > bStart) {
conflictIds.add(sorted[i].id);
conflictIds.add(sorted[j].id);
} else {
break;
}
}
}
}
return conflictIds;
}
/**
*
*/
export function addDaysToDateString(dateStr: string, days: number): string {
const date = new Date(dateStr);
date.setDate(date.getDate() + days);
return date.toISOString().split("T")[0];
}

View File

@ -35,6 +35,7 @@
"@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.22",
"@tiptap/core": "^2.27.1",
"@tiptap/extension-placeholder": "^2.27.1",
"@tiptap/pm": "^2.27.1",
@ -3756,6 +3757,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.22",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.22.tgz",
"integrity": "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@ -3769,6 +3787,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.22",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.22.tgz",
"integrity": "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",

View File

@ -44,6 +44,7 @@
"@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.22",
"@tiptap/core": "^2.27.1",
"@tiptap/extension-placeholder": "^2.27.1",
"@tiptap/pm": "^2.27.1",

View File

@ -0,0 +1,165 @@
# Screen 149 필드 타입 검증 가이드
## 배경
- **화면 149**: 품목정보 (item_info 테이블) 폼
- **division 컬럼**: DB에서 `input_type = 'text'`로 변경했으나 화면에는 여전히 SELECT 드롭다운으로 표시
- **unit 컬럼**: `input_type = 'category'` → SELECT 드롭다운으로 표시되어야 함
## DB 현황 (vexplor-dev 조회 결과)
| column_name | company_code | input_type |
|-------------|--------------|------------|
| division | * | category |
| division | COMPANY_7 | **text** |
| division | COMPANY_8, 9, 10, 18, 19, 20, 21 | category |
| unit | * | text |
| unit | COMPANY_18, 19, 20, 21, 7, 8, 9 | **category** |
**주의:** `wace` 사용자는 `company_code = '*'` (최고 관리자)입니다.
- division: company * → **category** (text 아님)
- unit: company * → **text** (category 아님)
**회사별로 다릅니다.** 예: COMPANY_7의 division은 text, unit은 category.
---
## 수동 검증 절차
### 1. 로그인
- URL: `http://localhost:9771/login`
- User ID: `wace`
- Password: `wace0909!!`
- 회사: "탑씰" (해당 회사 코드 확인 필요)
### 2. 화면 149 접속
- URL: `http://localhost:9771/screens/149`
- 페이지 로드 대기
### 3. 필드 확인
#### 구분 (division)
- **예상 (DB 기준):**
- company *: SELECT (category)
- COMPANY_7: TEXT INPUT (text)
- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인
#### 단위 (unit)
- **예상 (DB 기준):**
- company *: TEXT INPUT (text)
- COMPANY_18~21, 7~9: SELECT (category)
- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인
### 4. 스크린샷
- 구분, 단위 필드가 함께 보이도록 캡처
---
## 코드 흐름 (input_type → 렌더링)
### 1. 컬럼 메타 로드
```
DynamicComponentRenderer
→ loadColumnMeta(screenTableName)
→ GET /api/table-management/tables/item_info/columns?size=1000
→ columnMetaCache[tableName][columnName] = { inputType, ... }
```
### 2. 렌더 타입 결정 (357~369행)
```javascript
const dbInputType = columnMetaCache[screenTableName]?.[baseCol]?.inputType;
const ft = dbInputType || componentConfig?.fieldType;
if (["text", "number", ...].includes(ft)) return "v2-input"; // 텍스트 입력
if (["select", "category", "entity"].includes(ft)) return "v2-select"; // 드롭다운
```
### 3. mergeColumnMeta (81~130행)
- DB `input_type`이 화면 저장값보다 우선
- `needsSync`이면 DB 값으로 덮어씀
---
## 캐시 관련
### 1. 프론트엔드 (DynamicComponentRenderer)
- `columnMetaCache`: TTL 5초
- `table-columns-refresh` 이벤트 시 즉시 무효화 및 재로드
### 2. 백엔드 (tableManagementService)
- 컬럼 목록: 5분 TTL
- `updateColumnInputType` 호출 시 해당 테이블 캐시 삭제
### 3. 캐시 무효화가 필요한 경우
- 데이터 타입 관리에서 변경 후 화면이 갱신되지 않을 때
- **대응:** 페이지 새로고침 또는 `?_t=timestamp`로 API 재요청
---
## 가능한 원인
### 1. 회사 코드 불일치
- 로그인한 사용자 회사와 DB의 `company_code`가 다를 수 있음
- `wace``company_code = '*'` → division은 category, unit은 text
### 2. 화면 레이아웃에 저장된 값
- `componentConfig.fieldType`이 있으면 DB보다 우선될 수 있음
- 코드상으로는 `dbInputType`이 우선이므로, DB가 제대로 로드되면 덮어씀
### 3. 캐시
- 백엔드 5분, 프론트 5초
- 데이터 타입 변경 후 곧바로 화면을 열면 이전 캐시가 사용될 수 있음
### 4. API 응답 구조
- `columnMetaCache`에 넣을 때 `col.column_name || col.columnName` 사용
- `mergeColumnMeta``meta.input_type || meta.inputType` 사용
- 백엔드는 `inputType`(camelCase) 반환 → `columnMetaCache``inputType` 유지
---
## 디버깅용 Console 스크립트
화면 149 로드 후 브라우저 Console에서 실행:
```javascript
// 1. columnMetaCache 조회 (DynamicComponentRenderer 내부)
// React DevTools로 DynamicComponentRenderer 선택 후
// 또는 전역에 노출해 둔 경우:
const meta = window.__COLUMN_META_CACHE__?.item_info;
if (meta) {
console.log("division:", meta.division?.inputType || meta.division?.input_type);
console.log("unit:", meta.unit?.inputType || meta.unit?.input_type);
}
// 2. API 직접 호출
fetch("/api/table-management/tables/item_info/columns?size=1000", {
credentials: "include"
})
.then(r => r.json())
.then(d => {
const cols = d.data?.columns || d.columns || [];
const div = cols.find(c => (c.columnName || c.column_name) === "division");
const unit = cols.find(c => (c.columnName || c.column_name) === "unit");
console.log("API division:", div?.inputType || div?.input_type);
console.log("API unit:", unit?.inputType || unit?.input_type);
});
```
---
## 권장 사항
1. **회사 코드 확인**
- 로그인한 사용자의 `company_code` 확인
- `division`/`unit`을 text/category로 바꾼 회사가 맞는지 확인
2. **캐시 우회**
- 데이터 타입 변경 후 페이지 새로고침
- 또는 5초 이상 대기 후 다시 접속
3. **데이터 타입 관리에서 변경 시**
- 저장 후 `table-columns-refresh` 이벤트 발생 여부 확인
- 화면 디자이너의 V2FieldConfigPanel에서 변경 시에는 이벤트가 발생함
4. **테이블 관리 UI에서 변경 시**
- `table-columns-refresh` 이벤트가 발생하는지 확인
- 없으면 해당 화면에서 수동으로 `window.dispatchEvent(new CustomEvent("table-columns-refresh"))` 호출 후 재검증