Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node

This commit is contained in:
syc0123 2026-03-18 10:05:29 +09:00
commit 0437766ee5
64 changed files with 1416 additions and 572 deletions

View File

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

9
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

45
run-windows.bat Normal file
View File

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

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

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

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

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

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

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

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

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