Merge pull request 'jskim-node' (#420) from jskim-node into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/420
This commit is contained in:
kjs 2026-03-17 21:09:09 +09:00
commit 228bdeb264
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/ popdocs/
.cursor/rules/popdocs-safety.mdc .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"; import "dotenv/config";
process.env.TZ = "Asia/Seoul";
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달 import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";

View File

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

View File

@ -224,6 +224,31 @@ export async function updateColumnSettings(
`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}` `컬럼 설정 업데이트 완료: ${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> = { const response: ApiResponse<null> = {
success: true, success: true,
message: "컬럼 설정을 성공적으로 저장했습니다.", message: "컬럼 설정을 성공적으로 저장했습니다.",
@ -339,6 +364,29 @@ export async function updateAllColumnSettings(
`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}` `전체 컬럼 설정 일괄 업데이트 완료: ${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> = { const response: ApiResponse<null> = {
success: true, success: true,
message: "모든 컬럼 설정을 성공적으로 저장했습니다.", message: "모든 컬럼 설정을 성공적으로 저장했습니다.",

View File

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

View File

@ -251,6 +251,28 @@ class AuditLogService {
[...params, limit, offset] [...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 }; return { data, total };
} }

View File

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

View File

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

View File

@ -82,7 +82,8 @@ export function DashboardTopMenu({
) => { ) => {
if (format === "png") { if (format === "png") {
const link = document.createElement("a"); 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.download = filename;
link.href = dataUrl; link.href = dataUrl;
document.body.appendChild(link); document.body.appendChild(link);
@ -111,7 +112,8 @@ export function DashboardTopMenu({
}); });
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); 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); pdf.save(filename);
} }
}; };

View File

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

View File

@ -126,12 +126,12 @@ export function ColumnDetailPanel({
{conf.iconChar} {conf.iconChar}
</span> </span>
<span className={cn( <span className={cn(
"text-[11px] font-semibold leading-tight", "text-[16px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground", isSelected ? "text-primary" : "text-foreground",
)}> )}>
{conf.label} {conf.label}
</span> </span>
<span className="text-[9px] leading-tight text-muted-foreground"> <span className="text-[12px] leading-tight text-muted-foreground">
{conf.desc} {conf.desc}
</span> </span>
</button> </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: "⊟" }, 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: "≡" }, 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: "◉" }, 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") { if (format === "png") {
console.log("💾 PNG 다운로드 시작..."); console.log("💾 PNG 다운로드 시작...");
const link = document.createElement("a"); 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.download = filename;
link.href = dataUrl; link.href = dataUrl;
document.body.appendChild(link); document.body.appendChild(link);
@ -253,7 +254,8 @@ export function DashboardViewer({
}); });
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); 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); pdf.save(filename);
console.log("✅ PDF 다운로드 완료:", filename); console.log("✅ PDF 다운로드 완료:", filename);
} }

View File

