Compare commits

...

17 Commits

Author SHA1 Message Date
kjs 228bdeb264 Merge pull request 'jskim-node' (#420) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/420
2026-03-17 21:09:09 +09:00
kjs c06b4ff9eb Merge branch 'main' into jskim-node 2026-03-17 21:08:58 +09:00
kjs 2cf5c8de32 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 21:08:07 +09:00
kjs a3e6afa93e feat: enhance V2ButtonConfigPanel to support dynamic column mapping
- Implemented logic to load reference table columns based on selected target table, improving the configurability of button mappings.
- Added error handling for API calls to ensure robustness when fetching column data.
- Updated dependencies in the useEffect hook to ensure proper reactivity when available tables change.

These enhancements aim to provide users with a more flexible and dynamic configuration experience in the V2ButtonConfigPanel, allowing for better management of button mappings based on table relationships.
2026-03-17 21:05:59 +09:00
kjs 5e6261f51a feat: enhance V2 process work standard configuration panel
- Introduced a new TableCombobox component for selecting tables, improving user experience by allowing table searches and selections.
- Added a ColumnCombobox component to facilitate column selection based on the chosen table, enhancing the configurability of the process work standard settings.
- Updated the V2ProcessWorkStandardConfigPanel to utilize the new combobox components, streamlining the configuration process for item tables and columns.
- Removed the deprecated mcp.json file and updated .gitignore to reflect recent changes.

These enhancements aim to improve the usability and flexibility of the configuration panel, making it easier for users to manage their process work standards.
2026-03-17 18:19:08 +09:00
kjs ae4fe7a66e chore: update .gitignore and remove quick insert options from button configurations
- Added new entries to .gitignore for multi-agent MCP task queue and related rules.
- Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations.
- Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability.

These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
2026-03-17 17:37:40 +09:00
kjs 2976cad0a5 feat: enhance item routing modal configuration
- Added new properties for configuring the item addition modal's maximum width and list height in the ItemRoutingConfig interface.
- Updated the ColumnEditor component to include additional content for modal size settings, allowing users to specify width and height in pixels.
- Adjusted the ItemRoutingComponent to apply the new configuration settings for the modal's dimensions, improving the user experience by providing more control over modal appearance.

These changes aim to enhance the flexibility and usability of the item routing modal within the application.
2026-03-17 16:54:56 +09:00
kjs 6d0c52e17a refactor: remove component count display in TabsWidget
- Removed the display of the component count next to the tab labels in the TabsWidget.
- This change simplifies the tab interface by eliminating unnecessary information, enhancing the overall user experience.

These updates aim to streamline the visual presentation of the TabsWidget component.
2026-03-17 16:37:04 +09:00
kmh ab98c655b5 feat: enhance EditModal and input components for improved layout and functionality
- Updated EditModal to prioritize screen resolution settings from layout data, ensuring accurate dimension calculations.
- Refined modal styling for better responsiveness and consistency with ScreenModal.
- Adjusted V2Input and V2Select components to remove unnecessary gap styling, streamlining their layout.
- Enhanced DynamicComponentRenderer to handle horizontal labels more effectively with an external wrapper.

These changes aim to improve the user experience and visual consistency across the application.
2026-03-17 16:25:42 +09:00
kjs 764e0ae568 style: adjust panel and text sizes in Table Management and Column Detail components
- Increased the width of the left panel in the Table Management page from 240px to 280px for better visibility.
- Updated text sizes in the Table Management page for table names and descriptions to enhance readability.
- Expanded the width of the right panel in the Table Management page from 320px to 380px to accommodate additional content.
- Adjusted text sizes in the Column Detail Panel for improved clarity and consistency.

These changes aim to enhance the user interface and improve the overall user experience in the table management section.
2026-03-17 16:20:07 +09:00
kjs 9d7ec613db fix: update default visibility settings for buttons in V2SplitPanelLayoutConfigPanel
- Changed default state for the search and add buttons in both left and right panels to false.
- Updated the default state for the edit button in both panels to true.
- Updated the default state for the delete button in both panels to true.

These adjustments aim to improve the initial configuration experience for users by setting more appropriate defaults for button visibility.
2026-03-17 14:31:45 +09:00
kjs f612aff6fd Merge branch 'barcode' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 11:36:33 +09:00
kjs acc304ccd6 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 11:36:15 +09:00
kjs 837e0aca41 refactor: update dynamic form service to utilize V2 layout structure
- Replaced the previous screen layout query with a new query to fetch V2 layout data, ensuring compatibility with the updated layout structure.
- Enhanced component extraction logic to handle button components from the V2 layout, improving the control management process.
- Updated logging to provide clearer insights during component checks and control management execution.

These changes aim to streamline the dynamic form service by leveraging the new V2 layout capabilities, enhancing overall functionality and maintainability.
2026-03-17 11:34:15 +09:00
kjs e38d0a6992 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 11:31:56 +09:00
kjs 7c96461f59 feat: enhance audit log functionality and file upload components
- Updated the audit log controller to determine super admin status based on user type instead of company code.
- Added detailed logging for column settings updates and batch updates in the table management controller, capturing user actions and changes made.
- Implemented security measures in the audit log service to mask sensitive data for non-super admin users.
- Introduced a new TableCellFile component to handle file attachments, supporting both objid and JSON array formats for file information.
- Enhanced the file upload component to manage file states more effectively during record changes and mode transitions.

These updates aim to improve the audit logging capabilities and file management features within the ERP system, ensuring better security and user experience.
2026-03-17 11:31:54 +09:00
chpark cab2a3c01a 윈도우용 실행파일 커밋 2026-03-05 22:34:03 +09:00
64 changed files with 1445 additions and 559 deletions

View File

@ -1,8 +0,0 @@
{
"mcpServers": {
"Framelink Figma MCP": {
"command": "npx",
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
}
}
}

9
.gitignore vendored
View File

@ -182,3 +182,12 @@ scripts/browser-test-*.js
# 개인 작업 문서
popdocs/
.cursor/rules/popdocs-safety.mdc
# 멀티 에이전트 MCP 태스크 큐
mcp-task-queue/
.cursor/mcp.json
.cursor/rules/multi-agent-pm.mdc
.cursor/rules/multi-agent-worker.mdc
.cursor/rules/multi-agent-tester.mdc
.cursor/rules/multi-agent-reviewer.mdc
.cursor/rules/multi-agent-knowledge.mdc

View File

@ -1,4 +1,5 @@
import "dotenv/config";
process.env.TZ = "Asia/Seoul";
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
import express from "express";
import cors from "cors";

View File

@ -10,7 +10,7 @@ export const getAuditLogs = async (
): Promise<void> => {
try {
const userCompanyCode = req.user?.companyCode;
const isSuperAdmin = userCompanyCode === "*";
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const {
companyCode,
@ -63,7 +63,7 @@ export const getAuditLogStats = async (
): Promise<void> => {
try {
const userCompanyCode = req.user?.companyCode;
const isSuperAdmin = userCompanyCode === "*";
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const { companyCode, days } = req.query;
const targetCompany = isSuperAdmin
@ -91,7 +91,7 @@ export const getAuditLogUsers = async (
): Promise<void> => {
try {
const userCompanyCode = req.user?.companyCode;
const isSuperAdmin = userCompanyCode === "*";
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const { companyCode } = req.query;
const conditions: string[] = ["LOWER(u.status) = 'active'"];

View File

@ -224,6 +224,31 @@ export async function updateColumnSettings(
`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}`
);
auditLogService.log({
companyCode: companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "UPDATE",
resourceType: "TABLE",
resourceId: `${tableName}.${columnName}`,
resourceName: settings.columnLabel || columnName,
tableName: "table_type_columns",
summary: `테이블 타입관리: ${tableName}.${columnName} 컬럼 설정 변경`,
changes: {
after: {
columnLabel: settings.columnLabel,
inputType: settings.inputType,
referenceTable: settings.referenceTable,
referenceColumn: settings.referenceColumn,
displayColumn: settings.displayColumn,
codeCategory: settings.codeCategory,
},
fields: ["columnLabel", "inputType", "referenceTable", "referenceColumn", "displayColumn", "codeCategory"],
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
const response: ApiResponse<null> = {
success: true,
message: "컬럼 설정을 성공적으로 저장했습니다.",
@ -339,6 +364,29 @@ export async function updateAllColumnSettings(
`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
);
const changedColumns = columnSettings
.filter((c) => c.columnName)
.map((c) => c.columnName)
.join(", ");
auditLogService.log({
companyCode: companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "BATCH_UPDATE",
resourceType: "TABLE",
resourceId: tableName,
resourceName: tableName,
tableName: "table_type_columns",
summary: `테이블 타입관리: ${tableName} 전체 컬럼 설정 일괄 변경 (${columnSettings.length}개)`,
changes: {
after: { columns: changedColumns, count: columnSettings.length },
fields: columnSettings.filter((c) => c.columnName).map((c) => c.columnName!),
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
const response: ApiResponse<null> = {
success: true,
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",

View File

@ -66,8 +66,9 @@ export const initializePool = (): Pool => {
// 연결 풀 이벤트 핸들러
pool.on("connect", (client) => {
client.query("SET timezone = 'Asia/Seoul'");
if (config.debug) {
console.log("✅ PostgreSQL 클라이언트 연결 생성");
console.log("✅ PostgreSQL 클라이언트 연결 생성 (timezone: Asia/Seoul)");
}
});

View File

@ -251,6 +251,28 @@ class AuditLogService {
[...params, limit, offset]
);
const SECURITY_MASK = "(보안 항목 - 값 비공개)";
const securedTables = ["table_type_columns"];
if (!isSuperAdmin) {
for (const entry of data) {
if (entry.table_name && securedTables.includes(entry.table_name) && entry.changes) {
const changes = typeof entry.changes === "string" ? JSON.parse(entry.changes) : entry.changes;
if (changes.before) {
for (const key of Object.keys(changes.before)) {
changes.before[key] = SECURITY_MASK;
}
}
if (changes.after) {
for (const key of Object.keys(changes.after)) {
changes.after[key] = SECURITY_MASK;
}
}
entry.changes = changes;
}
}
}
return { data, total };
}

View File

@ -1707,71 +1707,66 @@ export class DynamicFormService {
try {
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
// 화면의 저장 버튼에서 제어관리 설정 조회
const screenLayouts = await query<{
component_id: string;
properties: any;
// V2 레이아웃에서 layout_data jsonb 조회
const v2Layouts = await query<{
layout_id: number;
layout_data: any;
}>(
`SELECT component_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type IN ('component', 'v2-button-primary')`,
[screenId]
`SELECT layout_id, layout_data
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode]
);
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
if (v2Layouts.length === 0) {
console.log(` V2 레이아웃이 없습니다. (화면 ID: ${screenId}, company: ${companyCode})`);
return;
}
// layout_data.components 배열에서 버튼 컴포넌트 추출
const layoutData = v2Layouts[0].layout_data;
const components: any[] = layoutData?.components || [];
console.log(`📋 V2 컴포넌트 조회 결과: ${components.length}`);
// 저장 버튼 중에서 제어관리가 활성화된 것 찾기
let controlConfigFound = false;
for (const layout of screenLayouts) {
const properties = layout.properties as any;
for (const comp of components) {
const overrides = comp?.overrides || {};
// 디버깅: 모든 컴포넌트 정보 출력
console.log(`🔍 컴포넌트 검사:`, {
componentId: layout.component_id,
componentType: properties?.componentType,
actionType: properties?.componentConfig?.action?.type,
enableDataflowControl:
properties?.webTypeConfig?.enableDataflowControl,
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
hasDiagramId:
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
hasFlowControls:
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
});
const isButtonComponent =
overrides?.type === "v2-button-primary" ||
(comp?.url || "").includes("v2-button-primary");
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
const buttonActionType = properties?.componentConfig?.action?.type;
const buttonActionType = overrides?.action?.type;
const isMatchingAction =
(triggerType === "delete" && buttonActionType === "delete") ||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
const isButtonComponent =
properties?.componentType === "button-primary" ||
properties?.componentType === "v2-button-primary";
console.log(`🔍 V2 컴포넌트 검사:`, {
componentId: comp?.id,
type: overrides?.type,
actionType: buttonActionType,
enableDataflowControl: overrides?.enableDataflowControl,
hasDataflowConfig: !!overrides?.dataflowConfig,
});
if (
isButtonComponent &&
isMatchingAction &&
properties?.webTypeConfig?.enableDataflowControl === true
overrides?.enableDataflowControl === true
) {
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
// 다중 제어 설정 확인 (flowControls 배열)
const dataflowConfig = overrides?.dataflowConfig;
const flowControls = dataflowConfig?.flowControls || [];
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
if (flowControls.length > 0) {
controlConfigFound = true;
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}`);
// 순서대로 정렬
const sortedControls = [...flowControls].sort(
(a: any, b: any) => (a.order || 0) - (b.order || 0)
);
// 다중 제어 순차 실행
await this.executeMultipleFlowControls(
sortedControls,
savedData,
@ -1782,13 +1777,12 @@ export class DynamicFormService {
companyCode
);
} else if (dataflowConfig?.selectedDiagramId) {
// 기존 단일 제어 실행 (하위 호환성)
controlConfigFound = true;
const diagramId = dataflowConfig.selectedDiagramId;
const relationshipId = dataflowConfig.selectedRelationshipId;
console.log(`🎯 단일 제어관리 설정 발견:`, {
componentId: layout.component_id,
componentId: comp?.id,
diagramId,
relationshipId,
triggerType,
@ -1806,7 +1800,6 @@ export class DynamicFormService {
);
}
// 첫 번째 설정된 버튼의 제어관리만 실행
break;
}
}

View File

@ -1382,7 +1382,7 @@ export default function TableManagementPage() {
{/* 3패널 메인 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[240px] min-w-[240px] flex-shrink-0 flex-col border-r">
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
{/* 검색 */}
<div className="flex-shrink-0 p-3 pb-0">
<div className="relative">
@ -1482,13 +1482,13 @@ export default function TableManagementPage() {
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1">
<span className={cn(
"truncate text-[12px] leading-tight",
"truncate text-[16px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.displayName || table.tableName}
</span>
</div>
<div className="text-muted-foreground truncate font-mono text-[10px] leading-tight tracking-tight">
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
{table.tableName}
</div>
</div>
@ -1605,7 +1605,7 @@ export default function TableManagementPage() {
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[320px] min-w-[320px] flex-shrink-0 overflow-hidden">
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
<ColumnDetailPanel
column={columns.find((c) => c.columnName === selectedColumn) ?? null}
tables={tables}

View File

@ -82,7 +82,8 @@ export function DashboardTopMenu({
) => {
if (format === "png") {
const link = document.createElement("a");
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
const _fd = new Date();
const filename = `${dashboardTitle || "dashboard"}_${_fd.getFullYear()}-${String(_fd.getMonth() + 1).padStart(2, "0")}-${String(_fd.getDate()).padStart(2, "0")}.png`;
link.download = filename;
link.href = dataUrl;
document.body.appendChild(link);
@ -111,7 +112,8 @@ export function DashboardTopMenu({
});
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
const _pd = new Date();
const filename = `${dashboardTitle || "dashboard"}_${_pd.getFullYear()}-${String(_pd.getMonth() + 1).padStart(2, "0")}-${String(_pd.getDate()).padStart(2, "0")}.pdf`;
pdf.save(filename);
}
};

View File

@ -100,36 +100,37 @@ export function getQuickDateRange(range: "today" | "week" | "month" | "year"): {
} {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
switch (range) {
case "today":
return {
startDate: today.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
startDate: fmtDate(today),
endDate: fmtDate(today),
};
case "week": {
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay()); // 일요일부터
weekStart.setDate(today.getDate() - today.getDay());
return {
startDate: weekStart.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
startDate: fmtDate(weekStart),
endDate: fmtDate(today),
};
}
case "month": {
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return {
startDate: monthStart.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
startDate: fmtDate(monthStart),
endDate: fmtDate(today),
};
}
case "year": {
const yearStart = new Date(today.getFullYear(), 0, 1);
return {
startDate: yearStart.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
startDate: fmtDate(yearStart),
endDate: fmtDate(today),
};
}

View File

@ -126,12 +126,12 @@ export function ColumnDetailPanel({
{conf.iconChar}
</span>
<span className={cn(
"text-[11px] font-semibold leading-tight",
"text-[16px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground",
)}>
{conf.label}
</span>
<span className="text-[9px] leading-tight text-muted-foreground">
<span className="text-[12px] leading-tight text-muted-foreground">
{conf.desc}
</span>
</button>

View File

@ -66,6 +66,8 @@ export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
};
/** 컬럼 그룹 판별 */

View File

@ -217,7 +217,8 @@ export function DashboardViewer({
if (format === "png") {
console.log("💾 PNG 다운로드 시작...");
const link = document.createElement("a");
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
const _dvd = new Date();
const filename = `${dashboardTitle || "dashboard"}_${_dvd.getFullYear()}-${String(_dvd.getMonth() + 1).padStart(2, "0")}-${String(_dvd.getDate()).padStart(2, "0")}.png`;
link.download = filename;
link.href = dataUrl;
document.body.appendChild(link);
@ -253,7 +254,8 @@ export function DashboardViewer({
});
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
const _dvp = new Date();
const filename = `${dashboardTitle || "dashboard"}_${_dvp.getFullYear()}-${String(_dvp.getMonth() + 1).padStart(2, "0")}-${String(_dvp.getDate()).padStart(2, "0")}.pdf`;
pdf.save(filename);
console.log("✅ PDF 다운로드 완료:", filename);
}

View File

@ -61,7 +61,8 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
// 데이터 처리
if (result.success && result.data?.rows) {
const rows = result.data.rows;
const today = new Date().toISOString().split("T")[0];
const _td = new Date();
const today = `${_td.getFullYear()}-${String(_td.getMonth() + 1).padStart(2, "0")}-${String(_td.getDate()).padStart(2, "0")}`;
// 오늘 발송 건수 (created_at 기준)
const shippedToday = rows.filter((row: any) => {

View File

@ -101,7 +101,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
const [routeDate, setRouteDate] = useState<string>(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; });
// 공차/운행 정보 상태
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});

View File

@ -1120,7 +1120,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
const timestamp = new Date().toISOString().slice(0, 10);
const _rpd = new Date();
const timestamp = `${_rpd.getFullYear()}-${String(_rpd.getMonth() + 1).padStart(2, "0")}-${String(_rpd.getDate()).padStart(2, "0")}`;
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;

View File

@ -563,8 +563,20 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면의 실제 크기 계산
const dimensions = calculateScreenDimensions(components);
// 화면 관리에서 설정한 해상도 우선 사용 (ScreenModal과 동일)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) {
dimensions = {
width: screenResolution.width,
height: screenResolution.height,
offsetX: 0,
offsetY: 0,
};
} else {
dimensions = calculateScreenDimensions(components);
}
setScreenDimensions(dimensions);
setScreenData({
@ -1547,31 +1559,25 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
};
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더
// 모달 크기 설정 - ScreenModal과 동일한 방식 (maxHeight로 유연 처리)
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
};
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
const dialogGap = 16; // DialogContent gap-4
const extraPadding = 24; // 추가 여백 (안전 마진)
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
return {
className: "overflow-hidden p-0",
style: {
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
width: `${finalWidth}px`,
maxHeight: "calc(100dvh - 8px)",
maxWidth: "98vw",
maxHeight: "95vh",
padding: 0,
gap: 0,
},
};
};
@ -1593,7 +1599,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</DialogHeader>
<div className="[&::-webkit-scrollbar-thumb]:bg-muted/60 flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex-1 min-h-0 flex items-start justify-center overflow-auto">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -1608,42 +1614,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
>
<div
data-screen-runtime="true"
className="bg-card relative m-auto"
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
// 조건부 레이어가 활성화되면 높이 자동 확장
width: `${screenDimensions?.width || 800}px`,
minHeight: `${screenDimensions?.height || 600}px`,
height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30;
const baseHeight = screenDimensions?.height || 600;
if (activeConditionalComponents.length > 0) {
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp) => {
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY;
const h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
return `${Math.max(baseHeight, maxBottom + 20)}px`;
}
return baseHeight;
return `${baseHeight}px`;
})(),
transformOrigin: "center center",
maxWidth: "100%",
overflow: "visible",
}}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
// screenResolution이 있으면 offsetY=0이므로 디자이너 좌표 그대로 사용
// offsetY > 0 (자동 계산)일 때만 라벨 공간 보정
const labelSpace = offsetY > 0 ? 30 : 0;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
},
};
@ -1709,11 +1714,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{/* 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30;
const labelSpace = offsetY > 0 ? 30 : 0;
const adjustedComponent = {
...component,

View File

@ -86,13 +86,16 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
const generateAutoValue = useCallback(
async (autoValueType: string, ruleId?: string): Promise<string> => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " ");
return `${localDate} ${localTime}`;
case "current_date":
return now.toISOString().slice(0, 10);
return localDate;
case "current_time":
return now.toTimeString().slice(0, 8);
return localTime;
case "current_user":
return userName || "사용자";
case "uuid":

View File

@ -1155,13 +1155,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const generateAutoValue = useCallback(
(autoValueType: string): string => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss
return `${localDate} ${localTime}`;
case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD
return localDate;
case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss
return localTime;
case "current_user":
return currentUser?.userName || currentUser?.userId || "unknown_user";
case "uuid":

View File

@ -357,13 +357,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 자동값 생성 함수
const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise<string> => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
return `${localDate} ${localTime}`;
case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD
return localDate;
case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss
return localTime;
case "current_user":
// 실제 접속중인 사용자명 사용
return userName || "사용자"; // 사용자명이 없으면 기본값

View File

@ -183,13 +183,16 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const generateAutoValue = useCallback(
(autoValueType: string): string => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " ");
return `${localDate} ${localTime}`;
case "current_date":
return now.toISOString().slice(0, 10);
return localDate;
case "current_time":
return now.toTimeString().slice(0, 8);
return localTime;
case "current_user":
return userName || "사용자";
case "uuid":

View File

@ -3852,7 +3852,6 @@ function ControlManagementTab({
openModalWithData: "데이터+모달",
openRelatedModal: "연관모달",
transferData: "데이터전달",
quickInsert: "즉시저장",
control: "제어흐름",
view_table_history: "이력보기",
excel_download: "엑셀다운",

View File

@ -56,9 +56,11 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
// 현재 날짜 설정
const setCurrentDate = (field: "minDate" | "maxDate" | "defaultValue") => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const d = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const dateString = localConfig.showTime
? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm
: now.toISOString().slice(0, 10); // YYYY-MM-DD
? `${d}T${pad(now.getHours())}:${pad(now.getMinutes())}`
: d;
updateConfig(field, dateString);
};

View File

@ -263,7 +263,6 @@ export const BasicTab: React.FC<ButtonTabProps> = ({
</SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="approval"> </SelectItem>
@ -271,9 +270,6 @@ export const BasicTab: React.FC<ButtonTabProps> = ({
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="operation_control"> </SelectItem>
{/* 이벤트 버스 */}
<SelectItem value="event"> </SelectItem>
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>

View File

@ -1018,7 +1018,8 @@ export function FlowWidget({
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Data");
const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.xlsx`;
const _fxd = new Date();
const fileName = `${flowName || "flow"}_data_${_fxd.getFullYear()}-${String(_fxd.getMonth() + 1).padStart(2, "0")}-${String(_fxd.getDate()).padStart(2, "0")}.xlsx`;
XLSX.writeFile(wb, fileName);
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
@ -1183,7 +1184,8 @@ export function FlowWidget({
}
}
const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.pdf`;
const _fpd = new Date();
const fileName = `${flowName || "flow"}_data_${_fpd.getFullYear()}-${String(_fpd.getMonth() + 1).padStart(2, "0")}-${String(_fpd.getDate()).padStart(2, "0")}.pdf`;
doc.save(fileName);
toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" });

View File

@ -477,9 +477,6 @@ export function TabsWidget({
<div key={tab.id} className="relative">
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
{tab.label}
{tab.components && tab.components.length > 0 && (
<span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
)}
</TabsTrigger>
{allowCloseable && (
<Button

View File

@ -52,8 +52,10 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
const getDefaultValue = (): string => {
if (config?.defaultValue === "current") {
const now = new Date();
if (isDatetime) return now.toISOString().slice(0, 16);
return now.toISOString().slice(0, 10);
const pad = (n: number) => String(n).padStart(2, "0");
const d = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
if (isDatetime) return `${d}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
return d;
}
return "";
};

View File

@ -680,11 +680,15 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
const now = new Date();
switch (col.autoFill.type) {
case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD
case "currentDate": {
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
case "currentDateTime": {
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
}
case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번

View File

@ -89,12 +89,12 @@ function formatTel(value: string): string {
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
}
// 서울: 02 → 2-4-4
// 서울: 02 → 9자리 2-3-4, 10자리 2-4-4
if (digits.startsWith("02")) {
if (digits.length <= 2) return digits;
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
if (digits.length <= 5) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
const mid = digits.length >= 10 ? 4 : 3;
return `${digits.slice(0, 2)}-${digits.slice(2, 2 + mid)}-${digits.slice(2 + mid, 2 + mid + 4)}`;
}
// 안심번호: 050x → 4-4-4
@ -135,6 +135,14 @@ const TextInput = forwardRef<
const [hasBlurred, setHasBlurred] = useState(false);
const [validationError, setValidationError] = useState<string>("");
// 커서 위치 보존을 위한 내부 ref
const innerRef = useRef<HTMLInputElement>(null);
const combinedRef = (node: HTMLInputElement | null) => {
(innerRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
};
// 형식에 따른 값 포맷팅
const formatValue = useCallback(
(val: string): string => {
@ -154,11 +162,15 @@ const TextInput = forwardRef<
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
const cursorPos = input.selectionStart ?? 0;
let newValue = e.target.value;
const oldValue = input.value;
const needsCursorFix = format === "biz_no" || format === "tel" || format === "currency";
// 형식에 따른 자동 포맷팅
if (format === "currency") {
// 숫자와 쉼표만 허용
newValue = newValue.replace(/[^\d,]/g, "");
newValue = formatCurrency(newValue);
} else if (format === "biz_no") {
@ -167,6 +179,20 @@ const TextInput = forwardRef<
newValue = formatTel(newValue);
}
// 포맷팅 후 커서 위치 보정 (하이픈/쉼표 개수 차이 기반)
if (needsCursorFix) {
const separator = format === "currency" ? /,/g : /-/g;
const oldSeps = (oldValue.slice(0, cursorPos).match(separator) || []).length;
const newSeps = (newValue.slice(0, cursorPos).match(separator) || []).length;
const adjustedCursor = Math.min(cursorPos + (newSeps - oldSeps), newValue.length);
requestAnimationFrame(() => {
if (innerRef.current) {
innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor);
}
});
}
// 입력 중 에러 표시 해제 (입력 중에는 관대하게)
if (hasBlurred && validationError) {
const { isValid } = validateInputFormat(newValue, format);
@ -244,7 +270,7 @@ const TextInput = forwardRef<
return (
<div className="relative h-full w-full">
<Input
ref={ref}
ref={combinedRef}
type="text"
value={displayValue}
onChange={handleChange}
@ -1202,7 +1228,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
ref={ref}
id={id}
className={cn(
"flex gap-1",
"flex",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
)}
style={{

View File

@ -1041,12 +1041,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
switch (col.autoFill.type) {
case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD
return localDate;
case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
return `${localDate} ${localTime}`;
case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번

View File

@ -1291,7 +1291,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
ref={ref}
id={id}
className={cn(
"flex gap-1",
"flex",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
isDesignMode && "pointer-events-none",
)}

View File

@ -130,12 +130,6 @@ const ACTION_TYPE_CARDS = [
title: "엑셀 업로드",
description: "엑셀 파일을 올려요",
},
{
value: "quickInsert",
icon: Zap,
title: "즉시 저장",
description: "바로 저장해요",
},
{
value: "approval",
icon: Check,
@ -148,12 +142,6 @@ const ACTION_TYPE_CARDS = [
title: "제어 흐름",
description: "흐름을 제어해요",
},
{
value: "event",
icon: Send,
title: "이벤트 발송",
description: "이벤트를 보내요",
},
{
value: "copy",
icon: Copy,
@ -399,13 +387,51 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
if (targetTable) {
const cols = await loadTableColumns(targetTable);
try {
const fullResponse = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
let fullColumnData = fullResponse.data?.data;
if (!Array.isArray(fullColumnData) && fullColumnData?.columns) fullColumnData = fullColumnData.columns;
if (!Array.isArray(fullColumnData) && fullColumnData?.data) fullColumnData = fullColumnData.data;
if (Array.isArray(fullColumnData)) {
const refTableSet = new Set<string>();
fullColumnData.forEach((col: any) => {
const inputType = col.inputType || col.input_type;
if (inputType !== "entity") return;
let refTable = col.referenceTable || col.reference_table;
if (!refTable && col.detailSettings) {
try {
const ds = typeof col.detailSettings === "string" ? JSON.parse(col.detailSettings) : col.detailSettings;
refTable = ds?.referenceTable;
} catch { /* ignore */ }
}
if (refTable) refTableSet.add(refTable);
});
const targetColumnNames = new Set(cols.map((c) => c.name));
for (const refTable of refTableSet) {
const refCols = await loadTableColumns(refTable);
const refTableLabel = availableTables.find((t) => t.name === refTable)?.label || refTable;
refCols.forEach((rc) => {
if (!targetColumnNames.has(rc.name)) {
cols.push({
name: rc.name,
label: `${rc.label} [${refTableLabel}]`,
});
}
});
}
}
} catch { /* ignore */ }
setMappingTargetColumns(cols);
} else {
setMappingTargetColumns([]);
}
};
loadAll();
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]);
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
// 화면 목록 로드 (모달 액션용)
useEffect(() => {

View File

@ -180,12 +180,13 @@ function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: nu
}
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
function ColumnEditor({ columns, onChange, tableName, title, icon, extraContent }: {
columns: ColumnDef[];
onChange: (cols: ColumnDef[]) => void;
tableName: string;
title: string;
icon: React.ReactNode;
extraContent?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
@ -263,6 +264,7 @@ function ColumnEditor({ columns, onChange, tableName, title, icon }: {
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
<Plus className="h-3 w-3" />
</Button>
{extraContent}
</div>
</CollapsibleContent>
</Collapsible>
@ -378,13 +380,34 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
{/* ─── 품목 추가 모달 (컬럼 + 크기 설정) ─── */}
<ColumnEditor
columns={config.modalDisplayColumns || []}
onChange={(cols) => update({ modalDisplayColumns: cols })}
tableName={config.dataSource.itemTable}
title="품목 추가 모달 컬럼"
title="품목 추가 모달"
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
extraContent={
<div className="mt-3 space-y-2 border-t pt-3">
<span className="text-[10px] font-medium text-muted-foreground"> (px)</span>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"> ()</span>
<Input type="number" min={300} max={1600}
value={parseInt(config.addModalMaxWidth || "600")}
onChange={(e) => update({ addModalMaxWidth: `${e.target.value}px` })}
placeholder="600" className="h-7 text-xs" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"> ( )</span>
<Input type="number" min={150} max={900}
value={parseInt(config.addModalListMaxHeight || "340")}
onChange={(e) => update({ addModalListMaxHeight: `${e.target.value}px` })}
placeholder="340" className="h-7 text-xs" />
</div>
</div>
</div>
}
/>
{/* ─── 품목 필터 조건 ─── */}

View File

@ -1,17 +1,33 @@
"use client";
/**
* V2
* Progressive Disclosure: 작업 -> -> ()
* V2 ()
*/
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
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 { Badge } from "@/components/ui/badge";
import { Settings, ChevronDown, ChevronRight, Plus, Trash2, Database, Layers, List } from "lucide-react";
import {
Settings,
ChevronDown,
ChevronRight,
Plus,
Trash2,
Check,
ChevronsUpDown,
Database,
Layers,
List,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type {
ProcessWorkStandardConfig,
@ -20,26 +36,87 @@ import type {
} from "@/lib/registry/components/v2-process-work-standard/types";
import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config";
interface TableInfo { tableName: string; displayName?: string; }
function TableCombobox({ value, onChange, tables, loading, label }: {
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean; label: string;
}) {
const [open, setOpen] = useState(false);
const selected = tables.find((t) => t.tableName === value);
return (
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-[200px] justify-between text-xs" disabled={loading}>
<span className="truncate">{loading ? "로딩..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="end">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((t) => (
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && <span className="text-muted-foreground text-[10px]">{t.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
interface V2ProcessWorkStandardConfigPanelProps {
config: Partial<ProcessWorkStandardConfig>;
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
}
export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardConfigPanelProps> = ({
config: configProp,
onChange,
}) => {
export const V2ProcessWorkStandardConfigPanel: React.FC<
V2ProcessWorkStandardConfigPanelProps
> = ({ config: configProp, onChange }) => {
const [phasesOpen, setPhasesOpen] = useState(false);
const [detailTypesOpen, setDetailTypesOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [layoutOpen, setLayoutOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
}
} catch { /* ignore */ } finally { setLoadingTables(false); }
};
loadTables();
}, []);
const config: ProcessWorkStandardConfig = {
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases,
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
phases: configProp?.phases?.length
? configProp.phases
: defaultConfig.phases,
detailTypes: configProp?.detailTypes?.length
? configProp.detailTypes
: defaultConfig.detailTypes,
};
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
@ -50,13 +127,16 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
update({ dataSource: { ...config.dataSource, [field]: value } });
};
// ─── 작업 단계 관리 ───
const addPhase = () => {
const nextOrder = config.phases.length + 1;
update({
phases: [
...config.phases,
{ key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder },
{
key: `PHASE_${nextOrder}`,
label: `단계 ${nextOrder}`,
sortOrder: nextOrder,
},
],
});
};
@ -65,18 +145,24 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
update({ phases: config.phases.filter((_, i) => i !== idx) });
};
const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => {
const updatePhase = (
idx: number,
field: keyof WorkPhaseDefinition,
value: string | number,
) => {
const next = [...config.phases];
next[idx] = { ...next[idx], [field]: value };
update({ phases: next });
};
// ─── 상세 유형 관리 ───
const addDetailType = () => {
update({
detailTypes: [
...config.detailTypes,
{ value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" },
{
value: `TYPE_${config.detailTypes.length + 1}`,
label: "신규 유형",
},
],
});
};
@ -85,7 +171,11 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
};
const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => {
const updateDetailType = (
idx: number,
field: keyof DetailTypeDefinition,
value: string,
) => {
const next = [...config.detailTypes];
next[idx] = { ...next[idx], [field]: value };
update({ detailTypes: next });
@ -93,31 +183,75 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
return (
<div className="space-y-4">
{/* ─── 1단계: 작업 단계 설정 (Collapsible + 접이식 카드) ─── */}
{/* 품목 목록 모드 */}
<div className="space-y-2 rounded-lg border p-4">
<span className="text-sm font-medium"> </span>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className={cn(
"flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
(config.itemListMode || "all") === "all"
? "border-primary bg-primary/5 text-primary"
: "border-input hover:bg-muted/50",
)}
onClick={() => update({ itemListMode: "all" })}
>
<span className="font-medium"> </span>
<span className="text-muted-foreground text-[10px]">
</span>
</button>
<button
type="button"
className={cn(
"flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
config.itemListMode === "registered"
? "border-primary bg-primary/5 text-primary"
: "border-input hover:bg-muted/50",
)}
onClick={() => update({ itemListMode: "registered" })}
>
<span className="font-medium"> </span>
<span className="text-muted-foreground text-[10px]">
</span>
</button>
</div>
{config.itemListMode === "registered" && (
<p className="text-muted-foreground pt-1 text-[10px]">
.
</p>
)}
</div>
{/* 작업 단계 */}
<Collapsible open={phasesOpen} onOpenChange={setPhasesOpen}>
<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"
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<Layers className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
<Badge variant="secondary" className="h-5 text-[10px]">
{config.phases.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
"text-muted-foreground h-4 w-4 transition-transform duration-200",
phasesOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
<p className="text-[10px] text-muted-foreground mb-1"> (Phase) </p>
<div className="space-y-1.5 rounded-b-lg border border-t-0 p-3">
<p className="text-muted-foreground mb-1 text-[10px]">
</p>
<div className="space-y-1">
{config.phases.map((phase, idx) => (
<Collapsible key={idx}>
@ -125,18 +259,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
className="hover:bg-muted/30 flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors"
>
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{phase.label}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{phase.key}</Badge>
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-muted-foreground shrink-0 text-[10px] font-medium">
#{idx + 1}
</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{phase.label}
</span>
<Badge
variant="outline"
className="h-4 shrink-0 text-[9px]"
>
{phase.key}
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); removePhase(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
onClick={(e) => {
e.stopPropagation();
removePhase(idx);
}}
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
disabled={config.phases.length <= 1}
>
<Trash2 className="h-3 w-3" />
@ -146,32 +292,45 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleContent>
<div className="grid grid-cols-3 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-muted-foreground text-[10px]">
</span>
<Input
value={phase.key}
onChange={(e) => updatePhase(idx, "key", e.target.value)}
onChange={(e) =>
updatePhase(idx, "key", e.target.value)
}
className="h-7 text-xs"
placeholder="키"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-muted-foreground text-[10px]">
</span>
<Input
value={phase.label}
onChange={(e) => updatePhase(idx, "label", e.target.value)}
onChange={(e) =>
updatePhase(idx, "label", e.target.value)
}
className="h-7 text-xs"
placeholder="표시명"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-muted-foreground text-[10px]">
</span>
<Input
type="number"
min={1}
value={phase.sortOrder}
onChange={(e) => updatePhase(idx, "sortOrder", parseInt(e.target.value) || 1)}
className="h-7 text-xs text-center"
placeholder="1"
onChange={(e) =>
updatePhase(
idx,
"sortOrder",
parseInt(e.target.value) || 1,
)
}
className="h-7 text-center text-xs"
/>
</div>
</div>
@ -183,7 +342,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1 text-xs border-dashed"
className="h-7 w-full gap-1 border-dashed text-xs"
onClick={addPhase}
>
<Plus className="h-3 w-3" />
@ -193,31 +352,33 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 상세 유형 옵션 (Collapsible + 접이식 카드) ─── */}
{/* 상세 유형 */}
<Collapsible open={detailTypesOpen} onOpenChange={setDetailTypesOpen}>
<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"
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
>
<div className="flex items-center gap-2">
<List className="h-4 w-4 text-muted-foreground" />
<List className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
<Badge variant="secondary" className="h-5 text-[10px]">
{config.detailTypes.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
"text-muted-foreground h-4 w-4 transition-transform duration-200",
detailTypesOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
<p className="text-[10px] text-muted-foreground mb-1"> </p>
<div className="space-y-1.5 rounded-b-lg border border-t-0 p-3">
<p className="text-muted-foreground mb-1 text-[10px]">
</p>
<div className="space-y-1">
{config.detailTypes.map((dt, idx) => (
<Collapsible key={idx}>
@ -225,18 +386,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
className="hover:bg-muted/30 flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors"
>
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{dt.label}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{dt.value}</Badge>
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-muted-foreground shrink-0 text-[10px] font-medium">
#{idx + 1}
</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{dt.label}
</span>
<Badge
variant="outline"
className="h-4 shrink-0 text-[9px]"
>
{dt.value}
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); removeDetailType(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
onClick={(e) => {
e.stopPropagation();
removeDetailType(idx);
}}
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
disabled={config.detailTypes.length <= 1}
>
<Trash2 className="h-3 w-3" />
@ -246,21 +419,27 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-muted-foreground text-[10px]">
</span>
<Input
value={dt.value}
onChange={(e) => updateDetailType(idx, "value", e.target.value)}
onChange={(e) =>
updateDetailType(idx, "value", e.target.value)
}
className="h-7 text-xs"
placeholder="값"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-muted-foreground text-[10px]">
</span>
<Input
value={dt.label}
onChange={(e) => updateDetailType(idx, "label", e.target.value)}
onChange={(e) =>
updateDetailType(idx, "label", e.target.value)
}
className="h-7 text-xs"
placeholder="표시명"
/>
</div>
</div>
@ -272,7 +451,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1 text-xs border-dashed"
className="h-7 w-full gap-1 border-dashed text-xs"
onClick={addDetailType}
>
<Plus className="h-3 w-3" />
@ -282,179 +461,102 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
</CollapsibleContent>
</Collapsible>
{/* ─── 3단계: 고급 설정 (데이터 소스 + 레이아웃 통합) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
{/* 데이터 소스 (테이블만) */}
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
<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"
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Database className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180",
"text-muted-foreground h-4 w-4 transition-transform duration-200",
dataSourceOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-3">
<div className="space-y-2.5 rounded-b-lg border border-t-0 p-4">
<p className="text-muted-foreground text-[10px]">
.
</p>
<TableCombobox label="품목" value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
<TableCombobox label="라우팅 버전" value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
<TableCombobox label="라우팅 상세" value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
<TableCombobox label="공정 마스터" value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
</div>
</CollapsibleContent>
</Collapsible>
{/* 레이아웃 기본 설정 */}
<div className="space-y-2">
<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">/ </p>
</div>
<Input
type="number"
min={15}
max={50}
value={config.splitRatio || 30}
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 30 })}
className="h-7 w-[80px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })}
placeholder="품목 및 공정 선택"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs"> </p>
<p className="text-[10px] text-muted-foreground">/ </p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => update({ readonly: checked })}
/>
</div>
{/* 레이아웃 & 기타 */}
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Settings className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"></span>
</div>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 transition-transform duration-200",
layoutOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-muted-foreground text-xs">
(%)
</span>
<p className="text-muted-foreground mt-0.5 text-[10px]">
/
</p>
</div>
<Input
type="number"
min={15}
max={50}
value={config.splitRatio || 30}
onChange={(e) =>
update({ splitRatio: parseInt(e.target.value) || 30 })
}
className="h-7 w-[80px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground text-xs">
</span>
<Input
value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })}
placeholder="품목 및 공정 선택"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs"> </p>
<p className="text-muted-foreground text-[10px]">
/
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => update({ readonly: checked })}
/>
</div>
{/* 데이터 소스 (서브 Collapsible) */}
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md border px-3 py-2 transition-colors hover:bg-muted/30"
>
<div className="flex items-center gap-2">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium"> </span>
{config.dataSource.itemTable && (
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">
{config.dataSource.itemTable}
</Badge>
)}
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform",
dataSourceOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.itemTable}
onChange={(e) => updateDataSource("itemTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.itemNameColumn}
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.itemCodeColumn}
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
</div>
<div className="space-y-1 pt-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.routingVersionTable}
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> FK</span>
<Input
value={config.dataSource.routingFkColumn}
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.routingVersionNameColumn}
onChange={(e) => updateDataSource("routingVersionNameColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
</div>
<div className="space-y-1 pt-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.routingDetailTable}
onChange={(e) => updateDataSource("routingDetailTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="space-y-1 pt-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.processTable}
onChange={(e) => updateDataSource("processTable", e.target.value)}
className="h-7 w-full text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.processNameColumn}
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={config.dataSource.processCodeColumn}
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
className="h-7 text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</CollapsibleContent>
</Collapsible>
@ -462,6 +564,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
);
};
V2ProcessWorkStandardConfigPanel.displayName = "V2ProcessWorkStandardConfigPanel";
V2ProcessWorkStandardConfigPanel.displayName =
"V2ProcessWorkStandardConfigPanel";
export default V2ProcessWorkStandardConfigPanel;

View File

@ -1125,28 +1125,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
<div className="space-y-1">
<SwitchRow
label="검색"
checked={config.leftPanel?.showSearch ?? true}
checked={config.leftPanel?.showSearch ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showSearch: checked })
}
/>
<SwitchRow
label="추가 버튼"
checked={config.leftPanel?.showAdd ?? true}
checked={config.leftPanel?.showAdd ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({ showAdd: checked })
}
/>
<SwitchRow
label="수정 버튼"
checked={config.leftPanel?.showEdit ?? false}
checked={config.leftPanel?.showEdit ?? true}
onCheckedChange={(checked) =>
updateLeftPanel({ showEdit: checked })
}
/>
<SwitchRow
label="삭제 버튼"
checked={config.leftPanel?.showDelete ?? false}
checked={config.leftPanel?.showDelete ?? true}
onCheckedChange={(checked) =>
updateLeftPanel({ showDelete: checked })
}
@ -1574,28 +1574,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
<div className="space-y-1">
<SwitchRow
label="검색"
checked={config.rightPanel?.showSearch ?? true}
checked={config.rightPanel?.showSearch ?? false}
onCheckedChange={(checked) =>
updateRightPanel({ showSearch: checked })
}
/>
<SwitchRow
label="추가 버튼"
checked={config.rightPanel?.showAdd ?? true}
checked={config.rightPanel?.showAdd ?? false}
onCheckedChange={(checked) =>
updateRightPanel({ showAdd: checked })
}
/>
<SwitchRow
label="수정 버튼"
checked={config.rightPanel?.showEdit ?? false}
checked={config.rightPanel?.showEdit ?? true}
onCheckedChange={(checked) =>
updateRightPanel({ showEdit: checked })
}
/>
<SwitchRow
label="삭제 버튼"
checked={config.rightPanel?.showDelete ?? false}
checked={config.rightPanel?.showDelete ?? true}
onCheckedChange={(checked) =>
updateRightPanel({ showDelete: checked })
}

View File

@ -56,10 +56,10 @@ export default function VehicleReport() {
// 일별 통계
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
const [dailyStartDate, setDailyStartDate] = useState(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
(() => { const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })()
);
const [dailyEndDate, setDailyEndDate] = useState(
new Date().toISOString().split("T")[0]
(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })()
);
const [dailyLoading, setDailyLoading] = useState(false);

View File

@ -808,12 +808,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
? component.style?.labelText || (component as any).label || component.componentConfig?.label
: undefined;
// 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리
// 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지)
// 🔧 수평 라벨(left/right) 감지 → 외부 absolute 래퍼로 라벨 처리 (카테고리 셀렉트와 동일 방식)
const labelPosition = component.style?.labelPosition;
const isV2Component = componentType?.startsWith("v2-");
const needsExternalHorizLabel = !!(
!props.isDesignMode &&
isV2Component &&
effectiveLabel &&
(labelPosition === "left" || labelPosition === "right")

View File

@ -53,7 +53,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
// 자동생성 로직
useEffect(() => {
if (finalAutoGeneration?.enabled) {
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}-${String(n.getDate()).padStart(2, "0")}`;
setAutoGeneratedValue(today);
// 인터랙티브 모드에서 폼 데이터에도 설정

View File

@ -154,48 +154,53 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
const prevIsRecordModeRef = useRef<boolean | null>(null);
useEffect(() => {
if (prevRecordIdRef.current !== recordId) {
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
prev: prevRecordIdRef.current,
current: recordId,
isRecordMode,
});
const recordIdChanged = prevRecordIdRef.current !== null && prevRecordIdRef.current !== recordId;
const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode;
if (recordIdChanged || modeChanged) {
prevRecordIdRef.current = recordId;
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
if (isRecordMode) {
setUploadedFiles([]);
prevIsRecordModeRef.current = isRecordMode;
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
setUploadedFiles([]);
setRepresentativeImageUrl(null);
// localStorage 캐시도 정리 (새 등록 모드 전환 시)
if (!isRecordMode) {
try {
const backupKey = getUniqueKey();
localStorage.removeItem(backupKey);
if (typeof window !== "undefined") {
const globalFileState = (window as any).globalFileState || {};
delete globalFileState[backupKey];
(window as any).globalFileState = globalFileState;
}
} catch {}
}
} else if (prevRecordIdRef.current === null) {
prevRecordIdRef.current = recordId;
prevIsRecordModeRef.current = isRecordMode;
}
}, [recordId, isRecordMode]);
}, [recordId, isRecordMode, getUniqueKey]);
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
useEffect(() => {
if (!component?.id) return;
// 새 등록 모드(레코드 없음)에서는 localStorage 복원 스킵 - 빈 상태 유지
if (!isRecordMode || !recordId) {
return;
}
try {
// 🔑 레코드별 고유 키 사용
const backupKey = getUniqueKey();
const backupFiles = localStorage.getItem(backupKey);
console.log("🔎 [DEBUG-MOUNT] localStorage 확인:", {
backupKey,
hasBackup: !!backupFiles,
componentId: component.id,
recordId: recordId,
formDataId: formData?.id,
stackTrace: new Error().stack?.split('\n').slice(1, 4).join(' <- '),
});
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 [DEBUG-MOUNT] 파일 즉시 복원:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원 (레코드별 고유 키 사용)
@ -210,7 +215,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
} catch (e) {
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
}
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
}, [component.id, getUniqueKey, recordId, isRecordMode]);
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
useEffect(() => {
@ -325,9 +330,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const loadComponentFiles = useCallback(async () => {
if (!component?.id) return false;
// 새 등록 모드(레코드 없음)에서는 파일 조회 스킵 - 빈 상태 유지
if (!isRecordMode || !recordId) {
return false;
}
try {
// 🔑 레코드 모드: 해당 행의 파일만 조회
if (isRecordMode && recordTableName && recordId) {
if (recordTableName) {
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
tableName: recordTableName,
recordId: recordId,
@ -457,17 +467,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
useEffect(() => {
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
componentId: component.id,
componentFiles: componentFiles.length,
formData: formData,
screenId: formData?.screenId,
tableName: formData?.tableName, // 🔍 테이블명 확인
recordId: formData?.id, // 🔍 레코드 ID 확인
currentUploadedFiles: uploadedFiles.length,
});
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
loadComponentFiles().then((dbLoadSuccess) => {
@ -475,15 +474,22 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return; // DB 로드 성공 시 localStorage 무시
}
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 새 등록 모드(레코드 없음)에서는 fallback 로드도 스킵 - 항상 빈 상태 유지
if (!isRecordMode || !recordId) {
return;
}
// 전역 상태에서 최신 파일 정보 가져오기
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
const uniqueKeyForFallback = getUniqueKey();
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
if (currentFiles.length === 0) {
return;
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
@ -491,7 +497,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setForceUpdate((prev) => prev + 1);
}
});
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate, isRecordMode, recordId, getUniqueKey]);
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
useEffect(() => {

View File

@ -653,9 +653,9 @@ export function RepeaterTable({
if (typeof val === "string" && val.includes("T")) {
return val.split("T")[0];
}
// Date 객체이면 변환
// Date 객체이면 로컬 날짜로 변환
if (val instanceof Date) {
return val.toISOString().split("T")[0];
return `${val.getFullYear()}-${String(val.getMonth() + 1).padStart(2, "0")}-${String(val.getDate()).padStart(2, "0")}`;
}
return String(val);
};

View File

@ -448,7 +448,8 @@ export function SimpleRepeaterTableComponent({
} else if (col.type === "number") {
newRow[col.field] = 0;
} else if (col.type === "date") {
newRow[col.field] = new Date().toISOString().split("T")[0];
const _n = new Date();
newRow[col.field] = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
} else {
newRow[col.field] = "";
}

View File

@ -2707,7 +2707,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
// 파일명 생성
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`;
const _en = new Date();
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`;
// 파일 다운로드
XLSX.writeFile(wb, fileName);

View File

@ -147,13 +147,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
prevRecordIdRef.current = recordId;
prevIsRecordModeRef.current = isRecordMode;
// 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화
// 등록 모드에서는 항상 빈 상태로 시작해야 함
if (isRecordMode || !recordId) {
setUploadedFiles([]);
setRepresentativeImageUrl(null);
filesLoadedFromObjidRef.current = false;
}
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
setUploadedFiles([]);
setRepresentativeImageUrl(null);
filesLoadedFromObjidRef.current = false;
} else if (prevIsRecordModeRef.current === null) {
// 초기 마운트 시 모드 저장
prevIsRecordModeRef.current = isRecordMode;
@ -198,7 +195,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const imageObjidFromFormData = formData?.[columnName];
useEffect(() => {
if (!imageObjidFromFormData) return;
if (!imageObjidFromFormData) {
// formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시)
if (uploadedFiles.length > 0 && !isRecordMode) {
setUploadedFiles([]);
filesLoadedFromObjidRef.current = false;
}
return;
}
// 등록 모드(새 레코드)일 때는 이전 파일을 로드하지 않음
if (!isRecordMode) return;
const rawValue = String(imageObjidFromFormData);
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
@ -255,7 +262,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}, [imageObjidFromFormData, columnName, component.id]);
}, [imageObjidFromFormData, columnName, component.id, isRecordMode]);
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분

View File

@ -458,7 +458,7 @@ export function ItemRoutingComponent({
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogContent className="max-w-[95vw]" style={{ maxWidth: `min(95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
@ -481,14 +481,14 @@ export function ItemRoutingComponent({
</Button>
</div>
<div className="max-h-[340px] overflow-auto rounded-md border">
<div className="overflow-auto rounded-md border" style={{ maxHeight: config.addModalListMaxHeight || "340px" }}>
{allItems.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<Table>
<TableHeader>
<Table noWrapper>
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
{modalDisplayCols.map((col) => (

View File

@ -64,6 +64,10 @@ export interface ItemRoutingConfig {
modalDisplayColumns?: ColumnDef[];
/** 품목 조회 시 사전 필터 조건 */
itemFilterConditions?: ItemFilterCondition[];
/** 품목 추가 모달 최대 너비 (px 또는 vw, 기본: 600px) */
addModalMaxWidth?: string;
/** 품목 추가 모달 목록 최대 높이 (px, 기본: 340px) */
addModalListMaxHeight?: string;
}
// 컴포넌트 Props

View File

@ -1,15 +1,113 @@
"use client";
import React from "react";
import { Plus, Trash2, GripVertical } from "lucide-react";
import React, { useState, useEffect } from "react";
import { Plus, Trash2, GripVertical, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
import { defaultConfig } from "./config";
interface TableInfo { tableName: string; displayName?: string; }
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
function TableCombobox({ value, onChange, tables, loading }: {
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
}) {
const [open, setOpen] = useState(false);
const selected = tables.find((t) => t.tableName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" disabled={loading}>
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((t) => (
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && <span className="text-[10px] text-muted-foreground">{t.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
value: string; onChange: (v: string) => void; tableName: string; placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!tableName) { setColumns([]); return; }
const load = async () => {
setLoading(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getColumnList(tableName);
if (res.success && res.data?.columns) setColumns(res.data.columns);
} catch { /* ignore */ } finally { setLoading(false); }
};
load();
}, [tableName]);
const selected = columns.find((c) => c.columnName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" disabled={loading || !tableName}>
<span className="truncate">
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{columns.map((c) => (
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
onSelect={() => { onChange(c.columnName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{c.displayName || c.columnName}</span>
{c.displayName && <span className="text-[10px] text-muted-foreground">{c.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface ConfigPanelProps {
config: Partial<ProcessWorkStandardConfig>;
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
@ -19,6 +117,9 @@ export function ProcessWorkStandardConfigPanel({
config: configProp,
onChange,
}: ConfigPanelProps) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const config: ProcessWorkStandardConfig = {
...defaultConfig,
...configProp,
@ -27,6 +128,20 @@ export function ProcessWorkStandardConfigPanel({
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
};
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
}
} catch { /* ignore */ } finally { setLoadingTables(false); }
};
loadTables();
}, []);
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
onChange({ ...configProp, ...partial });
};
@ -112,72 +227,40 @@ export function ProcessWorkStandardConfigPanel({
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.itemTable}
onChange={(e) => updateDataSource("itemTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.itemNameColumn}
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.itemCodeColumn}
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.routingVersionTable}
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
</div>
<div>
<Label className="text-xs"> FK </Label>
<Input
value={config.dataSource.routingFkColumn}
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<ColumnCombobox value={config.dataSource.routingFkColumn} onChange={(v) => updateDataSource("routingFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.processTable}
onChange={(e) => updateDataSource("processTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.processNameColumn}
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.processCodeColumn}
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
</div>
</div>
</section>

View File

@ -123,6 +123,196 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
});
TableCellImage.displayName = "TableCellImage";
// 📎 테이블 셀 파일 컴포넌트
// objid(콤마 구분 포함) 또는 JSON 배열 값을 받아 파일명 표시 + 클릭 시 읽기 전용 모달
const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => {
const [fileInfos, setFileInfos] = React.useState<Array<{ objid: string; name: string; ext: string; size?: number }>>([]);
const [loading, setLoading] = React.useState(true);
const [modalOpen, setModalOpen] = React.useState(false);
React.useEffect(() => {
let mounted = true;
const rawValue = String(value).trim();
if (!rawValue || rawValue === "-") {
setLoading(false);
return;
}
// JSON 배열 형태인지 확인
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
const infos = parsed.map((f: any) => ({
objid: String(f.objid || f.id || ""),
name: f.realFileName || f.real_file_name || f.name || "파일",
ext: f.fileExt || f.file_ext || "",
size: f.fileSize || f.file_size || 0,
}));
if (mounted) {
setFileInfos(infos);
setLoading(false);
}
return;
}
} catch {
// JSON 파싱 실패 → objid 문자열로 처리
}
// 콤마 구분 objid 또는 단일 objid
const objids = rawValue.split(",").map(s => s.trim()).filter(Boolean);
if (objids.length === 0) {
if (mounted) setLoading(false);
return;
}
Promise.all(
objids.map(async (oid) => {
try {
const { getFileInfoByObjid } = await import("@/lib/api/file");
const res = await getFileInfoByObjid(oid);
if (res.success && res.data) {
return {
objid: oid,
name: res.data.realFileName || "파일",
ext: res.data.fileExt || "",
size: res.data.fileSize || 0,
};
}
} catch {}
return { objid: oid, name: `파일(${oid})`, ext: "" };
})
).then((results) => {
if (mounted) {
setFileInfos(results);
setLoading(false);
}
});
return () => { mounted = false; };
}, [value]);
if (loading) {
return <span className="text-muted-foreground text-xs animate-pulse">...</span>;
}
if (fileInfos.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>;
}
const { Paperclip, Download: DownloadIcon, FileText: FileTextIcon } = require("lucide-react");
const fileNames = fileInfos.map(f => f.name).join(", ");
const getFileIconClass = (ext: string) => {
const e = (ext || "").toLowerCase().replace(".", "");
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(e)) return "text-primary";
if (["pdf"].includes(e)) return "text-destructive";
if (["doc", "docx", "hwp", "hwpx"].includes(e)) return "text-blue-500";
if (["xls", "xlsx"].includes(e)) return "text-emerald-500";
return "text-muted-foreground";
};
const handleDownload = async (file: { objid: string; name: string }) => {
if (!file.objid) return;
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/download/${file.objid}`, {
responseType: "blob",
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name || "download";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("파일 다운로드 오류:", err);
}
};
return (
<>
<div
className="flex max-w-full cursor-pointer items-center gap-1.5 text-sm hover:underline"
title={`클릭하여 첨부파일 보기`}
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
>
<Paperclip className="h-4 w-4 shrink-0 text-amber-500" />
<span className="truncate text-blue-600">
{fileInfos.length === 1 ? fileNames : `첨부파일 ${fileInfos.length}`}
</span>
</div>
{modalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
e.stopPropagation();
setModalOpen(false);
}}
>
<div
className="w-full max-w-md rounded-lg border bg-card p-0 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-amber-500" />
<span className="text-sm font-semibold"> ({fileInfos.length})</span>
</div>
<button
type="button"
onClick={() => setModalOpen(false)}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<span className="sr-only"></span>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-60 overflow-y-auto p-2">
{fileInfos.map((file, idx) => (
<div
key={file.objid || idx}
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-muted/50"
>
<FileTextIcon className={`h-5 w-5 shrink-0 ${getFileIconClass(file.ext)}`} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.size ? (
<p className="text-xs text-muted-foreground">
{file.size > 1048576
? `${(file.size / 1048576).toFixed(1)} MB`
: `${(file.size / 1024).toFixed(0)} KB`}
</p>
) : null}
</div>
<button
type="button"
title="다운로드"
onClick={(e) => { e.stopPropagation(); handleDownload(file); }}
className="shrink-0 rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<DownloadIcon className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
</>
);
});
TableCellFile.displayName = "TableCellFile";
// 이미지 blob 로딩 헬퍼
function loadImageBlob(
objid: string,
@ -2816,7 +3006,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
// 파일명 생성
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`;
const _en = new Date();
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`;
// 파일 다운로드
XLSX.writeFile(wb, fileName);
@ -4303,8 +4494,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return <TableCellImage value={String(value)} />;
}
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
// 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원)
const isAttachmentColumn =
inputType === "file" ||
inputType === "attachment" ||
@ -4312,41 +4502,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
column.columnName?.toLowerCase().includes("attachment") ||
column.columnName?.toLowerCase().includes("file");
if (isAttachmentColumn) {
// JSONB 배열 또는 JSON 문자열 파싱
let files: any[] = [];
try {
if (typeof value === "string" && value.trim()) {
const parsed = JSON.parse(value);
files = Array.isArray(parsed) ? parsed : [];
} else if (Array.isArray(value)) {
files = value;
} else if (value && typeof value === "object") {
// 단일 객체인 경우 배열로 변환
files = [value];
}
} catch (e) {
// 파싱 실패 시 빈 배열
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
}
if (!files || files.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>;
}
// 파일 이름 표시 (여러 개면 쉼표로 구분)
const { Paperclip } = require("lucide-react");
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
return (
<div className="flex max-w-full items-center gap-1.5 text-sm">
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
<span className="truncate text-blue-600" title={fileNames}>
{fileNames}
</span>
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
</div>
);
if (isAttachmentColumn && value) {
return <TableCellFile value={String(value)} />;
}
if (isAttachmentColumn && !value) {
return <span className="text-muted-foreground text-xs">-</span>;
}
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)

View File

@ -227,7 +227,7 @@ export function TimelineSchedulerComponent({
if (onCellClick) {
onCellClick({
resourceId,
date: date.toISOString().split("T")[0],
date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
});
}
},
@ -343,7 +343,7 @@ export function TimelineSchedulerComponent({
if (onAddSchedule && effectiveResources.length > 0) {
onAddSchedule(
effectiveResources[0].id,
new Date().toISOString().split("T")[0]
(() => { const _n = new Date(); return `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; })()
);
}
}, [onAddSchedule, effectiveResources]);
@ -383,7 +383,8 @@ export function TimelineSchedulerComponent({
const items = Array.from(grouped.entries()).map(([code, rows]) => {
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0);
const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort();
const earliestDate = dates[0] || new Date().toISOString().split("T")[0];
const _dn = new Date();
const earliestDate = dates[0] || `${_dn.getFullYear()}-${String(_dn.getMonth() + 1).padStart(2, "0")}-${String(_dn.getDate()).padStart(2, "0")}`;
const first = rows[0];
return {
item_code: code,

View File

@ -28,7 +28,7 @@ interface ItemTimelineCardProps {
onScheduleClick?: (schedule: ScheduleItem) => void;
}
const toDateString = (d: Date) => d.toISOString().split("T")[0];
const toDateString = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const addDays = (d: Date, n: number) => {
const r = new Date(d);

View File

@ -13,7 +13,7 @@ const SCHEDULE_TABLE = "schedule_mng";
* ISO ( )
*/
const toDateString = (date: Date): string => {
return date.toISOString().split("T")[0];
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
};
/**

View File

@ -54,5 +54,5 @@ export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
export function addDaysToDateString(dateStr: string, days: number): string {
const date = new Date(dateStr);
date.setDate(date.getDate() + days);
return date.toISOString().split("T")[0];
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}

View File

@ -251,7 +251,7 @@ export function computeDateRange(
preset: DatePresetOption
): { preset: DatePresetOption; from: string; to: string } | null {
const now = new Date();
const fmt = (d: Date) => d.toISOString().split("T")[0];
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
switch (preset) {
case "today":

View File

@ -349,7 +349,7 @@ export class EnhancedFormService {
if (lowerDataType.includes("date")) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
return isNaN(date.getTime()) ? null : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
if (lowerDataType.includes("time")) {

View File

@ -1,6 +1,7 @@
"use client";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { toLocalDate, toLocalTime, toLocalDateTime } from "@/lib/utils/localDate";
/**
*
@ -52,19 +53,19 @@ export class AutoGenerationUtils {
let result: string;
switch (format) {
case "date":
result = now.toISOString().split("T")[0]; // YYYY-MM-DD
result = toLocalDate(now);
break;
case "time":
result = now.toTimeString().split(" ")[0]; // HH:mm:ss
result = toLocalTime(now);
break;
case "datetime":
result = now.toISOString().replace("T", " ").split(".")[0]; // YYYY-MM-DD HH:mm:ss
result = toLocalDateTime(now);
break;
case "timestamp":
result = now.getTime().toString();
break;
default:
result = now.toISOString(); // ISO 8601 format
result = toLocalDateTime(now);
break;
}

View File

@ -5156,7 +5156,8 @@ export class ButtonActionExecutor {
const menuName = localStorage.getItem("currentMenuName");
if (menuName) defaultFileName = menuName;
}
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const _xd = new Date();
const fileName = config.excelFileName || `${defaultFileName}_${_xd.getFullYear()}-${String(_xd.getMonth() + 1).padStart(2, "0")}-${String(_xd.getDate()).padStart(2, "0")}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
await exportToExcel(dataToExport, fileName, sheetName, true);
@ -5262,7 +5263,8 @@ export class ButtonActionExecutor {
}
}
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const _xd2 = new Date();
const fileName = config.excelFileName || `${defaultFileName}_${_xd2.getFullYear()}-${String(_xd2.getMonth() + 1).padStart(2, "0")}-${String(_xd2.getDate()).padStart(2, "0")}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;

View File

@ -440,7 +440,7 @@ const validateDateField = (fieldName: string, value: any, config?: Record<string
}
}
return { isValid: true, transformedValue: dateValue.toISOString().split("T")[0] };
return { isValid: true, transformedValue: `${dateValue.getFullYear()}-${String(dateValue.getMonth() + 1).padStart(2, "0")}-${String(dateValue.getDate()).padStart(2, "0")}` };
};
/**

View File

@ -0,0 +1,30 @@
/**
* () /
* DB Asia/Seoul로
*
* toISOString() UTC를
*/
export function toLocalDate(date: Date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function toLocalTime(date: Date = new Date()): string {
const h = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
const sec = String(date.getSeconds()).padStart(2, "0");
return `${h}:${min}:${sec}`;
}
export function toLocalDateTime(date: Date = new Date()): string {
return `${toLocalDate(date)} ${toLocalTime(date)}`;
}
export function toLocalDateTimeForInput(date: Date = new Date()): string {
const h = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
return `${toLocalDate(date)}T${h}:${min}`;
}

View File

@ -91,8 +91,8 @@ function getDefaultPeriod(): { start: string; end: string } {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
start: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`,
end: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}-${String(end.getDate()).padStart(2, "0")}`,
};
}

45
run-windows.bat Normal file
View File

@ -0,0 +1,45 @@
@echo off
REM 스크립트가 있는 디렉토리로 이동
cd /d "%~dp0"
echo =====================================
echo PLM 솔루션 - Windows 시작
echo =====================================
echo 기존 컨테이너 및 네트워크 정리 중...
docker-compose -f docker-compose.win.yml down -v 2>nul
docker network rm plm-network 2>nul
echo PLM 서비스 시작 중...
docker-compose -f docker-compose.win.yml build --no-cache
docker-compose -f docker-compose.win.yml up -d
if %errorlevel% equ 0 (
echo.
echo ✅ PLM 서비스가 성공적으로 시작되었습니다!
echo.
echo 🌐 접속 URL:
echo • 프론트엔드 (Next.js): http://localhost:3000
echo • 백엔드 (Spring/JSP): http://localhost:9090
echo.
echo 📋 서비스 상태 확인:
echo docker-compose -f docker-compose.win.yml ps
echo.
echo 📊 로그 확인:
echo docker-compose -f docker-compose.win.yml logs
echo.
echo 5초 후 프론트엔드 페이지를 자동으로 엽니다...
timeout /t 5 /nobreak >nul
start http://localhost:3000
) else (
echo.
echo ❌ PLM 서비스 시작에 실패했습니다!
echo.
echo 🔍 문제 해결 방법:
echo 1. Docker Desktop이 실행 중인지 확인
echo 2. 포트가 사용 중인지 확인 (3000, 9090)
echo 3. 로그 확인: docker-compose -f docker-compose.win.yml logs
echo.
pause
)

71
start-all-separated.bat Normal file
View File

@ -0,0 +1,71 @@
@echo off
chcp 65001 >nul
REM 스크립트가 있는 디렉토리로 이동
cd /d "%~dp0"
echo ============================================
echo PLM 솔루션 - 전체 서비스 시작 (분리형)
echo ============================================
echo.
echo 🚀 백엔드와 프론트엔드를 순차적으로 시작합니다...
echo.
REM 백엔드 먼저 시작
echo ============================================
echo 1. 백엔드 서비스 시작 중...
echo ============================================
REM 기존 컨테이너 및 네트워크 정리
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
docker-compose -f docker-compose.frontend.win.yml down -v 2>nul
docker network rm pms-network 2>nul
REM 백엔드 빌드 및 시작
docker-compose -f docker-compose.backend.win.yml build --no-cache
docker-compose -f docker-compose.backend.win.yml up -d
echo.
echo ⏳ 백엔드 서비스 안정화 대기 중... (20초)
timeout /t 20 /nobreak >nul
REM 프론트엔드 시작
echo.
echo ============================================
echo 2. 프론트엔드 서비스 시작 중...
echo ============================================
REM 프론트엔드 빌드 및 시작
docker-compose -f docker-compose.frontend.win.yml build --no-cache
docker-compose -f docker-compose.frontend.win.yml up -d
echo.
echo ⏳ 프론트엔드 서비스 안정화 대기 중... (10초)
timeout /t 10 /nobreak >nul
echo.
echo ============================================
echo 🎉 모든 서비스가 시작되었습니다!
echo ============================================
echo.
echo [DATABASE] PostgreSQL: http://39.117.244.52:11132
echo [BACKEND] Spring Boot: http://localhost:8080/api
echo [FRONTEND] Next.js: http://localhost:9771
echo.
echo 서비스 상태 확인:
echo 백엔드: docker-compose -f docker-compose.backend.win.yml ps
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml ps
echo.
echo 로그 확인:
echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f
echo.
echo 서비스 중지:
echo 백엔드: docker-compose -f docker-compose.backend.win.yml down
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml down
echo 전체: stop-all-separated.bat
echo.
echo ============================================
pause

97
start-windows-simple.bat Normal file
View File

@ -0,0 +1,97 @@
@echo off
chcp 65001 >nul
REM 스크립트가 있는 디렉토리로 이동
cd /d "%~dp0"
echo ============================================
echo PLM 솔루션 - 윈도우 간편 시작
echo ============================================
echo.
REM Docker Desktop 실행 확인
echo 🔍 Docker Desktop 상태 확인 중...
docker --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Docker Desktop이 실행되지 않았습니다!
echo Docker Desktop을 먼저 실행해주세요.
echo.
pause
exit /b 1
)
echo ✅ Docker Desktop이 실행 중입니다.
echo.
REM 기존 컨테이너 정리
echo 🧹 기존 컨테이너 정리 중...
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
docker-compose -f docker-compose.frontend.win.yml down -v 2>nul
docker network rm pms-network 2>nul
echo.
REM 백엔드 시작
echo ============================================
echo 🚀 1단계: 백엔드 서비스 시작 중...
echo ============================================
docker-compose -f docker-compose.backend.win.yml up -d --build
if %errorlevel% neq 0 (
echo ❌ 백엔드 시작 실패!
echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs
pause
exit /b 1
)
echo ✅ 백엔드 서비스 시작 완료
echo ⏳ 백엔드 안정화 대기 중... (30초)
timeout /t 30 /nobreak >nul
REM 프론트엔드 시작
echo.
echo ============================================
echo 🎨 2단계: 프론트엔드 서비스 시작 중...
echo ============================================
docker-compose -f docker-compose.frontend.win.yml up -d --build
if %errorlevel% neq 0 (
echo ❌ 프론트엔드 시작 실패!
echo 로그를 확인하세요: docker-compose -f docker-compose.frontend.win.yml logs
pause
exit /b 1
)
echo ✅ 프론트엔드 서비스 시작 완료
echo ⏳ 프론트엔드 안정화 대기 중... (15초)
timeout /t 15 /nobreak >nul
echo.
echo ============================================
echo 🎉 PLM 솔루션이 성공적으로 시작되었습니다!
echo ============================================
echo.
echo 📱 접속 정보:
echo • 프론트엔드: http://localhost:9771
echo • 백엔드 API: http://localhost:8080/api
echo • 데이터베이스: 39.117.244.52:11132
echo.
echo 📊 서비스 상태 확인:
echo docker-compose -f docker-compose.backend.win.yml ps
echo docker-compose -f docker-compose.frontend.win.yml ps
echo.
echo 📋 로그 확인:
echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f
echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f
echo.
echo 🛑 서비스 중지:
echo stop-all-separated.bat 실행
echo.
REM 브라우저 자동 열기
echo 5초 후 브라우저에서 애플리케이션을 엽니다...
timeout /t 5 /nobreak >nul
start http://localhost:9771
echo.
echo 애플리케이션이 준비되었습니다!
pause

56
stop-all-separated.bat Normal file
View File

@ -0,0 +1,56 @@
@echo off
chcp 65001 >nul
echo ============================================
echo PLM 솔루션 - 전체 서비스 중지 (분리형)
echo ============================================
echo.
echo 🛑 백엔드와 프론트엔드 서비스를 순차적으로 중지합니다...
echo.
REM 프론트엔드 먼저 중지
echo ============================================
echo 1. 프론트엔드 서비스 중지 중...
echo ============================================
docker-compose -f docker-compose.frontend.win.yml down -v
echo.
echo ⏳ 프론트엔드 서비스 완전 중지 대기 중... (5초)
timeout /t 5 /nobreak >nul
REM 백엔드 중지
echo.
echo ============================================
echo 2. 백엔드 서비스 중지 중...
echo ============================================
docker-compose -f docker-compose.backend.win.yml down -v
echo.
echo ⏳ 백엔드 서비스 완전 중지 대기 중... (5초)
timeout /t 5 /nobreak >nul
REM 네트워크 정리 (선택사항)
echo.
echo ============================================
echo 3. 네트워크 정리 중...
echo ============================================
docker network rm pms-network 2>nul || echo 네트워크가 이미 삭제되었습니다.
echo.
echo ============================================
echo ✅ 모든 서비스가 중지되었습니다!
echo ============================================
echo.
echo 서비스 상태 확인:
echo docker ps
echo.
echo 서비스 시작:
echo start-all-separated.bat
echo.
echo ============================================
pause

47
test-backend-build.bat Normal file
View File

@ -0,0 +1,47 @@
@echo off
chcp 65001 >nul
REM 스크립트가 있는 디렉토리로 이동
cd /d "%~dp0"
echo ============================================
echo 백엔드 빌드 테스트 (Windows 전용)
echo ============================================
echo.
echo 🔍 기존 컨테이너 정리 중...
docker-compose -f docker-compose.backend.win.yml down -v 2>nul
echo.
echo 🚀 백엔드 빌드 시작...
docker-compose -f docker-compose.backend.win.yml build --no-cache
if %errorlevel% equ 0 (
echo.
echo ✅ 백엔드 빌드 성공!
echo.
echo 🚀 백엔드 시작 중...
docker-compose -f docker-compose.backend.win.yml up -d
if %errorlevel% equ 0 (
echo ✅ 백엔드 시작 완료!
echo.
echo 📊 컨테이너 상태:
docker-compose -f docker-compose.backend.win.yml ps
echo.
echo 📋 로그 확인:
echo docker-compose -f docker-compose.backend.win.yml logs -f
echo.
echo 🌐 헬스체크:
echo http://localhost:8080/health
) else (
echo ❌ 백엔드 시작 실패!
echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs
)
) else (
echo ❌ 백엔드 빌드 실패!
echo 위의 오류 메시지를 확인하세요.
)
echo.
pause