Compare commits

..

53 Commits

Author SHA1 Message Date
kjs b70fe40621 Merge pull request 'jskim-node' (#418) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/418
2026-03-16 14:53:13 +09:00
kjs ec3cb8155f Merge branch 'ycshin-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 14:51:51 +09:00
kjs 1a319d1785 feat: enhance V2TimelineSchedulerConfigPanel with filter and view mode options
- Added new filter and linking settings section to the V2TimelineSchedulerConfigPanel, allowing users to manage static filters and linked filters more effectively.
- Introduced view mode options to switch between different display modes in the timeline scheduler.
- Updated the configuration types and added new toolbar action settings to support custom actions in the timeline toolbar.
- Enhanced the overall user experience by providing more flexible filtering and display options.

These updates aim to improve the functionality and usability of the timeline scheduler within the ERP system, enabling better data management and visualization.

Made-with: Cursor
2026-03-16 14:51:34 +09:00
kjs 64c9f25f63 feat: add schedule preview functionality for production plans
- Implemented previewSchedule and previewSemiSchedule functions in the production controller to allow users to preview schedule changes without making actual database modifications.
- Added corresponding routes for schedule preview in productionRoutes.
- Enhanced productionPlanService with logic to generate schedule previews based on provided items and plan IDs.
- Introduced SchedulePreviewDialog component to display the preview results in the frontend, including summary and detailed views of planned schedules.

These updates improve the user experience by providing a way to visualize scheduling changes before applying them, ensuring better planning and decision-making.

Made-with: Cursor
2026-03-16 14:00:07 +09:00
syc0123 7e02fff717 fix: update default button size in V2ButtonPrimary component
- Changed the default width of the V2ButtonPrimary component from 140 to 100 pixels to improve UI consistency and responsiveness.
- This adjustment aligns the button size with design specifications for better user experience.
2026-03-16 11:28:03 +09:00
syc0123 a5890bbd67 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-16 10:45:00 +09:00
kjs 5cdbd2446b Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 10:40:11 +09:00
kjs 6505df8555 feat: enhance v2-timeline-scheduler component functionality
- Updated the v2-timeline-scheduler documentation to reflect the latest implementation status and enhancements.
- Improved the TimelineSchedulerComponent by integrating conflict detection and milestone rendering features.
- Refactored ResourceRow and ScheduleBar components to support new props for handling conflicts and milestones.
- Added visual indicators for conflicts and milestones to enhance user experience and clarity in scheduling.

These changes aim to improve the functionality and usability of the timeline scheduler within the ERP system.

Made-with: Cursor
2026-03-16 10:40:10 +09:00
kjs d3e62912e7 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 10:39:52 +09:00
syc0123 3225a7bb21 feat: add bulk update script for COMPANY_7 button styles
- Introduced a new script `btn-bulk-update-company7.ts` to facilitate bulk updates of button styles for COMPANY_7.
- The script includes functionalities for testing, running updates, creating backups, and restoring from backups.
- Implemented logic to dynamically apply button styles based on action types, ensuring consistent UI across the application.
- Updated documentation to reflect changes in button icon mapping and dynamic loading of icons.

This addition enhances the maintainability and consistency of button styles for COMPANY_7, streamlining the update process.
2026-03-16 10:38:12 +09:00
DDD1542 6395f4d032 Implement Card Pulse Animation and UI Enhancements
- Added a new pulse animation for screen cards to enhance visual feedback.
- Updated the background of the screen management list for improved aesthetics.
- Refined the search input styling for better integration with the overall UI.
- Enhanced screen card hover effects with dynamic glow based on screen type.
- Adjusted layout spacing for a more consistent user experience.

Made-with: Cursor
2026-03-16 09:17:59 +09:00
DDD1542 cbd47184e7 Enhance Screen Management UI
- Updated the search input to include a clear button for easier user interaction.
- Improved the layout with a muted background for better visibility.
- Enhanced screen card display with dynamic type color and glow effects based on screen type.
- Adjusted text colors for better contrast and readability in dark mode.
- Refined connection indicators and button styles for improved UX.

Made-with: Cursor
2026-03-16 09:17:52 +09:00
DDD1542 fe3c6d3bce [agent-pipeline] rollback to 232650bc 2026-03-15 22:29:56 +09:00
DDD1542 015706b95a [agent-pipeline] pipe-20260315131310-l8kw round-2 2026-03-15 22:29:56 +09:00
DDD1542 232650bc07 [agent-pipeline] pipe-20260315131310-l8kw round-1 2026-03-15 22:19:35 +09:00
DDD1542 8ed7faf517 [agent-pipeline] pipe-20260315121506-3c5c round-2 2026-03-15 21:22:36 +09:00
DDD1542 009607f3f1 [agent-pipeline] pipe-20260315121506-3c5c round-1 2026-03-15 21:18:08 +09:00
DDD1542 3ef8cebf1a [agent-pipeline] pipe-20260315110231-zn60 round-2 2026-03-15 20:14:51 +09:00
DDD1542 558acd1f9b [agent-pipeline] pipe-20260315110231-zn60 round-1 2026-03-15 20:09:41 +09:00
DDD1542 c0be2f3528 feat: 접는 사이드바 구현 (v5 파이프라인 후속)
- sidebarCollapsed 상태 + 조건부 렌더링
- PanelLeftOpen/PanelLeftClose 아이콘 토글
- collapsed 시 아이콘 컬럼 표시

Made-with: Cursor
2026-03-15 19:57:17 +09:00
DDD1542 beb95bf2aa [agent-pipeline] pipe-20260315091327-kxyf round-4 2026-03-15 18:57:12 +09:00
DDD1542 2cb736dac1 [agent-pipeline] pipe-20260315091327-kxyf round-3 2026-03-15 18:46:28 +09:00
DDD1542 ffc7cb7933 [agent-pipeline] rollback to 784dc73a 2026-03-15 18:31:12 +09:00
DDD1542 d542e92021 [agent-pipeline] pipe-20260315091327-kxyf round-2 2026-03-15 18:31:12 +09:00
DDD1542 784dc73abf [agent-pipeline] pipe-20260315091327-kxyf round-1 2026-03-15 18:22:20 +09:00
DDD1542 27ce039fc8 [agent-pipeline] pipe-20260315080636-1tpd round-4 2026-03-15 17:22:24 +09:00
DDD1542 4c19d3a6eb [agent-pipeline] pipe-20260315080636-1tpd round-3 2026-03-15 17:18:18 +09:00
DDD1542 94a95b7dc1 [agent-pipeline] pipe-20260315080636-1tpd round-2 2026-03-15 17:13:37 +09:00
DDD1542 f711506671 [agent-pipeline] pipe-20260315080636-1tpd round-1 2026-03-15 17:10:04 +09:00
DDD1542 24b53b5b33 [agent-pipeline] pipe-20260315072335-zb1m round-5 2026-03-15 16:42:20 +09:00
DDD1542 bf509171db [agent-pipeline] pipe-20260315072335-zb1m round-4 2026-03-15 16:38:00 +09:00
DDD1542 0ac9db45a0 [agent-pipeline] pipe-20260315072335-zb1m round-3 2026-03-15 16:32:53 +09:00
DDD1542 bafc81b2a3 [agent-pipeline] pipe-20260315072335-zb1m round-2 2026-03-15 16:30:14 +09:00
DDD1542 21ca0f3a3c [agent-pipeline] pipe-20260315072335-zb1m round-1 2026-03-15 16:27:14 +09:00
DDD1542 265d79cc5a [agent-pipeline] pipe-20260315065015-rei8 round-5 2026-03-15 16:07:43 +09:00
DDD1542 bad3a002f3 [agent-pipeline] rollback to b1afe1bc 2026-03-15 16:05:07 +09:00
DDD1542 0db57fe01a [agent-pipeline] pipe-20260315065015-rei8 round-4 2026-03-15 16:05:06 +09:00
DDD1542 b1afe1bc8d [agent-pipeline] pipe-20260315065015-rei8 round-3 2026-03-15 16:01:43 +09:00
DDD1542 c4db3fbfd4 [agent-pipeline] pipe-20260315065015-rei8 round-2 2026-03-15 15:58:02 +09:00
DDD1542 015cd2c3ed [agent-pipeline] pipe-20260315065015-rei8 round-1 2026-03-15 15:54:04 +09:00
DDD1542 ea6aa6921c [agent-pipeline] rollback to f375252d 2026-03-15 15:36:53 +09:00
DDD1542 e963129e63 [agent-pipeline] pipe-20260315061036-2tnn round-6 2026-03-15 15:36:53 +09:00
DDD1542 f375252db1 [agent-pipeline] pipe-20260315061036-2tnn round-5 2026-03-15 15:33:19 +09:00
DDD1542 542663e9e6 [agent-pipeline] rollback to 501325e4 2026-03-15 15:30:47 +09:00
DDD1542 245580117e [agent-pipeline] pipe-20260315061036-2tnn round-4 2026-03-15 15:30:47 +09:00
DDD1542 501325e4b4 [agent-pipeline] pipe-20260315061036-2tnn round-3 2026-03-15 15:27:14 +09:00
DDD1542 92cd070749 [agent-pipeline] pipe-20260315061036-2tnn round-2 2026-03-15 15:22:16 +09:00
DDD1542 c3a43179e3 refactor: update color schemes and improve component styling
- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application.
- Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity.
- Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience.
- Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance.

These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
2026-03-15 15:15:44 +09:00
DDD1542 b8f5d4be4c Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into refactor/config-panel-redesign
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-03-13 17:46:06 +09:00
DDD1542 8ca1890fc0 .. 2026-03-13 17:45:12 +09:00
syc0123 1a11b08487 feat: implement real-time numbering preview with manual input handling
- Enhanced the `previewCode` endpoint to accept a new `manualInputValue` parameter, allowing for dynamic sequence generation based on user input.
- Updated the `NumberingRuleService` to skip legacy sequence lookups when manual input is not provided, ensuring accurate initial sequence display.
- Integrated debounce functionality in the `V2Input` component to optimize API calls for real-time suffix updates as users type.
- Refactored category resolution logic into a helper function to reduce code duplication and improve maintainability.

These changes significantly improve the user experience by providing immediate feedback on numbering sequences based on manual inputs.
2026-03-12 16:07:13 +09:00
syc0123 93c6c45ce8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-12 11:34:00 +09:00
syc0123 8b7e31031d refactor: Improve numbering rule service for manual prefix handling and sequence allocation
- Modified the `buildPrefixKey` function to include an optional `manualValues` parameter, allowing manual input values to be incorporated into the prefix key.
- Adjusted the sequence allocation process in `allocateCode` to extract manual values before building the prefix key, ensuring accurate prefix generation.
- Removed the fallback to the "BULK1" value in manual configurations, preventing unintended overwrites and ensuring user input is prioritized.
- Enhanced the `joinPartsWithSeparators` function to prevent consecutive separators when handling empty parts, improving the output format.
- Added a new migration script to clean up existing "BULK1" values from the database, ensuring data integrity.

These changes address several issues related to manual input handling and improve the overall functionality of the numbering rule service.
2026-03-12 10:12:56 +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"))` 호출 후 재검증