@ -61,7 +61,8 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
// 데이터 처리 // 데이터 처리
if (result.success && result.data?.rows) { if (result.success && result.data?.rows) {
const rows = 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 기준) // 오늘 발송 건수 (created_at 기준)
const shippedToday = rows.filter((row: any) => { const shippedToday = rows.filter((row: any) => {

View File

@ -101,7 +101,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]); const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null); const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [routeLoading, setRouteLoading] = useState(false); 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>>({}); 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], { const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 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 url = window.URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;

View File

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

View File

@ -86,13 +86,16 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
const generateAutoValue = useCallback( const generateAutoValue = useCallback(
async (autoValueType: string, ruleId?: string): Promise<string> => { async (autoValueType: string, ruleId?: string): Promise<string> => {
const now = new Date(); 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) { switch (autoValueType) {
case "current_datetime": case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " "); return `${localDate} ${localTime}`;
case "current_date": case "current_date":
return now.toISOString().slice(0, 10); return localDate;
case "current_time": case "current_time":
return now.toTimeString().slice(0, 8); return localTime;
case "current_user": case "current_user":
return userName || "사용자"; return userName || "사용자";
case "uuid": case "uuid":

View File

@ -1155,13 +1155,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const generateAutoValue = useCallback( const generateAutoValue = useCallback(
(autoValueType: string): string => { (autoValueType: string): string => {
const now = new Date(); 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) { switch (autoValueType) {
case "current_datetime": case "current_datetime":
return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss return `${localDate} ${localTime}`;
case "current_date": case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD return localDate;
case "current_time": case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss return localTime;
case "current_user": case "current_user":
return currentUser?.userName || currentUser?.userId || "unknown_user"; return currentUser?.userName || currentUser?.userId || "unknown_user";
case "uuid": 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 generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise<string> => {
const now = new Date(); 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) { switch (autoValueType) {
case "current_datetime": case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss return `${localDate} ${localTime}`;
case "current_date": case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD return localDate;
case "current_time": case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss return localTime;
case "current_user": case "current_user":
// 실제 접속중인 사용자명 사용 // 실제 접속중인 사용자명 사용
return userName || "사용자"; // 사용자명이 없으면 기본값 return userName || "사용자"; // 사용자명이 없으면 기본값

View File

@ -183,13 +183,16 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const generateAutoValue = useCallback( const generateAutoValue = useCallback(
(autoValueType: string): string => { (autoValueType: string): string => {
const now = new Date(); 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) { switch (autoValueType) {
case "current_datetime": case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " "); return `${localDate} ${localTime}`;
case "current_date": case "current_date":
return now.toISOString().slice(0, 10); return localDate;
case "current_time": case "current_time":
return now.toTimeString().slice(0, 8); return localTime;
case "current_user": case "current_user":
return userName || "사용자"; return userName || "사용자";
case "uuid": case "uuid":

View File

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

View File

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

View File

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

View File

@ -1018,7 +1018,8 @@ export function FlowWidget({
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Data"); 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); XLSX.writeFile(wb, fileName);
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); 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); doc.save(fileName);
toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" }); toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" });

View File

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

View File

@ -52,8 +52,10 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
const getDefaultValue = (): string => { const getDefaultValue = (): string => {
if (config?.defaultValue === "current") { if (config?.defaultValue === "current") {
const now = new Date(); const now = new Date();
if (isDatetime) return now.toISOString().slice(0, 16); const pad = (n: number) => String(n).padStart(2, "0");
return now.toISOString().slice(0, 10); 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 ""; return "";
}; };

View File

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

View File

@ -89,12 +89,12 @@ function formatTel(value: string): string {
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`; 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.startsWith("02")) {
if (digits.length <= 2) return digits; if (digits.length <= 2) return digits;
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`; if (digits.length <= 5) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`; const mid = digits.length >= 10 ? 4 : 3;
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`; return `${digits.slice(0, 2)}-${digits.slice(2, 2 + mid)}-${digits.slice(2 + mid, 2 + mid + 4)}`;
} }
// 안심번호: 050x → 4-4-4 // 안심번호: 050x → 4-4-4
@ -135,6 +135,14 @@ const TextInput = forwardRef<
const [hasBlurred, setHasBlurred] = useState(false); const [hasBlurred, setHasBlurred] = useState(false);
const [validationError, setValidationError] = useState<string>(""); 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( const formatValue = useCallback(
(val: string): string => { (val: string): string => {
@ -154,11 +162,15 @@ const TextInput = forwardRef<
const handleChange = useCallback( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
const cursorPos = input.selectionStart ?? 0;
let newValue = e.target.value; let newValue = e.target.value;
const oldValue = input.value;
const needsCursorFix = format === "biz_no" || format === "tel" || format === "currency";
// 형식에 따른 자동 포맷팅 // 형식에 따른 자동 포맷팅
if (format === "currency") { if (format === "currency") {
// 숫자와 쉼표만 허용
newValue = newValue.replace(/[^\d,]/g, ""); newValue = newValue.replace(/[^\d,]/g, "");
newValue = formatCurrency(newValue); newValue = formatCurrency(newValue);
} else if (format === "biz_no") { } else if (format === "biz_no") {
@ -167,6 +179,20 @@ const TextInput = forwardRef<
newValue = formatTel(newValue); 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) { if (hasBlurred && validationError) {
const { isValid } = validateInputFormat(newValue, format); const { isValid } = validateInputFormat(newValue, format);
@ -244,7 +270,7 @@ const TextInput = forwardRef<
return ( return (
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<Input <Input
ref={ref} ref={combinedRef}
type="text" type="text"
value={displayValue} value={displayValue}
onChange={handleChange} onChange={handleChange}
@ -1202,7 +1228,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
ref={ref} ref={ref}
id={id} id={id}
className={cn( className={cn(
"flex gap-1", "flex",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
)} )}
style={{ style={{

View File

@ -1041,12 +1041,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const now = new Date(); 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) { switch (col.autoFill.type) {
case "currentDate": case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD return localDate;
case "currentDateTime": case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss return `${localDate} ${localTime}`;
case "sequence": case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번 return rowIndex + 1; // 1부터 시작하는 순번

View File

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

View File

@ -130,12 +130,6 @@ const ACTION_TYPE_CARDS = [
title: "엑셀 업로드", title: "엑셀 업로드",
description: "엑셀 파일을 올려요", description: "엑셀 파일을 올려요",
}, },
{
value: "quickInsert",
icon: Zap,
title: "즉시 저장",
description: "바로 저장해요",
},
{ {
value: "approval", value: "approval",
icon: Check, icon: Check,
@ -148,12 +142,6 @@ const ACTION_TYPE_CARDS = [
title: "제어 흐름", title: "제어 흐름",
description: "흐름을 제어해요", description: "흐름을 제어해요",
}, },
{
value: "event",
icon: Send,
title: "이벤트 발송",
description: "이벤트를 보내요",
},
{ {
value: "copy", value: "copy",
icon: Copy, icon: Copy,
@ -399,13 +387,51 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
if (targetTable) { if (targetTable) {
const cols = await loadTableColumns(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); setMappingTargetColumns(cols);
} else { } else {
setMappingTargetColumns([]); setMappingTargetColumns([]);
} }
}; };
loadAll(); loadAll();
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]); }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
// 화면 목록 로드 (모달 액션용) // 화면 목록 로드 (모달 액션용)
useEffect(() => { 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[]; columns: ColumnDef[];
onChange: (cols: ColumnDef[]) => void; onChange: (cols: ColumnDef[]) => void;
tableName: string; tableName: string;
title: string; title: string;
icon: React.ReactNode; icon: React.ReactNode;
extraContent?: React.ReactNode;
}) { }) {
const [open, setOpen] = useState(false); 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}> <Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
{extraContent}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
@ -378,13 +380,34 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
icon={<Eye className="h-4 w-4 text-muted-foreground" />} icon={<Eye className="h-4 w-4 text-muted-foreground" />}
/> />
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */} {/* ─── 품목 추가 모달 (컬럼 + 크기 설정) ─── */}
<ColumnEditor <ColumnEditor
columns={config.modalDisplayColumns || []} columns={config.modalDisplayColumns || []}
onChange={(cols) => update({ modalDisplayColumns: cols })} onChange={(cols) => update({ modalDisplayColumns: cols })}
tableName={config.dataSource.itemTable} tableName={config.dataSource.itemTable}
title="품목 추가 모달 컬럼" title="품목 추가 모달"
icon={<Columns className="h-4 w-4 text-muted-foreground" />} 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"; "use client";
/** /**
* V2 * V2 ()
* Progressive Disclosure: 작업 -> -> ()
*/ */
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; 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 { 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 { cn } from "@/lib/utils";
import type { import type {
ProcessWorkStandardConfig, ProcessWorkStandardConfig,
@ -20,26 +36,87 @@ import type {
} from "@/lib/registry/components/v2-process-work-standard/types"; } from "@/lib/registry/components/v2-process-work-standard/types";
import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config"; 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 { interface V2ProcessWorkStandardConfigPanelProps {
config: Partial<ProcessWorkStandardConfig>; config: Partial<ProcessWorkStandardConfig>;
onChange: (config: Partial<ProcessWorkStandardConfig>) => void; onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
} }
export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardConfigPanelProps> = ({ export const V2ProcessWorkStandardConfigPanel: React.FC<
config: configProp, V2ProcessWorkStandardConfigPanelProps
onChange, > = ({ config: configProp, onChange }) => {
}) => {
const [phasesOpen, setPhasesOpen] = useState(false); const [phasesOpen, setPhasesOpen] = useState(false);
const [detailTypesOpen, setDetailTypesOpen] = useState(false); const [detailTypesOpen, setDetailTypesOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [layoutOpen, setLayoutOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = 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 = { const config: ProcessWorkStandardConfig = {
...defaultConfig, ...defaultConfig,
...configProp, ...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases, phases: configProp?.phases?.length
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes, ? configProp.phases
: defaultConfig.phases,
detailTypes: configProp?.detailTypes?.length
? configProp.detailTypes
: defaultConfig.detailTypes,
}; };
const update = (partial: Partial<ProcessWorkStandardConfig>) => { const update = (partial: Partial<ProcessWorkStandardConfig>) => {
@ -50,13 +127,16 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
update({ dataSource: { ...config.dataSource, [field]: value } }); update({ dataSource: { ...config.dataSource, [field]: value } });
}; };
// ─── 작업 단계 관리 ───
const addPhase = () => { const addPhase = () => {
const nextOrder = config.phases.length + 1; const nextOrder = config.phases.length + 1;
update({ update({
phases: [ phases: [
...config.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) }); 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]; const next = [...config.phases];
next[idx] = { ...next[idx], [field]: value }; next[idx] = { ...next[idx], [field]: value };
update({ phases: next }); update({ phases: next });
}; };
// ─── 상세 유형 관리 ───
const addDetailType = () => { const addDetailType = () => {
update({ update({
detailTypes: [ detailTypes: [
...config.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) }); 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]; const next = [...config.detailTypes];
next[idx] = { ...next[idx], [field]: value }; next[idx] = { ...next[idx], [field]: value };
update({ detailTypes: next }); update({ detailTypes: next });
@ -93,31 +183,75 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
return ( return (
<div className="space-y-4"> <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}> <Collapsible open={phasesOpen} onOpenChange={setPhasesOpen}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="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"> <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> <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} {config.phases.length}
</Badge> </Badge>
</div> </div>
<ChevronDown <ChevronDown
className={cn( 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", phasesOpen && "rotate-180",
)} )}
/> />
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5"> <div className="space-y-1.5 rounded-b-lg border border-t-0 p-3">
<p className="text-[10px] text-muted-foreground mb-1"> (Phase) </p> <p className="text-muted-foreground mb-1 text-[10px]">
</p>
<div className="space-y-1"> <div className="space-y-1">
{config.phases.map((phase, idx) => ( {config.phases.map((phase, idx) => (
<Collapsible key={idx}> <Collapsible key={idx}>
@ -125,18 +259,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="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" /> <ChevronRight className="text-muted-foreground h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span> <span className="text-muted-foreground shrink-0 text-[10px] font-medium">
<span className="text-xs font-medium truncate flex-1 min-w-0">{phase.label}</span> #{idx + 1}
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{phase.key}</Badge> </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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={(e) => { e.stopPropagation(); removePhase(idx); }} onClick={(e) => {
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0" e.stopPropagation();
removePhase(idx);
}}
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
disabled={config.phases.length <= 1} disabled={config.phases.length <= 1}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@ -146,32 +292,45 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleContent> <CollapsibleContent>
<div className="grid grid-cols-3 gap-1.5 border-t px-2.5 py-2"> <div className="grid grid-cols-3 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-muted-foreground text-[10px]">
</span>
<Input <Input
value={phase.key} value={phase.key}
onChange={(e) => updatePhase(idx, "key", e.target.value)} onChange={(e) =>
updatePhase(idx, "key", e.target.value)
}
className="h-7 text-xs" className="h-7 text-xs"
placeholder="키"
/> />
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-muted-foreground text-[10px]">
</span>
<Input <Input
value={phase.label} value={phase.label}
onChange={(e) => updatePhase(idx, "label", e.target.value)} onChange={(e) =>
updatePhase(idx, "label", e.target.value)
}
className="h-7 text-xs" className="h-7 text-xs"
placeholder="표시명"
/> />
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-muted-foreground text-[10px]">
</span>
<Input <Input
type="number" type="number"
min={1} min={1}
value={phase.sortOrder} value={phase.sortOrder}
onChange={(e) => updatePhase(idx, "sortOrder", parseInt(e.target.value) || 1)} onChange={(e) =>
className="h-7 text-xs text-center" updatePhase(
placeholder="1" idx,
"sortOrder",
parseInt(e.target.value) || 1,
)
}
className="h-7 text-center text-xs"
/> />
</div> </div>
</div> </div>
@ -183,7 +342,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<Button <Button
variant="outline" variant="outline"
size="sm" 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} onClick={addPhase}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@ -193,31 +352,33 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* ─── 2단계: 상세 유형 옵션 (Collapsible + 접이식 카드) ─── */} {/* 상세 유형 */}
<Collapsible open={detailTypesOpen} onOpenChange={setDetailTypesOpen}> <Collapsible open={detailTypesOpen} onOpenChange={setDetailTypesOpen}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="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"> <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> <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} {config.detailTypes.length}
</Badge> </Badge>
</div> </div>
<ChevronDown <ChevronDown
className={cn( 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", detailTypesOpen && "rotate-180",
)} )}
/> />
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5"> <div className="space-y-1.5 rounded-b-lg border border-t-0 p-3">
<p className="text-[10px] text-muted-foreground mb-1"> </p> <p className="text-muted-foreground mb-1 text-[10px]">
</p>
<div className="space-y-1"> <div className="space-y-1">
{config.detailTypes.map((dt, idx) => ( {config.detailTypes.map((dt, idx) => (
<Collapsible key={idx}> <Collapsible key={idx}>
@ -225,18 +386,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="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" /> <ChevronRight className="text-muted-foreground h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span> <span className="text-muted-foreground shrink-0 text-[10px] font-medium">
<span className="text-xs font-medium truncate flex-1 min-w-0">{dt.label}</span> #{idx + 1}
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{dt.value}</Badge> </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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={(e) => { e.stopPropagation(); removeDetailType(idx); }} onClick={(e) => {
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0" e.stopPropagation();
removeDetailType(idx);
}}
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
disabled={config.detailTypes.length <= 1} disabled={config.detailTypes.length <= 1}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@ -246,21 +419,27 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<CollapsibleContent> <CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2"> <div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-muted-foreground text-[10px]">
</span>
<Input <Input
value={dt.value} value={dt.value}
onChange={(e) => updateDetailType(idx, "value", e.target.value)} onChange={(e) =>
updateDetailType(idx, "value", e.target.value)
}
className="h-7 text-xs" className="h-7 text-xs"
placeholder="값"
/> />
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-muted-foreground text-[10px]">
</span>
<Input <Input
value={dt.label} value={dt.label}
onChange={(e) => updateDetailType(idx, "label", e.target.value)} onChange={(e) =>
updateDetailType(idx, "label", e.target.value)
}
className="h-7 text-xs" className="h-7 text-xs"
placeholder="표시명"
/> />
</div> </div>
</div> </div>
@ -272,7 +451,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<Button <Button
variant="outline" variant="outline"
size="sm" 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} onClick={addDetailType}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@ -282,46 +461,83 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* ─── 3단계: 고급 설정 (데이터 소스 + 레이아웃 통합) ─── */} {/* 데이터 소스 (테이블만) */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}> <Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="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"> <div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" /> <Database className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"> </span> <span className="text-sm font-medium"> </span>
</div> </div>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200", "text-muted-foreground h-4 w-4 transition-transform duration-200",
advancedOpen && "rotate-180", dataSourceOpen && "rotate-180",
)} )}
/> />
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <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"> <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 className="flex items-center justify-between py-1">
<div> <div>
<span className="text-xs text-muted-foreground"> (%)</span> <span className="text-muted-foreground text-xs">
<p className="text-[10px] text-muted-foreground mt-0.5">/ </p> (%)
</span>
<p className="text-muted-foreground mt-0.5 text-[10px]">
/
</p>
</div> </div>
<Input <Input
type="number" type="number"
min={15} min={15}
max={50} max={50}
value={config.splitRatio || 30} value={config.splitRatio || 30}
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 30 })} onChange={(e) =>
update({ splitRatio: parseInt(e.target.value) || 30 })
}
className="h-7 w-[80px] text-xs" className="h-7 w-[80px] text-xs"
/> />
</div> </div>
<div className="flex items-center justify-between py-1"> <div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span> <span className="text-muted-foreground text-xs">
</span>
<Input <Input
value={config.leftPanelTitle || ""} value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })} onChange={(e) => update({ leftPanelTitle: e.target.value })}
@ -332,7 +548,9 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
<div className="flex items-center justify-between py-1"> <div className="flex items-center justify-between py-1">
<div> <div>
<p className="text-xs"> </p> <p className="text-xs"> </p>
<p className="text-[10px] text-muted-foreground">/ </p> <p className="text-muted-foreground text-[10px]">
/
</p>
</div> </div>
<Switch <Switch
checked={config.readonly || false} checked={config.readonly || false}
@ -340,128 +558,13 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
/> />
</div> </div>
</div> </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> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
); );
}; };
V2ProcessWorkStandardConfigPanel.displayName = "V2ProcessWorkStandardConfigPanel"; V2ProcessWorkStandardConfigPanel.displayName =
"V2ProcessWorkStandardConfigPanel";
export default V2ProcessWorkStandardConfigPanel; export default V2ProcessWorkStandardConfigPanel;

View File

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

View File

@ -56,10 +56,10 @@ export default function VehicleReport() {
// 일별 통계 // 일별 통계
const [dailyData, setDailyData] = useState<DailyStat[]>([]); const [dailyData, setDailyData] = useState<DailyStat[]>([]);
const [dailyStartDate, setDailyStartDate] = useState( 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( 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); 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 ? component.style?.labelText || (component as any).label || component.componentConfig?.label
: undefined; : undefined;
// 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리 // 🔧 수평 라벨(left/right) 감지 → 외부 absolute 래퍼로 라벨 처리 (카테고리 셀렉트와 동일 방식)
// 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지)
const labelPosition = component.style?.labelPosition; const labelPosition = component.style?.labelPosition;
const isV2Component = componentType?.startsWith("v2-"); const isV2Component = componentType?.startsWith("v2-");
const needsExternalHorizLabel = !!( const needsExternalHorizLabel = !!(
!props.isDesignMode &&
isV2Component && isV2Component &&
effectiveLabel && effectiveLabel &&
(labelPosition === "left" || labelPosition === "right") (labelPosition === "left" || labelPosition === "right")

View File

@ -53,7 +53,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
// 자동생성 로직 // 자동생성 로직
useEffect(() => { useEffect(() => {
if (finalAutoGeneration?.enabled) { 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); setAutoGeneratedValue(today);
// 인터랙티브 모드에서 폼 데이터에도 설정 // 인터랙티브 모드에서 폼 데이터에도 설정

View File

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

View File

@ -653,9 +653,9 @@ export function RepeaterTable({
if (typeof val === "string" && val.includes("T")) { if (typeof val === "string" && val.includes("T")) {
return val.split("T")[0]; return val.split("T")[0];
} }
// Date 객체이면 변환 // Date 객체이면 로컬 날짜로 변환
if (val instanceof 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); return String(val);
}; };

View File

@ -448,7 +448,8 @@ export function SimpleRepeaterTableComponent({
} else if (col.type === "number") { } else if (col.type === "number") {
newRow[col.field] = 0; newRow[col.field] = 0;
} else if (col.type === "date") { } 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 { } else {
newRow[col.field] = ""; newRow[col.field] = "";
} }

View File

@ -2707,7 +2707,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); 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); XLSX.writeFile(wb, fileName);

View File

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

View File

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

View File

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

View File

@ -1,15 +1,113 @@
"use client"; "use client";
import React from "react"; import React, { useState, useEffect } from "react";
import { Plus, Trash2, GripVertical } from "lucide-react"; import { Plus, Trash2, GripVertical, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
import { defaultConfig } from "./config"; 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 { interface ConfigPanelProps {
config: Partial<ProcessWorkStandardConfig>; config: Partial<ProcessWorkStandardConfig>;
onChange: (config: Partial<ProcessWorkStandardConfig>) => void; onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
@ -19,6 +117,9 @@ export function ProcessWorkStandardConfigPanel({
config: configProp, config: configProp,
onChange, onChange,
}: ConfigPanelProps) { }: ConfigPanelProps) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const config: ProcessWorkStandardConfig = { const config: ProcessWorkStandardConfig = {
...defaultConfig, ...defaultConfig,
...configProp, ...configProp,
@ -27,6 +128,20 @@ export function ProcessWorkStandardConfigPanel({
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes, 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>) => { const update = (partial: Partial<ProcessWorkStandardConfig>) => {
onChange({ ...configProp, ...partial }); onChange({ ...configProp, ...partial });
}; };
@ -112,72 +227,40 @@ export function ProcessWorkStandardConfigPanel({
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
value={config.dataSource.itemTable}
onChange={(e) => updateDataSource("itemTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
value={config.dataSource.itemNameColumn}
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
value={config.dataSource.itemCodeColumn}
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
</div> </div>
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
value={config.dataSource.routingVersionTable}
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
<div> <div>
<Label className="text-xs"> FK </Label> <Label className="text-xs"> FK </Label>
<Input <ColumnCombobox value={config.dataSource.routingFkColumn} onChange={(v) => updateDataSource("routingFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
value={config.dataSource.routingFkColumn}
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
value={config.dataSource.processTable}
onChange={(e) => updateDataSource("processTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
value={config.dataSource.processNameColumn}
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
value={config.dataSource.processCodeColumn}
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div> </div>
</div> </div>
</section> </section>

View File

@ -123,6 +123,196 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
}); });
TableCellImage.displayName = "TableCellImage"; 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 로딩 헬퍼 // 이미지 blob 로딩 헬퍼
function loadImageBlob( function loadImageBlob(
objid: string, objid: string,
@ -2816,7 +3006,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); 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); XLSX.writeFile(wb, fileName);
@ -4303,8 +4494,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return <TableCellImage value={String(value)} />; return <TableCellImage value={String(value)} />;
} }
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시 // 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원)
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
const isAttachmentColumn = const isAttachmentColumn =
inputType === "file" || inputType === "file" ||
inputType === "attachment" || inputType === "attachment" ||
@ -4312,43 +4502,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
column.columnName?.toLowerCase().includes("attachment") || column.columnName?.toLowerCase().includes("attachment") ||
column.columnName?.toLowerCase().includes("file"); column.columnName?.toLowerCase().includes("file");
if (isAttachmentColumn) { if (isAttachmentColumn && value) {
// JSONB 배열 또는 JSON 문자열 파싱 return <TableCellFile value={String(value)} />;
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) { if (isAttachmentColumn && !value) {
// 파싱 실패 시 빈 배열
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
}
if (!files || files.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>; 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 (inputType === "category") { if (inputType === "category") {
if (!value) return ""; if (!value) return "";

View File

@ -227,7 +227,7 @@ export function TimelineSchedulerComponent({
if (onCellClick) { if (onCellClick) {
onCellClick({ onCellClick({
resourceId, 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) { if (onAddSchedule && effectiveResources.length > 0) {
onAddSchedule( onAddSchedule(
effectiveResources[0].id, 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]); }, [onAddSchedule, effectiveResources]);
@ -383,7 +383,8 @@ export function TimelineSchedulerComponent({
const items = Array.from(grouped.entries()).map(([code, rows]) => { const items = Array.from(grouped.entries()).map(([code, rows]) => {
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0); 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 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]; const first = rows[0];
return { return {
item_code: code, item_code: code,

View File

@ -28,7 +28,7 @@ interface ItemTimelineCardProps {
onScheduleClick?: (schedule: ScheduleItem) => void; 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 addDays = (d: Date, n: number) => {
const r = new Date(d); const r = new Date(d);

View File

@ -13,7 +13,7 @@ const SCHEDULE_TABLE = "schedule_mng";
* ISO ( ) * ISO ( )
*/ */
const toDateString = (date: Date): string => { 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 { export function addDaysToDateString(dateStr: string, days: number): string {
const date = new Date(dateStr); const date = new Date(dateStr);
date.setDate(date.getDate() + days); 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
): { preset: DatePresetOption; from: string; to: string } | null { ): { preset: DatePresetOption; from: string; to: string } | null {
const now = new Date(); 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) { switch (preset) {
case "today": case "today":

View File

@ -349,7 +349,7 @@ export class EnhancedFormService {
if (lowerDataType.includes("date")) { if (lowerDataType.includes("date")) {
const date = new Date(value); 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")) { if (lowerDataType.includes("time")) {

View File

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

View File

@ -5156,7 +5156,8 @@ export class ButtonActionExecutor {
const menuName = localStorage.getItem("currentMenuName"); const menuName = localStorage.getItem("currentMenuName");
if (menuName) defaultFileName = menuName; 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"; const sheetName = config.excelSheetName || "Sheet1";
await exportToExcel(dataToExport, fileName, sheetName, true); 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 sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false; 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 start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { return {
start: start.toISOString().split("T")[0], start: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`,
end: end.toISOString().split("T")[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