jskim-node #420
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"Framelink Figma MCP": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -182,3 +182,12 @@ scripts/browser-test-*.js
|
||||||
# 개인 작업 문서
|
# 개인 작업 문서
|
||||||
popdocs/
|
popdocs/
|
||||||
.cursor/rules/popdocs-safety.mdc
|
.cursor/rules/popdocs-safety.mdc
|
||||||
|
|
||||||
|
# 멀티 에이전트 MCP 태스크 큐
|
||||||
|
mcp-task-queue/
|
||||||
|
.cursor/mcp.json
|
||||||
|
.cursor/rules/multi-agent-pm.mdc
|
||||||
|
.cursor/rules/multi-agent-worker.mdc
|
||||||
|
.cursor/rules/multi-agent-tester.mdc
|
||||||
|
.cursor/rules/multi-agent-reviewer.mdc
|
||||||
|
.cursor/rules/multi-agent-knowledge.mdc
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
process.env.TZ = "Asia/Seoul";
|
||||||
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const getAuditLogs = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
const isSuperAdmin = userCompanyCode === "*";
|
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
companyCode,
|
companyCode,
|
||||||
|
|
@ -63,7 +63,7 @@ export const getAuditLogStats = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
const isSuperAdmin = userCompanyCode === "*";
|
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||||
const { companyCode, days } = req.query;
|
const { companyCode, days } = req.query;
|
||||||
|
|
||||||
const targetCompany = isSuperAdmin
|
const targetCompany = isSuperAdmin
|
||||||
|
|
@ -91,7 +91,7 @@ export const getAuditLogUsers = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
const isSuperAdmin = userCompanyCode === "*";
|
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||||
const { companyCode } = req.query;
|
const { companyCode } = req.query;
|
||||||
|
|
||||||
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,31 @@ export async function updateColumnSettings(
|
||||||
`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}`
|
`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "TABLE",
|
||||||
|
resourceId: `${tableName}.${columnName}`,
|
||||||
|
resourceName: settings.columnLabel || columnName,
|
||||||
|
tableName: "table_type_columns",
|
||||||
|
summary: `테이블 타입관리: ${tableName}.${columnName} 컬럼 설정 변경`,
|
||||||
|
changes: {
|
||||||
|
after: {
|
||||||
|
columnLabel: settings.columnLabel,
|
||||||
|
inputType: settings.inputType,
|
||||||
|
referenceTable: settings.referenceTable,
|
||||||
|
referenceColumn: settings.referenceColumn,
|
||||||
|
displayColumn: settings.displayColumn,
|
||||||
|
codeCategory: settings.codeCategory,
|
||||||
|
},
|
||||||
|
fields: ["columnLabel", "inputType", "referenceTable", "referenceColumn", "displayColumn", "codeCategory"],
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "컬럼 설정을 성공적으로 저장했습니다.",
|
message: "컬럼 설정을 성공적으로 저장했습니다.",
|
||||||
|
|
@ -339,6 +364,29 @@ export async function updateAllColumnSettings(
|
||||||
`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
|
`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const changedColumns = columnSettings
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => c.columnName)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "BATCH_UPDATE",
|
||||||
|
resourceType: "TABLE",
|
||||||
|
resourceId: tableName,
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName: "table_type_columns",
|
||||||
|
summary: `테이블 타입관리: ${tableName} 전체 컬럼 설정 일괄 변경 (${columnSettings.length}개)`,
|
||||||
|
changes: {
|
||||||
|
after: { columns: changedColumns, count: columnSettings.length },
|
||||||
|
fields: columnSettings.filter((c) => c.columnName).map((c) => c.columnName!),
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",
|
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,9 @@ export const initializePool = (): Pool => {
|
||||||
|
|
||||||
// 연결 풀 이벤트 핸들러
|
// 연결 풀 이벤트 핸들러
|
||||||
pool.on("connect", (client) => {
|
pool.on("connect", (client) => {
|
||||||
|
client.query("SET timezone = 'Asia/Seoul'");
|
||||||
if (config.debug) {
|
if (config.debug) {
|
||||||
console.log("✅ PostgreSQL 클라이언트 연결 생성");
|
console.log("✅ PostgreSQL 클라이언트 연결 생성 (timezone: Asia/Seoul)");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,28 @@ class AuditLogService {
|
||||||
[...params, limit, offset]
|
[...params, limit, offset]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const SECURITY_MASK = "(보안 항목 - 값 비공개)";
|
||||||
|
const securedTables = ["table_type_columns"];
|
||||||
|
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
for (const entry of data) {
|
||||||
|
if (entry.table_name && securedTables.includes(entry.table_name) && entry.changes) {
|
||||||
|
const changes = typeof entry.changes === "string" ? JSON.parse(entry.changes) : entry.changes;
|
||||||
|
if (changes.before) {
|
||||||
|
for (const key of Object.keys(changes.before)) {
|
||||||
|
changes.before[key] = SECURITY_MASK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.after) {
|
||||||
|
for (const key of Object.keys(changes.after)) {
|
||||||
|
changes.after[key] = SECURITY_MASK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.changes = changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { data, total };
|
return { data, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1707,71 +1707,66 @@ export class DynamicFormService {
|
||||||
try {
|
try {
|
||||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||||
|
|
||||||
// 화면의 저장 버튼에서 제어관리 설정 조회
|
// V2 레이아웃에서 layout_data jsonb 조회
|
||||||
const screenLayouts = await query<{
|
const v2Layouts = await query<{
|
||||||
component_id: string;
|
layout_id: number;
|
||||||
properties: any;
|
layout_data: any;
|
||||||
}>(
|
}>(
|
||||||
`SELECT component_id, properties
|
`SELECT layout_id, layout_data
|
||||||
FROM screen_layouts
|
FROM screen_layouts_v2
|
||||||
WHERE screen_id = $1
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
AND component_type IN ('component', 'v2-button-primary')`,
|
[screenId, companyCode]
|
||||||
[screenId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
if (v2Layouts.length === 0) {
|
||||||
|
console.log(`ℹ️ V2 레이아웃이 없습니다. (화면 ID: ${screenId}, company: ${companyCode})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout_data.components 배열에서 버튼 컴포넌트 추출
|
||||||
|
const layoutData = v2Layouts[0].layout_data;
|
||||||
|
const components: any[] = layoutData?.components || [];
|
||||||
|
|
||||||
|
console.log(`📋 V2 컴포넌트 조회 결과: ${components.length}개`);
|
||||||
|
|
||||||
// 저장 버튼 중에서 제어관리가 활성화된 것 찾기
|
|
||||||
let controlConfigFound = false;
|
let controlConfigFound = false;
|
||||||
for (const layout of screenLayouts) {
|
for (const comp of components) {
|
||||||
const properties = layout.properties as any;
|
const overrides = comp?.overrides || {};
|
||||||
|
|
||||||
// 디버깅: 모든 컴포넌트 정보 출력
|
const isButtonComponent =
|
||||||
console.log(`🔍 컴포넌트 검사:`, {
|
overrides?.type === "v2-button-primary" ||
|
||||||
componentId: layout.component_id,
|
(comp?.url || "").includes("v2-button-primary");
|
||||||
componentType: properties?.componentType,
|
|
||||||
actionType: properties?.componentConfig?.action?.type,
|
|
||||||
enableDataflowControl:
|
|
||||||
properties?.webTypeConfig?.enableDataflowControl,
|
|
||||||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
|
||||||
hasDiagramId:
|
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
|
||||||
hasFlowControls:
|
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
|
||||||
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
||||||
const buttonActionType = properties?.componentConfig?.action?.type;
|
const buttonActionType = overrides?.action?.type;
|
||||||
const isMatchingAction =
|
const isMatchingAction =
|
||||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||||
|
|
||||||
const isButtonComponent =
|
console.log(`🔍 V2 컴포넌트 검사:`, {
|
||||||
properties?.componentType === "button-primary" ||
|
componentId: comp?.id,
|
||||||
properties?.componentType === "v2-button-primary";
|
type: overrides?.type,
|
||||||
|
actionType: buttonActionType,
|
||||||
|
enableDataflowControl: overrides?.enableDataflowControl,
|
||||||
|
hasDataflowConfig: !!overrides?.dataflowConfig,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isButtonComponent &&
|
isButtonComponent &&
|
||||||
isMatchingAction &&
|
isMatchingAction &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
overrides?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
const dataflowConfig = overrides?.dataflowConfig;
|
||||||
|
|
||||||
// 다중 제어 설정 확인 (flowControls 배열)
|
|
||||||
const flowControls = dataflowConfig?.flowControls || [];
|
const flowControls = dataflowConfig?.flowControls || [];
|
||||||
|
|
||||||
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
|
|
||||||
if (flowControls.length > 0) {
|
if (flowControls.length > 0) {
|
||||||
controlConfigFound = true;
|
controlConfigFound = true;
|
||||||
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`);
|
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`);
|
||||||
|
|
||||||
// 순서대로 정렬
|
|
||||||
const sortedControls = [...flowControls].sort(
|
const sortedControls = [...flowControls].sort(
|
||||||
(a: any, b: any) => (a.order || 0) - (b.order || 0)
|
(a: any, b: any) => (a.order || 0) - (b.order || 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 다중 제어 순차 실행
|
|
||||||
await this.executeMultipleFlowControls(
|
await this.executeMultipleFlowControls(
|
||||||
sortedControls,
|
sortedControls,
|
||||||
savedData,
|
savedData,
|
||||||
|
|
@ -1782,13 +1777,12 @@ export class DynamicFormService {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
} else if (dataflowConfig?.selectedDiagramId) {
|
} else if (dataflowConfig?.selectedDiagramId) {
|
||||||
// 기존 단일 제어 실행 (하위 호환성)
|
|
||||||
controlConfigFound = true;
|
controlConfigFound = true;
|
||||||
const diagramId = dataflowConfig.selectedDiagramId;
|
const diagramId = dataflowConfig.selectedDiagramId;
|
||||||
const relationshipId = dataflowConfig.selectedRelationshipId;
|
const relationshipId = dataflowConfig.selectedRelationshipId;
|
||||||
|
|
||||||
console.log(`🎯 단일 제어관리 설정 발견:`, {
|
console.log(`🎯 단일 제어관리 설정 발견:`, {
|
||||||
componentId: layout.component_id,
|
componentId: comp?.id,
|
||||||
diagramId,
|
diagramId,
|
||||||
relationshipId,
|
relationshipId,
|
||||||
triggerType,
|
triggerType,
|
||||||
|
|
@ -1806,7 +1800,6 @@ export class DynamicFormService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 첫 번째 설정된 버튼의 제어관리만 실행
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1382,7 +1382,7 @@ export default function TableManagementPage() {
|
||||||
{/* 3패널 메인 */}
|
{/* 3패널 메인 */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 좌측: 테이블 목록 (240px) */}
|
{/* 좌측: 테이블 목록 (240px) */}
|
||||||
<div className="bg-card flex w-[240px] min-w-[240px] flex-shrink-0 flex-col border-r">
|
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-shrink-0 p-3 pb-0">
|
<div className="flex-shrink-0 p-3 pb-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -1482,13 +1482,13 @@ export default function TableManagementPage() {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-[12px] leading-tight",
|
"truncate text-[16px] leading-tight",
|
||||||
isActive ? "font-bold" : "font-medium",
|
isActive ? "font-bold" : "font-medium",
|
||||||
)}>
|
)}>
|
||||||
{table.displayName || table.tableName}
|
{table.displayName || table.tableName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground truncate font-mono text-[10px] leading-tight tracking-tight">
|
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
|
||||||
{table.tableName}
|
{table.tableName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1605,7 +1605,7 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
|
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
|
||||||
{selectedColumn && (
|
{selectedColumn && (
|
||||||
<div className="w-[320px] min-w-[320px] flex-shrink-0 overflow-hidden">
|
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
|
||||||
<ColumnDetailPanel
|
<ColumnDetailPanel
|
||||||
column={columns.find((c) => c.columnName === selectedColumn) ?? null}
|
column={columns.find((c) => c.columnName === selectedColumn) ?? null}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ export function DashboardTopMenu({
|
||||||
) => {
|
) => {
|
||||||
if (format === "png") {
|
if (format === "png") {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
|
const _fd = new Date();
|
||||||
|
const filename = `${dashboardTitle || "dashboard"}_${_fd.getFullYear()}-${String(_fd.getMonth() + 1).padStart(2, "0")}-${String(_fd.getDate()).padStart(2, "0")}.png`;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
|
|
@ -111,7 +112,8 @@ export function DashboardTopMenu({
|
||||||
});
|
});
|
||||||
|
|
||||||
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
||||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
const _pd = new Date();
|
||||||
|
const filename = `${dashboardTitle || "dashboard"}_${_pd.getFullYear()}-${String(_pd.getMonth() + 1).padStart(2, "0")}-${String(_pd.getDate()).padStart(2, "0")}.pdf`;
|
||||||
pdf.save(filename);
|
pdf.save(filename);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -100,36 +100,37 @@ export function getQuickDateRange(range: "today" | "week" | "month" | "year"): {
|
||||||
} {
|
} {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case "today":
|
case "today":
|
||||||
return {
|
return {
|
||||||
startDate: today.toISOString().split("T")[0],
|
startDate: fmtDate(today),
|
||||||
endDate: today.toISOString().split("T")[0],
|
endDate: fmtDate(today),
|
||||||
};
|
};
|
||||||
|
|
||||||
case "week": {
|
case "week": {
|
||||||
const weekStart = new Date(today);
|
const weekStart = new Date(today);
|
||||||
weekStart.setDate(today.getDate() - today.getDay()); // 일요일부터
|
weekStart.setDate(today.getDate() - today.getDay());
|
||||||
return {
|
return {
|
||||||
startDate: weekStart.toISOString().split("T")[0],
|
startDate: fmtDate(weekStart),
|
||||||
endDate: today.toISOString().split("T")[0],
|
endDate: fmtDate(today),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "month": {
|
case "month": {
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
return {
|
return {
|
||||||
startDate: monthStart.toISOString().split("T")[0],
|
startDate: fmtDate(monthStart),
|
||||||
endDate: today.toISOString().split("T")[0],
|
endDate: fmtDate(today),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "year": {
|
case "year": {
|
||||||
const yearStart = new Date(today.getFullYear(), 0, 1);
|
const yearStart = new Date(today.getFullYear(), 0, 1);
|
||||||
return {
|
return {
|
||||||
startDate: yearStart.toISOString().split("T")[0],
|
startDate: fmtDate(yearStart),
|
||||||
endDate: today.toISOString().split("T")[0],
|
endDate: fmtDate(today),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,12 +126,12 @@ export function ColumnDetailPanel({
|
||||||
{conf.iconChar}
|
{conf.iconChar}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[11px] font-semibold leading-tight",
|
"text-[16px] font-semibold leading-tight",
|
||||||
isSelected ? "text-primary" : "text-foreground",
|
isSelected ? "text-primary" : "text-foreground",
|
||||||
)}>
|
)}>
|
||||||
{conf.label}
|
{conf.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] leading-tight text-muted-foreground">
|
<span className="text-[12px] leading-tight text-muted-foreground">
|
||||||
{conf.desc}
|
{conf.desc}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
||||||
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
|
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
|
||||||
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
|
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
|
||||||
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
|
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
|
||||||
|
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
|
||||||
|
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 컬럼 그룹 판별 */
|
/** 컬럼 그룹 판별 */
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,8 @@ export function DashboardViewer({
|
||||||
if (format === "png") {
|
if (format === "png") {
|
||||||
console.log("💾 PNG 다운로드 시작...");
|
console.log("💾 PNG 다운로드 시작...");
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
|
const _dvd = new Date();
|
||||||
|
const filename = `${dashboardTitle || "dashboard"}_${_dvd.getFullYear()}-${String(_dvd.getMonth() + 1).padStart(2, "0")}-${String(_dvd.getDate()).padStart(2, "0")}.png`;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
|
|
@ -253,7 +254,8 @@ export function DashboardViewer({
|
||||||
});
|
});
|
||||||
|
|
||||||
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
||||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
const _dvp = new Date();
|
||||||
|
const filename = `${dashboardTitle || "dashboard"}_${_dvp.getFullYear()}-${String(_dvp.getMonth() + 1).padStart(2, "0")}-${String(_dvp.getDate()).padStart(2, "0")}.pdf`;
|
||||||
pdf.save(filename);
|
pdf.save(filename);
|
||||||
console.log("✅ PDF 다운로드 완료:", filename);
|
console.log("✅ PDF 다운로드 완료:", filename);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,8 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
||||||
// 데이터 처리
|
// 데이터 처리
|
||||||
if (result.success && result.data?.rows) {
|
if (result.success && result.data?.rows) {
|
||||||
const rows = result.data.rows;
|
const rows = result.data.rows;
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const _td = new Date();
|
||||||
|
const today = `${_td.getFullYear()}-${String(_td.getMonth() + 1).padStart(2, "0")}-${String(_td.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
// 오늘 발송 건수 (created_at 기준)
|
// 오늘 발송 건수 (created_at 기준)
|
||||||
const shippedToday = rows.filter((row: any) => {
|
const shippedToday = rows.filter((row: any) => {
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
const [routeLoading, setRouteLoading] = useState(false);
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
const [routeDate, setRouteDate] = useState<string>(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; });
|
||||||
|
|
||||||
// 공차/운행 정보 상태
|
// 공차/운행 정보 상태
|
||||||
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});
|
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});
|
||||||
|
|
|
||||||
|
|
@ -1120,7 +1120,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
const blob = new Blob([response.data], {
|
const blob = new Blob([response.data], {
|
||||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
});
|
});
|
||||||
const timestamp = new Date().toISOString().slice(0, 10);
|
const _rpd = new Date();
|
||||||
|
const timestamp = `${_rpd.getFullYear()}-${String(_rpd.getMonth() + 1).padStart(2, "0")}-${String(_rpd.getDate()).padStart(2, "0")}`;
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
|
||||||
|
|
@ -563,8 +563,20 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
if (screenInfo && layoutData) {
|
if (screenInfo && layoutData) {
|
||||||
const components = layoutData.components || [];
|
const components = layoutData.components || [];
|
||||||
|
|
||||||
// 화면의 실제 크기 계산
|
// 화면 관리에서 설정한 해상도 우선 사용 (ScreenModal과 동일)
|
||||||
const dimensions = calculateScreenDimensions(components);
|
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
|
||||||
|
|
||||||
|
let dimensions;
|
||||||
|
if (screenResolution && screenResolution.width && screenResolution.height) {
|
||||||
|
dimensions = {
|
||||||
|
width: screenResolution.width,
|
||||||
|
height: screenResolution.height,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
dimensions = calculateScreenDimensions(components);
|
||||||
|
}
|
||||||
setScreenDimensions(dimensions);
|
setScreenDimensions(dimensions);
|
||||||
|
|
||||||
setScreenData({
|
setScreenData({
|
||||||
|
|
@ -1547,31 +1559,25 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더
|
// 모달 크기 설정 - ScreenModal과 동일한 방식 (maxHeight로 유연 처리)
|
||||||
const getModalStyle = () => {
|
const getModalStyle = () => {
|
||||||
if (!screenDimensions) {
|
if (!screenDimensions) {
|
||||||
return {
|
return {
|
||||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
|
||||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
|
||||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
|
|
||||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
|
||||||
const dialogGap = 16; // DialogContent gap-4
|
|
||||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
|
||||||
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
|
|
||||||
|
|
||||||
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden p-0",
|
className: "overflow-hidden p-0",
|
||||||
style: {
|
style: {
|
||||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
width: `${finalWidth}px`,
|
||||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
maxHeight: "calc(100dvh - 8px)",
|
||||||
maxWidth: "98vw",
|
maxWidth: "98vw",
|
||||||
maxHeight: "95vh",
|
padding: 0,
|
||||||
|
gap: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -1593,7 +1599,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="[&::-webkit-scrollbar-thumb]:bg-muted/60 flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
<div className="flex-1 min-h-0 flex items-start justify-center overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -1608,42 +1614,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-screen-runtime="true"
|
data-screen-runtime="true"
|
||||||
className="bg-card relative m-auto"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: `${screenDimensions?.width || 800}px`,
|
||||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
minHeight: `${screenDimensions?.height || 600}px`,
|
||||||
height: (() => {
|
height: (() => {
|
||||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
const baseHeight = screenDimensions?.height || 600;
|
||||||
if (activeConditionalComponents.length > 0) {
|
if (activeConditionalComponents.length > 0) {
|
||||||
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
|
||||||
const offsetY = screenDimensions?.offsetY || 0;
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
let maxBottom = 0;
|
let maxBottom = 0;
|
||||||
activeConditionalComponents.forEach((comp) => {
|
activeConditionalComponents.forEach((comp) => {
|
||||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY;
|
||||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||||
maxBottom = Math.max(maxBottom, y + h);
|
maxBottom = Math.max(maxBottom, y + h);
|
||||||
});
|
});
|
||||||
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
return `${Math.max(baseHeight, maxBottom + 20)}px`;
|
||||||
}
|
}
|
||||||
return baseHeight;
|
return `${baseHeight}px`;
|
||||||
})(),
|
})(),
|
||||||
transformOrigin: "center center",
|
overflow: "visible",
|
||||||
maxWidth: "100%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 기본 레이어 컴포넌트 렌더링 */}
|
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||||
{screenData.components.map((component) => {
|
{screenData.components.map((component) => {
|
||||||
// 컴포넌트 위치를 offset만큼 조정
|
|
||||||
const offsetX = screenDimensions?.offsetX || 0;
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
const offsetY = screenDimensions?.offsetY || 0;
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
|
// screenResolution이 있으면 offsetY=0이므로 디자이너 좌표 그대로 사용
|
||||||
|
// offsetY > 0 (자동 계산)일 때만 라벨 공간 보정
|
||||||
|
const labelSpace = offsetY > 0 ? 30 : 0;
|
||||||
|
|
||||||
const adjustedComponent = {
|
const adjustedComponent = {
|
||||||
...component,
|
...component,
|
||||||
position: {
|
position: {
|
||||||
...component.position,
|
...component.position,
|
||||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
|
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1709,11 +1714,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
{/* 조건부 레이어 컴포넌트 렌더링 */}
|
||||||
{activeConditionalComponents.map((component) => {
|
{activeConditionalComponents.map((component) => {
|
||||||
const offsetX = screenDimensions?.offsetX || 0;
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
const offsetY = screenDimensions?.offsetY || 0;
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
const labelSpace = 30;
|
const labelSpace = offsetY > 0 ? 30 : 0;
|
||||||
|
|
||||||
const adjustedComponent = {
|
const adjustedComponent = {
|
||||||
...component,
|
...component,
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,16 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
||||||
const generateAutoValue = useCallback(
|
const generateAutoValue = useCallback(
|
||||||
async (autoValueType: string, ruleId?: string): Promise<string> => {
|
async (autoValueType: string, ruleId?: string): Promise<string> => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
switch (autoValueType) {
|
switch (autoValueType) {
|
||||||
case "current_datetime":
|
case "current_datetime":
|
||||||
return now.toISOString().slice(0, 19).replace("T", " ");
|
return `${localDate} ${localTime}`;
|
||||||
case "current_date":
|
case "current_date":
|
||||||
return now.toISOString().slice(0, 10);
|
return localDate;
|
||||||
case "current_time":
|
case "current_time":
|
||||||
return now.toTimeString().slice(0, 8);
|
return localTime;
|
||||||
case "current_user":
|
case "current_user":
|
||||||
return userName || "사용자";
|
return userName || "사용자";
|
||||||
case "uuid":
|
case "uuid":
|
||||||
|
|
|
||||||
|
|
@ -1155,13 +1155,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const generateAutoValue = useCallback(
|
const generateAutoValue = useCallback(
|
||||||
(autoValueType: string): string => {
|
(autoValueType: string): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
switch (autoValueType) {
|
switch (autoValueType) {
|
||||||
case "current_datetime":
|
case "current_datetime":
|
||||||
return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss
|
return `${localDate} ${localTime}`;
|
||||||
case "current_date":
|
case "current_date":
|
||||||
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
return localDate;
|
||||||
case "current_time":
|
case "current_time":
|
||||||
return now.toTimeString().slice(0, 8); // HH:mm:ss
|
return localTime;
|
||||||
case "current_user":
|
case "current_user":
|
||||||
return currentUser?.userName || currentUser?.userId || "unknown_user";
|
return currentUser?.userName || currentUser?.userId || "unknown_user";
|
||||||
case "uuid":
|
case "uuid":
|
||||||
|
|
|
||||||
|
|
@ -357,13 +357,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 자동값 생성 함수
|
// 자동값 생성 함수
|
||||||
const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise<string> => {
|
const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise<string> => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
switch (autoValueType) {
|
switch (autoValueType) {
|
||||||
case "current_datetime":
|
case "current_datetime":
|
||||||
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
return `${localDate} ${localTime}`;
|
||||||
case "current_date":
|
case "current_date":
|
||||||
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
return localDate;
|
||||||
case "current_time":
|
case "current_time":
|
||||||
return now.toTimeString().slice(0, 8); // HH:mm:ss
|
return localTime;
|
||||||
case "current_user":
|
case "current_user":
|
||||||
// 실제 접속중인 사용자명 사용
|
// 실제 접속중인 사용자명 사용
|
||||||
return userName || "사용자"; // 사용자명이 없으면 기본값
|
return userName || "사용자"; // 사용자명이 없으면 기본값
|
||||||
|
|
|
||||||
|
|
@ -183,13 +183,16 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const generateAutoValue = useCallback(
|
const generateAutoValue = useCallback(
|
||||||
(autoValueType: string): string => {
|
(autoValueType: string): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
switch (autoValueType) {
|
switch (autoValueType) {
|
||||||
case "current_datetime":
|
case "current_datetime":
|
||||||
return now.toISOString().slice(0, 19).replace("T", " ");
|
return `${localDate} ${localTime}`;
|
||||||
case "current_date":
|
case "current_date":
|
||||||
return now.toISOString().slice(0, 10);
|
return localDate;
|
||||||
case "current_time":
|
case "current_time":
|
||||||
return now.toTimeString().slice(0, 8);
|
return localTime;
|
||||||
case "current_user":
|
case "current_user":
|
||||||
return userName || "사용자";
|
return userName || "사용자";
|
||||||
case "uuid":
|
case "uuid":
|
||||||
|
|
|
||||||
|
|
@ -3852,7 +3852,6 @@ function ControlManagementTab({
|
||||||
openModalWithData: "데이터+모달",
|
openModalWithData: "데이터+모달",
|
||||||
openRelatedModal: "연관모달",
|
openRelatedModal: "연관모달",
|
||||||
transferData: "데이터전달",
|
transferData: "데이터전달",
|
||||||
quickInsert: "즉시저장",
|
|
||||||
control: "제어흐름",
|
control: "제어흐름",
|
||||||
view_table_history: "이력보기",
|
view_table_history: "이력보기",
|
||||||
excel_download: "엑셀다운",
|
excel_download: "엑셀다운",
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,11 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
// 현재 날짜 설정
|
// 현재 날짜 설정
|
||||||
const setCurrentDate = (field: "minDate" | "maxDate" | "defaultValue") => {
|
const setCurrentDate = (field: "minDate" | "maxDate" | "defaultValue") => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const d = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
const dateString = localConfig.showTime
|
const dateString = localConfig.showTime
|
||||||
? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm
|
? `${d}T${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||||
: now.toISOString().slice(0, 10); // YYYY-MM-DD
|
: d;
|
||||||
updateConfig(field, dateString);
|
updateConfig(field, dateString);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,6 @@ export const BasicTab: React.FC<ButtonTabProps> = ({
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
{/* 고급 기능 */}
|
{/* 고급 기능 */}
|
||||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
<SelectItem value="approval">결재 요청</SelectItem>
|
<SelectItem value="approval">결재 요청</SelectItem>
|
||||||
|
|
||||||
|
|
@ -271,9 +270,6 @@ export const BasicTab: React.FC<ButtonTabProps> = ({
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||||
|
|
||||||
{/* 이벤트 버스 */}
|
|
||||||
<SelectItem value="event">이벤트 발송</SelectItem>
|
|
||||||
|
|
||||||
{/* 복사 */}
|
{/* 복사 */}
|
||||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1018,7 +1018,8 @@ export function FlowWidget({
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Data");
|
XLSX.utils.book_append_sheet(wb, ws, "Data");
|
||||||
|
|
||||||
const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.xlsx`;
|
const _fxd = new Date();
|
||||||
|
const fileName = `${flowName || "flow"}_data_${_fxd.getFullYear()}-${String(_fxd.getMonth() + 1).padStart(2, "0")}-${String(_fxd.getDate()).padStart(2, "0")}.xlsx`;
|
||||||
XLSX.writeFile(wb, fileName);
|
XLSX.writeFile(wb, fileName);
|
||||||
|
|
||||||
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
|
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
|
||||||
|
|
@ -1183,7 +1184,8 @@ export function FlowWidget({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.pdf`;
|
const _fpd = new Date();
|
||||||
|
const fileName = `${flowName || "flow"}_data_${_fpd.getFullYear()}-${String(_fpd.getMonth() + 1).padStart(2, "0")}-${String(_fpd.getDate()).padStart(2, "0")}.pdf`;
|
||||||
doc.save(fileName);
|
doc.save(fileName);
|
||||||
|
|
||||||
toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" });
|
toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" });
|
||||||
|
|
|
||||||
|
|
@ -477,9 +477,6 @@ export function TabsWidget({
|
||||||
<div key={tab.id} className="relative">
|
<div key={tab.id} className="relative">
|
||||||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.components && tab.components.length > 0 && (
|
|
||||||
<span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{allowCloseable && (
|
{allowCloseable && (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,10 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
||||||
const getDefaultValue = (): string => {
|
const getDefaultValue = (): string => {
|
||||||
if (config?.defaultValue === "current") {
|
if (config?.defaultValue === "current") {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (isDatetime) return now.toISOString().slice(0, 16);
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
return now.toISOString().slice(0, 10);
|
const d = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
if (isDatetime) return `${d}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -680,11 +680,15 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
switch (col.autoFill.type) {
|
switch (col.autoFill.type) {
|
||||||
case "currentDate":
|
case "currentDate": {
|
||||||
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
case "currentDateTime":
|
case "currentDateTime": {
|
||||||
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
case "sequence":
|
case "sequence":
|
||||||
return rowIndex + 1; // 1부터 시작하는 순번
|
return rowIndex + 1; // 1부터 시작하는 순번
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,12 @@ function formatTel(value: string): string {
|
||||||
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
|
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서울: 02 → 2-4-4
|
// 서울: 02 → 9자리 2-3-4, 10자리 2-4-4
|
||||||
if (digits.startsWith("02")) {
|
if (digits.startsWith("02")) {
|
||||||
if (digits.length <= 2) return digits;
|
if (digits.length <= 2) return digits;
|
||||||
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
|
if (digits.length <= 5) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
|
||||||
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;
|
const mid = digits.length >= 10 ? 4 : 3;
|
||||||
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
|
return `${digits.slice(0, 2)}-${digits.slice(2, 2 + mid)}-${digits.slice(2 + mid, 2 + mid + 4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 안심번호: 050x → 4-4-4
|
// 안심번호: 050x → 4-4-4
|
||||||
|
|
@ -135,6 +135,14 @@ const TextInput = forwardRef<
|
||||||
const [hasBlurred, setHasBlurred] = useState(false);
|
const [hasBlurred, setHasBlurred] = useState(false);
|
||||||
const [validationError, setValidationError] = useState<string>("");
|
const [validationError, setValidationError] = useState<string>("");
|
||||||
|
|
||||||
|
// 커서 위치 보존을 위한 내부 ref
|
||||||
|
const innerRef = useRef<HTMLInputElement>(null);
|
||||||
|
const combinedRef = (node: HTMLInputElement | null) => {
|
||||||
|
(innerRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||||
|
};
|
||||||
|
|
||||||
// 형식에 따른 값 포맷팅
|
// 형식에 따른 값 포맷팅
|
||||||
const formatValue = useCallback(
|
const formatValue = useCallback(
|
||||||
(val: string): string => {
|
(val: string): string => {
|
||||||
|
|
@ -154,11 +162,15 @@ const TextInput = forwardRef<
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = e.target;
|
||||||
|
const cursorPos = input.selectionStart ?? 0;
|
||||||
let newValue = e.target.value;
|
let newValue = e.target.value;
|
||||||
|
const oldValue = input.value;
|
||||||
|
|
||||||
|
const needsCursorFix = format === "biz_no" || format === "tel" || format === "currency";
|
||||||
|
|
||||||
// 형식에 따른 자동 포맷팅
|
// 형식에 따른 자동 포맷팅
|
||||||
if (format === "currency") {
|
if (format === "currency") {
|
||||||
// 숫자와 쉼표만 허용
|
|
||||||
newValue = newValue.replace(/[^\d,]/g, "");
|
newValue = newValue.replace(/[^\d,]/g, "");
|
||||||
newValue = formatCurrency(newValue);
|
newValue = formatCurrency(newValue);
|
||||||
} else if (format === "biz_no") {
|
} else if (format === "biz_no") {
|
||||||
|
|
@ -167,6 +179,20 @@ const TextInput = forwardRef<
|
||||||
newValue = formatTel(newValue);
|
newValue = formatTel(newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 포맷팅 후 커서 위치 보정 (하이픈/쉼표 개수 차이 기반)
|
||||||
|
if (needsCursorFix) {
|
||||||
|
const separator = format === "currency" ? /,/g : /-/g;
|
||||||
|
const oldSeps = (oldValue.slice(0, cursorPos).match(separator) || []).length;
|
||||||
|
const newSeps = (newValue.slice(0, cursorPos).match(separator) || []).length;
|
||||||
|
const adjustedCursor = Math.min(cursorPos + (newSeps - oldSeps), newValue.length);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (innerRef.current) {
|
||||||
|
innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 입력 중 에러 표시 해제 (입력 중에는 관대하게)
|
// 입력 중 에러 표시 해제 (입력 중에는 관대하게)
|
||||||
if (hasBlurred && validationError) {
|
if (hasBlurred && validationError) {
|
||||||
const { isValid } = validateInputFormat(newValue, format);
|
const { isValid } = validateInputFormat(newValue, format);
|
||||||
|
|
@ -244,7 +270,7 @@ const TextInput = forwardRef<
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={combinedRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -1202,7 +1228,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-1",
|
"flex",
|
||||||
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
|
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1041,12 +1041,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
switch (col.autoFill.type) {
|
switch (col.autoFill.type) {
|
||||||
case "currentDate":
|
case "currentDate":
|
||||||
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
return localDate;
|
||||||
|
|
||||||
case "currentDateTime":
|
case "currentDateTime":
|
||||||
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
return `${localDate} ${localTime}`;
|
||||||
|
|
||||||
case "sequence":
|
case "sequence":
|
||||||
return rowIndex + 1; // 1부터 시작하는 순번
|
return rowIndex + 1; // 1부터 시작하는 순번
|
||||||
|
|
|
||||||
|
|
@ -1291,7 +1291,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-1",
|
"flex",
|
||||||
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
|
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
|
||||||
isDesignMode && "pointer-events-none",
|
isDesignMode && "pointer-events-none",
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,6 @@ const ACTION_TYPE_CARDS = [
|
||||||
title: "엑셀 업로드",
|
title: "엑셀 업로드",
|
||||||
description: "엑셀 파일을 올려요",
|
description: "엑셀 파일을 올려요",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "quickInsert",
|
|
||||||
icon: Zap,
|
|
||||||
title: "즉시 저장",
|
|
||||||
description: "바로 저장해요",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "approval",
|
value: "approval",
|
||||||
icon: Check,
|
icon: Check,
|
||||||
|
|
@ -148,12 +142,6 @@ const ACTION_TYPE_CARDS = [
|
||||||
title: "제어 흐름",
|
title: "제어 흐름",
|
||||||
description: "흐름을 제어해요",
|
description: "흐름을 제어해요",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "event",
|
|
||||||
icon: Send,
|
|
||||||
title: "이벤트 발송",
|
|
||||||
description: "이벤트를 보내요",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "copy",
|
value: "copy",
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
|
|
@ -399,13 +387,51 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
if (targetTable) {
|
if (targetTable) {
|
||||||
const cols = await loadTableColumns(targetTable);
|
const cols = await loadTableColumns(targetTable);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullResponse = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
||||||
|
let fullColumnData = fullResponse.data?.data;
|
||||||
|
if (!Array.isArray(fullColumnData) && fullColumnData?.columns) fullColumnData = fullColumnData.columns;
|
||||||
|
if (!Array.isArray(fullColumnData) && fullColumnData?.data) fullColumnData = fullColumnData.data;
|
||||||
|
|
||||||
|
if (Array.isArray(fullColumnData)) {
|
||||||
|
const refTableSet = new Set<string>();
|
||||||
|
fullColumnData.forEach((col: any) => {
|
||||||
|
const inputType = col.inputType || col.input_type;
|
||||||
|
if (inputType !== "entity") return;
|
||||||
|
let refTable = col.referenceTable || col.reference_table;
|
||||||
|
if (!refTable && col.detailSettings) {
|
||||||
|
try {
|
||||||
|
const ds = typeof col.detailSettings === "string" ? JSON.parse(col.detailSettings) : col.detailSettings;
|
||||||
|
refTable = ds?.referenceTable;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (refTable) refTableSet.add(refTable);
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetColumnNames = new Set(cols.map((c) => c.name));
|
||||||
|
for (const refTable of refTableSet) {
|
||||||
|
const refCols = await loadTableColumns(refTable);
|
||||||
|
const refTableLabel = availableTables.find((t) => t.name === refTable)?.label || refTable;
|
||||||
|
refCols.forEach((rc) => {
|
||||||
|
if (!targetColumnNames.has(rc.name)) {
|
||||||
|
cols.push({
|
||||||
|
name: rc.name,
|
||||||
|
label: `${rc.label} [${refTableLabel}]`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
setMappingTargetColumns(cols);
|
setMappingTargetColumns(cols);
|
||||||
} else {
|
} else {
|
||||||
setMappingTargetColumns([]);
|
setMappingTargetColumns([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadAll();
|
loadAll();
|
||||||
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]);
|
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
|
||||||
|
|
||||||
// 화면 목록 로드 (모달 액션용)
|
// 화면 목록 로드 (모달 액션용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -180,12 +180,13 @@ function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: nu
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
|
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
|
||||||
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
|
function ColumnEditor({ columns, onChange, tableName, title, icon, extraContent }: {
|
||||||
columns: ColumnDef[];
|
columns: ColumnDef[];
|
||||||
onChange: (cols: ColumnDef[]) => void;
|
onChange: (cols: ColumnDef[]) => void;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
extraContent?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -263,6 +264,7 @@ function ColumnEditor({ columns, onChange, tableName, title, icon }: {
|
||||||
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
|
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
|
||||||
<Plus className="h-3 w-3" /> 컬럼 추가
|
<Plus className="h-3 w-3" /> 컬럼 추가
|
||||||
</Button>
|
</Button>
|
||||||
|
{extraContent}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
@ -378,13 +380,34 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
|
{/* ─── 품목 추가 모달 (컬럼 + 크기 설정) ─── */}
|
||||||
<ColumnEditor
|
<ColumnEditor
|
||||||
columns={config.modalDisplayColumns || []}
|
columns={config.modalDisplayColumns || []}
|
||||||
onChange={(cols) => update({ modalDisplayColumns: cols })}
|
onChange={(cols) => update({ modalDisplayColumns: cols })}
|
||||||
tableName={config.dataSource.itemTable}
|
tableName={config.dataSource.itemTable}
|
||||||
title="품목 추가 모달 컬럼"
|
title="품목 추가 모달"
|
||||||
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
extraContent={
|
||||||
|
<div className="mt-3 space-y-2 border-t pt-3">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">모달 크기 (px)</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">가로 (너비)</span>
|
||||||
|
<Input type="number" min={300} max={1600}
|
||||||
|
value={parseInt(config.addModalMaxWidth || "600")}
|
||||||
|
onChange={(e) => update({ addModalMaxWidth: `${e.target.value}px` })}
|
||||||
|
placeholder="600" className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">세로 (목록 높이)</span>
|
||||||
|
<Input type="number" min={150} max={900}
|
||||||
|
value={parseInt(config.addModalListMaxHeight || "340")}
|
||||||
|
onChange={(e) => update({ addModalListMaxHeight: `${e.target.value}px` })}
|
||||||
|
placeholder="340" className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─── 품목 필터 조건 ─── */}
|
{/* ─── 품목 필터 조건 ─── */}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,33 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 공정 작업기준 설정 패널
|
* V2 공정 작업기준 설정 패널 (간소화)
|
||||||
* Progressive Disclosure: 작업 단계 -> 상세 유형 -> 고급 설정(접힘)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Settings, ChevronDown, ChevronRight, Plus, Trash2, Database, Layers, List } from "lucide-react";
|
import {
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Database,
|
||||||
|
Layers,
|
||||||
|
List,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
ProcessWorkStandardConfig,
|
ProcessWorkStandardConfig,
|
||||||
|
|
@ -20,26 +36,87 @@ import type {
|
||||||
} from "@/lib/registry/components/v2-process-work-standard/types";
|
} from "@/lib/registry/components/v2-process-work-standard/types";
|
||||||
import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config";
|
import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config";
|
||||||
|
|
||||||
|
interface TableInfo { tableName: string; displayName?: string; }
|
||||||
|
|
||||||
|
function TableCombobox({ value, onChange, tables, loading, label }: {
|
||||||
|
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean; label: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-[200px] justify-between text-xs" disabled={loading}>
|
||||||
|
<span className="truncate">{loading ? "로딩..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}</span>
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="end">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
|
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && <span className="text-muted-foreground text-[10px]">{t.tableName}</span>}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface V2ProcessWorkStandardConfigPanelProps {
|
interface V2ProcessWorkStandardConfigPanelProps {
|
||||||
config: Partial<ProcessWorkStandardConfig>;
|
config: Partial<ProcessWorkStandardConfig>;
|
||||||
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
|
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardConfigPanelProps> = ({
|
export const V2ProcessWorkStandardConfigPanel: React.FC<
|
||||||
config: configProp,
|
V2ProcessWorkStandardConfigPanelProps
|
||||||
onChange,
|
> = ({ config: configProp, onChange }) => {
|
||||||
}) => {
|
|
||||||
const [phasesOpen, setPhasesOpen] = useState(false);
|
const [phasesOpen, setPhasesOpen] = useState(false);
|
||||||
const [detailTypesOpen, setDetailTypesOpen] = useState(false);
|
const [detailTypesOpen, setDetailTypesOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [layoutOpen, setLayoutOpen] = useState(false);
|
||||||
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const res = await tableManagementApi.getTableList();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const config: ProcessWorkStandardConfig = {
|
const config: ProcessWorkStandardConfig = {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
...configProp,
|
...configProp,
|
||||||
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases,
|
phases: configProp?.phases?.length
|
||||||
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
|
? configProp.phases
|
||||||
|
: defaultConfig.phases,
|
||||||
|
detailTypes: configProp?.detailTypes?.length
|
||||||
|
? configProp.detailTypes
|
||||||
|
: defaultConfig.detailTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
|
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
|
||||||
|
|
@ -50,13 +127,16 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
update({ dataSource: { ...config.dataSource, [field]: value } });
|
update({ dataSource: { ...config.dataSource, [field]: value } });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 작업 단계 관리 ───
|
|
||||||
const addPhase = () => {
|
const addPhase = () => {
|
||||||
const nextOrder = config.phases.length + 1;
|
const nextOrder = config.phases.length + 1;
|
||||||
update({
|
update({
|
||||||
phases: [
|
phases: [
|
||||||
...config.phases,
|
...config.phases,
|
||||||
{ key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder },
|
{
|
||||||
|
key: `PHASE_${nextOrder}`,
|
||||||
|
label: `단계 ${nextOrder}`,
|
||||||
|
sortOrder: nextOrder,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -65,18 +145,24 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
update({ phases: config.phases.filter((_, i) => i !== idx) });
|
update({ phases: config.phases.filter((_, i) => i !== idx) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => {
|
const updatePhase = (
|
||||||
|
idx: number,
|
||||||
|
field: keyof WorkPhaseDefinition,
|
||||||
|
value: string | number,
|
||||||
|
) => {
|
||||||
const next = [...config.phases];
|
const next = [...config.phases];
|
||||||
next[idx] = { ...next[idx], [field]: value };
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
update({ phases: next });
|
update({ phases: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 상세 유형 관리 ───
|
|
||||||
const addDetailType = () => {
|
const addDetailType = () => {
|
||||||
update({
|
update({
|
||||||
detailTypes: [
|
detailTypes: [
|
||||||
...config.detailTypes,
|
...config.detailTypes,
|
||||||
{ value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" },
|
{
|
||||||
|
value: `TYPE_${config.detailTypes.length + 1}`,
|
||||||
|
label: "신규 유형",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -85,7 +171,11 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
|
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => {
|
const updateDetailType = (
|
||||||
|
idx: number,
|
||||||
|
field: keyof DetailTypeDefinition,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
const next = [...config.detailTypes];
|
const next = [...config.detailTypes];
|
||||||
next[idx] = { ...next[idx], [field]: value };
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
update({ detailTypes: next });
|
update({ detailTypes: next });
|
||||||
|
|
@ -93,31 +183,75 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ─── 1단계: 작업 단계 설정 (Collapsible + 접이식 카드) ─── */}
|
{/* 품목 목록 모드 */}
|
||||||
|
<div className="space-y-2 rounded-lg border p-4">
|
||||||
|
<span className="text-sm font-medium">품목 목록 모드</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||||
|
(config.itemListMode || "all") === "all"
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-input hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
onClick={() => update({ itemListMode: "all" })}
|
||||||
|
>
|
||||||
|
<span className="font-medium">전체 품목</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
모든 품목 표시
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||||
|
config.itemListMode === "registered"
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-input hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
onClick={() => update({ itemListMode: "registered" })}
|
||||||
|
>
|
||||||
|
<span className="font-medium">등록 품목만</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
라우팅에 등록된 품목만
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{config.itemListMode === "registered" && (
|
||||||
|
<p className="text-muted-foreground pt-1 text-[10px]">
|
||||||
|
품목별 라우팅 탭에서 등록한 품목만 표시됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 단계 */}
|
||||||
<Collapsible open={phasesOpen} onOpenChange={setPhasesOpen}>
|
<Collapsible open={phasesOpen} onOpenChange={setPhasesOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-sm font-medium">작업 단계</span>
|
<span className="text-sm font-medium">작업 단계</span>
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||||
{config.phases.length}개
|
{config.phases.length}개
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
"text-muted-foreground h-4 w-4 transition-transform duration-200",
|
||||||
phasesOpen && "rotate-180",
|
phasesOpen && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
<div className="space-y-1.5 rounded-b-lg border border-t-0 p-3">
|
||||||
<p className="text-[10px] text-muted-foreground mb-1">공정별 작업 단계(Phase)를 정의</p>
|
<p className="text-muted-foreground mb-1 text-[10px]">
|
||||||
|
공정별 작업 단계를 정의
|
||||||
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{config.phases.map((phase, idx) => (
|
{config.phases.map((phase, idx) => (
|
||||||
<Collapsible key={idx}>
|
<Collapsible key={idx}>
|
||||||
|
|
@ -125,18 +259,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
|
className="hover:bg-muted/30 flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
|
||||||
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
<span className="text-muted-foreground shrink-0 text-[10px] font-medium">
|
||||||
<span className="text-xs font-medium truncate flex-1 min-w-0">{phase.label}</span>
|
#{idx + 1}
|
||||||
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{phase.key}</Badge>
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-4 shrink-0 text-[9px]"
|
||||||
|
>
|
||||||
|
{phase.key}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); removePhase(idx); }}
|
onClick={(e) => {
|
||||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
e.stopPropagation();
|
||||||
|
removePhase(idx);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
|
||||||
disabled={config.phases.length <= 1}
|
disabled={config.phases.length <= 1}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
|
|
@ -146,32 +292,45 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="grid grid-cols-3 gap-1.5 border-t px-2.5 py-2">
|
<div className="grid grid-cols-3 gap-1.5 border-t px-2.5 py-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">키</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
키
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={phase.key}
|
value={phase.key}
|
||||||
onChange={(e) => updatePhase(idx, "key", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updatePhase(idx, "key", e.target.value)
|
||||||
|
}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
placeholder="키"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">표시명</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
표시명
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={phase.label}
|
value={phase.label}
|
||||||
onChange={(e) => updatePhase(idx, "label", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updatePhase(idx, "label", e.target.value)
|
||||||
|
}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
placeholder="표시명"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">순서</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
순서
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={phase.sortOrder}
|
value={phase.sortOrder}
|
||||||
onChange={(e) => updatePhase(idx, "sortOrder", parseInt(e.target.value) || 1)}
|
onChange={(e) =>
|
||||||
className="h-7 text-xs text-center"
|
updatePhase(
|
||||||
placeholder="1"
|
idx,
|
||||||
|
"sortOrder",
|
||||||
|
parseInt(e.target.value) || 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 text-center text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -183,7 +342,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-full gap-1 text-xs border-dashed"
|
className="h-7 w-full gap-1 border-dashed text-xs"
|
||||||
onClick={addPhase}
|
onClick={addPhase}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
|
|
@ -193,31 +352,33 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 2단계: 상세 유형 옵션 (Collapsible + 접이식 카드) ─── */}
|
{/* 상세 유형 */}
|
||||||
<Collapsible open={detailTypesOpen} onOpenChange={setDetailTypesOpen}>
|
<Collapsible open={detailTypesOpen} onOpenChange={setDetailTypesOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<List className="h-4 w-4 text-muted-foreground" />
|
<List className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-sm font-medium">상세 유형</span>
|
<span className="text-sm font-medium">상세 유형</span>
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||||
{config.detailTypes.length}개
|
{config.detailTypes.length}개
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
"text-muted-foreground h-4 w-4 transition-transform duration-200",
|
||||||
detailTypesOpen && "rotate-180",
|
detailTypesOpen && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
<div className="space-y-1.5 rounded-b-lg border border-t-0 p-3">
|
||||||
<p className="text-[10px] text-muted-foreground mb-1">작업 항목의 상세 유형 드롭다운 옵션</p>
|
<p className="text-muted-foreground mb-1 text-[10px]">
|
||||||
|
작업 항목의 상세 유형 옵션
|
||||||
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{config.detailTypes.map((dt, idx) => (
|
{config.detailTypes.map((dt, idx) => (
|
||||||
<Collapsible key={idx}>
|
<Collapsible key={idx}>
|
||||||
|
|
@ -225,18 +386,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
|
className="hover:bg-muted/30 flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
|
||||||
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
<span className="text-muted-foreground shrink-0 text-[10px] font-medium">
|
||||||
<span className="text-xs font-medium truncate flex-1 min-w-0">{dt.label}</span>
|
#{idx + 1}
|
||||||
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{dt.value}</Badge>
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||||
|
{dt.label}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-4 shrink-0 text-[9px]"
|
||||||
|
>
|
||||||
|
{dt.value}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); removeDetailType(idx); }}
|
onClick={(e) => {
|
||||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
e.stopPropagation();
|
||||||
|
removeDetailType(idx);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"
|
||||||
disabled={config.detailTypes.length <= 1}
|
disabled={config.detailTypes.length <= 1}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
|
|
@ -246,21 +419,27 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">값</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
값
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={dt.value}
|
value={dt.value}
|
||||||
onChange={(e) => updateDetailType(idx, "value", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateDetailType(idx, "value", e.target.value)
|
||||||
|
}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
placeholder="값"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[10px] text-muted-foreground">표시명</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
표시명
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={dt.label}
|
value={dt.label}
|
||||||
onChange={(e) => updateDetailType(idx, "label", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateDetailType(idx, "label", e.target.value)
|
||||||
|
}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
placeholder="표시명"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -272,7 +451,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-full gap-1 text-xs border-dashed"
|
className="h-7 w-full gap-1 border-dashed text-xs"
|
||||||
onClick={addDetailType}
|
onClick={addDetailType}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
|
|
@ -282,179 +461,102 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 3단계: 고급 설정 (데이터 소스 + 레이아웃 통합) ─── */}
|
{/* 데이터 소스 (테이블만) */}
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Database className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-sm font-medium">고급 설정</span>
|
<span className="text-sm font-medium">테이블 설정</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
"text-muted-foreground h-4 w-4 transition-transform duration-200",
|
||||||
advancedOpen && "rotate-180",
|
dataSourceOpen && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-3">
|
<div className="space-y-2.5 rounded-b-lg border border-t-0 p-4">
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
테이블만 선택하면 컬럼 정보는 엔티티 설정에서 자동으로 가져옵니다.
|
||||||
|
</p>
|
||||||
|
<TableCombobox label="품목" value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
||||||
|
<TableCombobox label="라우팅 버전" value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
||||||
|
<TableCombobox label="라우팅 상세" value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
|
||||||
|
<TableCombobox label="공정 마스터" value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
{/* 레이아웃 기본 설정 */}
|
{/* 레이아웃 & 기타 */}
|
||||||
<div className="space-y-2">
|
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
||||||
<div className="flex items-center justify-between py-1">
|
<CollapsibleTrigger asChild>
|
||||||
<div>
|
<button
|
||||||
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
type="button"
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">품목/공정 선택 패널의 너비</p>
|
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>
|
>
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
type="number"
|
<Settings className="text-muted-foreground h-4 w-4" />
|
||||||
min={15}
|
<span className="text-sm font-medium">레이아웃</span>
|
||||||
max={50}
|
</div>
|
||||||
value={config.splitRatio || 30}
|
<ChevronDown
|
||||||
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 30 })}
|
className={cn(
|
||||||
className="h-7 w-[80px] text-xs"
|
"text-muted-foreground h-4 w-4 transition-transform duration-200",
|
||||||
/>
|
layoutOpen && "rotate-180",
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center justify-between py-1">
|
/>
|
||||||
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
</button>
|
||||||
<Input
|
</CollapsibleTrigger>
|
||||||
value={config.leftPanelTitle || ""}
|
<CollapsibleContent>
|
||||||
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
|
||||||
placeholder="품목 및 공정 선택"
|
<div className="flex items-center justify-between py-1">
|
||||||
className="h-7 w-[140px] text-xs"
|
<div>
|
||||||
/>
|
<span className="text-muted-foreground text-xs">
|
||||||
</div>
|
좌측 패널 비율 (%)
|
||||||
<div className="flex items-center justify-between py-1">
|
</span>
|
||||||
<div>
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
<p className="text-xs">읽기 전용</p>
|
품목/공정 선택 패널의 너비
|
||||||
<p className="text-[10px] text-muted-foreground">수정/삭제 버튼을 숨겨요</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Input
|
||||||
checked={config.readonly || false}
|
type="number"
|
||||||
onCheckedChange={(checked) => update({ readonly: checked })}
|
min={15}
|
||||||
/>
|
max={50}
|
||||||
</div>
|
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>
|
</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>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
@ -462,6 +564,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC<V2ProcessWorkStandardCon
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
V2ProcessWorkStandardConfigPanel.displayName = "V2ProcessWorkStandardConfigPanel";
|
V2ProcessWorkStandardConfigPanel.displayName =
|
||||||
|
"V2ProcessWorkStandardConfigPanel";
|
||||||
|
|
||||||
export default V2ProcessWorkStandardConfigPanel;
|
export default V2ProcessWorkStandardConfigPanel;
|
||||||
|
|
|
||||||
|
|
@ -1125,28 +1125,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="검색"
|
label="검색"
|
||||||
checked={config.leftPanel?.showSearch ?? true}
|
checked={config.leftPanel?.showSearch ?? false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateLeftPanel({ showSearch: checked })
|
updateLeftPanel({ showSearch: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="추가 버튼"
|
label="추가 버튼"
|
||||||
checked={config.leftPanel?.showAdd ?? true}
|
checked={config.leftPanel?.showAdd ?? false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateLeftPanel({ showAdd: checked })
|
updateLeftPanel({ showAdd: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="수정 버튼"
|
label="수정 버튼"
|
||||||
checked={config.leftPanel?.showEdit ?? false}
|
checked={config.leftPanel?.showEdit ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateLeftPanel({ showEdit: checked })
|
updateLeftPanel({ showEdit: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="삭제 버튼"
|
label="삭제 버튼"
|
||||||
checked={config.leftPanel?.showDelete ?? false}
|
checked={config.leftPanel?.showDelete ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateLeftPanel({ showDelete: checked })
|
updateLeftPanel({ showDelete: checked })
|
||||||
}
|
}
|
||||||
|
|
@ -1574,28 +1574,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="검색"
|
label="검색"
|
||||||
checked={config.rightPanel?.showSearch ?? true}
|
checked={config.rightPanel?.showSearch ?? false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateRightPanel({ showSearch: checked })
|
updateRightPanel({ showSearch: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="추가 버튼"
|
label="추가 버튼"
|
||||||
checked={config.rightPanel?.showAdd ?? true}
|
checked={config.rightPanel?.showAdd ?? false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateRightPanel({ showAdd: checked })
|
updateRightPanel({ showAdd: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="수정 버튼"
|
label="수정 버튼"
|
||||||
checked={config.rightPanel?.showEdit ?? false}
|
checked={config.rightPanel?.showEdit ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateRightPanel({ showEdit: checked })
|
updateRightPanel({ showEdit: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
label="삭제 버튼"
|
label="삭제 버튼"
|
||||||
checked={config.rightPanel?.showDelete ?? false}
|
checked={config.rightPanel?.showDelete ?? true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateRightPanel({ showDelete: checked })
|
updateRightPanel({ showDelete: checked })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,10 @@ export default function VehicleReport() {
|
||||||
// 일별 통계
|
// 일별 통계
|
||||||
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
|
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
|
||||||
const [dailyStartDate, setDailyStartDate] = useState(
|
const [dailyStartDate, setDailyStartDate] = useState(
|
||||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
|
(() => { const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })()
|
||||||
);
|
);
|
||||||
const [dailyEndDate, setDailyEndDate] = useState(
|
const [dailyEndDate, setDailyEndDate] = useState(
|
||||||
new Date().toISOString().split("T")[0]
|
(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })()
|
||||||
);
|
);
|
||||||
const [dailyLoading, setDailyLoading] = useState(false);
|
const [dailyLoading, setDailyLoading] = useState(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -808,12 +808,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
? component.style?.labelText || (component as any).label || component.componentConfig?.label
|
? component.style?.labelText || (component as any).label || component.componentConfig?.label
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리
|
// 🔧 수평 라벨(left/right) 감지 → 외부 absolute 래퍼로 라벨 처리 (카테고리 셀렉트와 동일 방식)
|
||||||
// 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지)
|
|
||||||
const labelPosition = component.style?.labelPosition;
|
const labelPosition = component.style?.labelPosition;
|
||||||
const isV2Component = componentType?.startsWith("v2-");
|
const isV2Component = componentType?.startsWith("v2-");
|
||||||
const needsExternalHorizLabel = !!(
|
const needsExternalHorizLabel = !!(
|
||||||
!props.isDesignMode &&
|
|
||||||
isV2Component &&
|
isV2Component &&
|
||||||
effectiveLabel &&
|
effectiveLabel &&
|
||||||
(labelPosition === "left" || labelPosition === "right")
|
(labelPosition === "left" || labelPosition === "right")
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
// 자동생성 로직
|
// 자동생성 로직
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finalAutoGeneration?.enabled) {
|
if (finalAutoGeneration?.enabled) {
|
||||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
const n = new Date();
|
||||||
|
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}-${String(n.getDate()).padStart(2, "0")}`;
|
||||||
setAutoGeneratedValue(today);
|
setAutoGeneratedValue(today);
|
||||||
|
|
||||||
// 인터랙티브 모드에서 폼 데이터에도 설정
|
// 인터랙티브 모드에서 폼 데이터에도 설정
|
||||||
|
|
|
||||||
|
|
@ -154,48 +154,53 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
||||||
const prevRecordIdRef = useRef<any>(null);
|
const prevRecordIdRef = useRef<any>(null);
|
||||||
|
const prevIsRecordModeRef = useRef<boolean | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevRecordIdRef.current !== recordId) {
|
const recordIdChanged = prevRecordIdRef.current !== null && prevRecordIdRef.current !== recordId;
|
||||||
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
|
const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode;
|
||||||
prev: prevRecordIdRef.current,
|
|
||||||
current: recordId,
|
if (recordIdChanged || modeChanged) {
|
||||||
isRecordMode,
|
|
||||||
});
|
|
||||||
prevRecordIdRef.current = recordId;
|
prevRecordIdRef.current = recordId;
|
||||||
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
|
|
||||||
if (isRecordMode) {
|
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
|
setRepresentativeImageUrl(null);
|
||||||
|
|
||||||
|
// localStorage 캐시도 정리 (새 등록 모드 전환 시)
|
||||||
|
if (!isRecordMode) {
|
||||||
|
try {
|
||||||
|
const backupKey = getUniqueKey();
|
||||||
|
localStorage.removeItem(backupKey);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const globalFileState = (window as any).globalFileState || {};
|
||||||
|
delete globalFileState[backupKey];
|
||||||
|
(window as any).globalFileState = globalFileState;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
} else if (prevRecordIdRef.current === null) {
|
||||||
|
prevRecordIdRef.current = recordId;
|
||||||
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
}
|
}
|
||||||
}, [recordId, isRecordMode]);
|
}, [recordId, isRecordMode, getUniqueKey]);
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!component?.id) return;
|
if (!component?.id) return;
|
||||||
|
|
||||||
|
// 새 등록 모드(레코드 없음)에서는 localStorage 복원 스킵 - 빈 상태 유지
|
||||||
|
if (!isRecordMode || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔑 레코드별 고유 키 사용
|
// 🔑 레코드별 고유 키 사용
|
||||||
const backupKey = getUniqueKey();
|
const backupKey = getUniqueKey();
|
||||||
const backupFiles = localStorage.getItem(backupKey);
|
const backupFiles = localStorage.getItem(backupKey);
|
||||||
console.log("🔎 [DEBUG-MOUNT] localStorage 확인:", {
|
|
||||||
backupKey,
|
|
||||||
hasBackup: !!backupFiles,
|
|
||||||
componentId: component.id,
|
|
||||||
recordId: recordId,
|
|
||||||
formDataId: formData?.id,
|
|
||||||
stackTrace: new Error().stack?.split('\n').slice(1, 4).join(' <- '),
|
|
||||||
});
|
|
||||||
if (backupFiles) {
|
if (backupFiles) {
|
||||||
const parsedFiles = JSON.parse(backupFiles);
|
const parsedFiles = JSON.parse(backupFiles);
|
||||||
if (parsedFiles.length > 0) {
|
if (parsedFiles.length > 0) {
|
||||||
console.log("🚀 [DEBUG-MOUNT] 파일 즉시 복원:", {
|
|
||||||
uniqueKey: backupKey,
|
|
||||||
componentId: component.id,
|
|
||||||
recordId: recordId,
|
|
||||||
restoredFiles: parsedFiles.length,
|
|
||||||
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
|
||||||
});
|
|
||||||
setUploadedFiles(parsedFiles);
|
setUploadedFiles(parsedFiles);
|
||||||
|
|
||||||
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
||||||
|
|
@ -210,7 +215,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
||||||
}
|
}
|
||||||
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
|
}, [component.id, getUniqueKey, recordId, isRecordMode]);
|
||||||
|
|
||||||
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -325,9 +330,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const loadComponentFiles = useCallback(async () => {
|
const loadComponentFiles = useCallback(async () => {
|
||||||
if (!component?.id) return false;
|
if (!component?.id) return false;
|
||||||
|
|
||||||
|
// 새 등록 모드(레코드 없음)에서는 파일 조회 스킵 - 빈 상태 유지
|
||||||
|
if (!isRecordMode || !recordId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
||||||
if (isRecordMode && recordTableName && recordId) {
|
if (recordTableName) {
|
||||||
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
||||||
tableName: recordTableName,
|
tableName: recordTableName,
|
||||||
recordId: recordId,
|
recordId: recordId,
|
||||||
|
|
@ -457,17 +467,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const componentFiles = (component as any)?.uploadedFiles || [];
|
const componentFiles = (component as any)?.uploadedFiles || [];
|
||||||
const lastUpdate = (component as any)?.lastFileUpdate;
|
|
||||||
|
|
||||||
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
|
|
||||||
componentId: component.id,
|
|
||||||
componentFiles: componentFiles.length,
|
|
||||||
formData: formData,
|
|
||||||
screenId: formData?.screenId,
|
|
||||||
tableName: formData?.tableName, // 🔍 테이블명 확인
|
|
||||||
recordId: formData?.id, // 🔍 레코드 ID 확인
|
|
||||||
currentUploadedFiles: uploadedFiles.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
|
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
|
||||||
loadComponentFiles().then((dbLoadSuccess) => {
|
loadComponentFiles().then((dbLoadSuccess) => {
|
||||||
|
|
@ -475,15 +474,22 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
return; // DB 로드 성공 시 localStorage 무시
|
return; // DB 로드 성공 시 localStorage 무시
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
// 새 등록 모드(레코드 없음)에서는 fallback 로드도 스킵 - 항상 빈 상태 유지
|
||||||
|
if (!isRecordMode || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 전역 상태에서 최신 파일 정보 가져오기
|
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
||||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||||
const globalFiles = globalFileState[component.id] || [];
|
const uniqueKeyForFallback = getUniqueKey();
|
||||||
|
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
||||||
|
|
||||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
|
if (currentFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 최신 파일과 현재 파일 비교
|
// 최신 파일과 현재 파일 비교
|
||||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||||
|
|
@ -491,7 +497,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
setForceUpdate((prev) => prev + 1);
|
setForceUpdate((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate, isRecordMode, recordId, getUniqueKey]);
|
||||||
|
|
||||||
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
|
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -653,9 +653,9 @@ export function RepeaterTable({
|
||||||
if (typeof val === "string" && val.includes("T")) {
|
if (typeof val === "string" && val.includes("T")) {
|
||||||
return val.split("T")[0];
|
return val.split("T")[0];
|
||||||
}
|
}
|
||||||
// Date 객체이면 변환
|
// Date 객체이면 로컬 날짜로 변환
|
||||||
if (val instanceof Date) {
|
if (val instanceof Date) {
|
||||||
return val.toISOString().split("T")[0];
|
return `${val.getFullYear()}-${String(val.getMonth() + 1).padStart(2, "0")}-${String(val.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
return String(val);
|
return String(val);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,8 @@ export function SimpleRepeaterTableComponent({
|
||||||
} else if (col.type === "number") {
|
} else if (col.type === "number") {
|
||||||
newRow[col.field] = 0;
|
newRow[col.field] = 0;
|
||||||
} else if (col.type === "date") {
|
} else if (col.type === "date") {
|
||||||
newRow[col.field] = new Date().toISOString().split("T")[0];
|
const _n = new Date();
|
||||||
|
newRow[col.field] = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
|
||||||
} else {
|
} else {
|
||||||
newRow[col.field] = "";
|
newRow[col.field] = "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2707,7 +2707,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
|
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
|
||||||
|
|
||||||
// 파일명 생성
|
// 파일명 생성
|
||||||
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
const _en = new Date();
|
||||||
|
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`;
|
||||||
|
|
||||||
// 파일 다운로드
|
// 파일 다운로드
|
||||||
XLSX.writeFile(wb, fileName);
|
XLSX.writeFile(wb, fileName);
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
prevRecordIdRef.current = recordId;
|
prevRecordIdRef.current = recordId;
|
||||||
prevIsRecordModeRef.current = isRecordMode;
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
|
|
||||||
// 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화
|
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
|
||||||
// 등록 모드에서는 항상 빈 상태로 시작해야 함
|
setUploadedFiles([]);
|
||||||
if (isRecordMode || !recordId) {
|
setRepresentativeImageUrl(null);
|
||||||
setUploadedFiles([]);
|
filesLoadedFromObjidRef.current = false;
|
||||||
setRepresentativeImageUrl(null);
|
|
||||||
filesLoadedFromObjidRef.current = false;
|
|
||||||
}
|
|
||||||
} else if (prevIsRecordModeRef.current === null) {
|
} else if (prevIsRecordModeRef.current === null) {
|
||||||
// 초기 마운트 시 모드 저장
|
// 초기 마운트 시 모드 저장
|
||||||
prevIsRecordModeRef.current = isRecordMode;
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
|
|
@ -198,7 +195,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const imageObjidFromFormData = formData?.[columnName];
|
const imageObjidFromFormData = formData?.[columnName];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageObjidFromFormData) return;
|
if (!imageObjidFromFormData) {
|
||||||
|
// formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시)
|
||||||
|
if (uploadedFiles.length > 0 && !isRecordMode) {
|
||||||
|
setUploadedFiles([]);
|
||||||
|
filesLoadedFromObjidRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등록 모드(새 레코드)일 때는 이전 파일을 로드하지 않음
|
||||||
|
if (!isRecordMode) return;
|
||||||
|
|
||||||
const rawValue = String(imageObjidFromFormData);
|
const rawValue = String(imageObjidFromFormData);
|
||||||
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
||||||
|
|
@ -255,7 +262,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [imageObjidFromFormData, columnName, component.id]);
|
}, [imageObjidFromFormData, columnName, component.id, isRecordMode]);
|
||||||
|
|
||||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||||
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ export function ItemRoutingComponent({
|
||||||
|
|
||||||
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
|
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
|
||||||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
<DialogContent className="max-w-[95vw]" style={{ maxWidth: `min(95vw, ${config.addModalMaxWidth || "600px"})` }}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">품목 추가</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">품목 추가</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
|
@ -481,14 +481,14 @@ export function ItemRoutingComponent({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[340px] overflow-auto rounded-md border">
|
<div className="overflow-auto rounded-md border" style={{ maxHeight: config.addModalListMaxHeight || "340px" }}>
|
||||||
{allItems.length === 0 ? (
|
{allItems.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<p className="text-xs text-muted-foreground">품목이 없습니다</p>
|
<p className="text-xs text-muted-foreground">품목이 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table noWrapper>
|
||||||
<TableHeader>
|
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
|
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
|
||||||
{modalDisplayCols.map((col) => (
|
{modalDisplayCols.map((col) => (
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ export interface ItemRoutingConfig {
|
||||||
modalDisplayColumns?: ColumnDef[];
|
modalDisplayColumns?: ColumnDef[];
|
||||||
/** 품목 조회 시 사전 필터 조건 */
|
/** 품목 조회 시 사전 필터 조건 */
|
||||||
itemFilterConditions?: ItemFilterCondition[];
|
itemFilterConditions?: ItemFilterCondition[];
|
||||||
|
/** 품목 추가 모달 최대 너비 (px 또는 vw, 기본: 600px) */
|
||||||
|
addModalMaxWidth?: string;
|
||||||
|
/** 품목 추가 모달 목록 최대 높이 (px, 기본: 340px) */
|
||||||
|
addModalListMaxHeight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 Props
|
// 컴포넌트 Props
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,113 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
import { Plus, Trash2, GripVertical, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
|
import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
|
||||||
import { defaultConfig } from "./config";
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
interface TableInfo { tableName: string; displayName?: string; }
|
||||||
|
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
|
||||||
|
|
||||||
|
function TableCombobox({ value, onChange, tables, loading }: {
|
||||||
|
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" disabled={loading}>
|
||||||
|
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
|
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && <span className="text-[10px] text-muted-foreground">{t.tableName}</span>}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
|
||||||
|
value: string; onChange: (v: string) => void; tableName: string; placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableName) { setColumns([]); return; }
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (res.success && res.data?.columns) setColumns(res.data.columns);
|
||||||
|
} catch { /* ignore */ } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" disabled={loading || !tableName}>
|
||||||
|
<span className="truncate">
|
||||||
|
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => { onChange(c.columnName); setOpen(false); }} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{c.displayName || c.columnName}</span>
|
||||||
|
{c.displayName && <span className="text-[10px] text-muted-foreground">{c.columnName}</span>}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: Partial<ProcessWorkStandardConfig>;
|
config: Partial<ProcessWorkStandardConfig>;
|
||||||
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
|
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
|
||||||
|
|
@ -19,6 +117,9 @@ export function ProcessWorkStandardConfigPanel({
|
||||||
config: configProp,
|
config: configProp,
|
||||||
onChange,
|
onChange,
|
||||||
}: ConfigPanelProps) {
|
}: ConfigPanelProps) {
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
|
||||||
const config: ProcessWorkStandardConfig = {
|
const config: ProcessWorkStandardConfig = {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
...configProp,
|
...configProp,
|
||||||
|
|
@ -27,6 +128,20 @@ export function ProcessWorkStandardConfigPanel({
|
||||||
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
|
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const res = await tableManagementApi.getTableList();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
|
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
|
||||||
onChange({ ...configProp, ...partial });
|
onChange({ ...configProp, ...partial });
|
||||||
};
|
};
|
||||||
|
|
@ -112,72 +227,40 @@ export function ProcessWorkStandardConfigPanel({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">품목 테이블</Label>
|
<Label className="text-xs">품목 테이블</Label>
|
||||||
<Input
|
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.itemTable}
|
|
||||||
onChange={(e) => updateDataSource("itemTable", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">품목명 컬럼</Label>
|
<Label className="text-xs">품목명 컬럼</Label>
|
||||||
<Input
|
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
|
||||||
value={config.dataSource.itemNameColumn}
|
|
||||||
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">품목코드 컬럼</Label>
|
<Label className="text-xs">품목코드 컬럼</Label>
|
||||||
<Input
|
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
|
||||||
value={config.dataSource.itemCodeColumn}
|
|
||||||
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">라우팅 버전 테이블</Label>
|
<Label className="text-xs">라우팅 버전 테이블</Label>
|
||||||
<Input
|
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.routingVersionTable}
|
|
||||||
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">품목 연결 FK 컬럼</Label>
|
<Label className="text-xs">품목 연결 FK 컬럼</Label>
|
||||||
<Input
|
<ColumnCombobox value={config.dataSource.routingFkColumn} onChange={(v) => updateDataSource("routingFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
|
||||||
value={config.dataSource.routingFkColumn}
|
|
||||||
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">공정 마스터 테이블</Label>
|
<Label className="text-xs">공정 마스터 테이블</Label>
|
||||||
<Input
|
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.processTable}
|
|
||||||
onChange={(e) => updateDataSource("processTable", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">공정명 컬럼</Label>
|
<Label className="text-xs">공정명 컬럼</Label>
|
||||||
<Input
|
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
|
||||||
value={config.dataSource.processNameColumn}
|
|
||||||
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">공정코드 컬럼</Label>
|
<Label className="text-xs">공정코드 컬럼</Label>
|
||||||
<Input
|
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
|
||||||
value={config.dataSource.processCodeColumn}
|
|
||||||
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,196 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
});
|
});
|
||||||
TableCellImage.displayName = "TableCellImage";
|
TableCellImage.displayName = "TableCellImage";
|
||||||
|
|
||||||
|
// 📎 테이블 셀 파일 컴포넌트
|
||||||
|
// objid(콤마 구분 포함) 또는 JSON 배열 값을 받아 파일명 표시 + 클릭 시 읽기 전용 모달
|
||||||
|
const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
|
const [fileInfos, setFileInfos] = React.useState<Array<{ objid: string; name: string; ext: string; size?: number }>>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const rawValue = String(value).trim();
|
||||||
|
if (!rawValue || rawValue === "-") {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 배열 형태인지 확인
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const infos = parsed.map((f: any) => ({
|
||||||
|
objid: String(f.objid || f.id || ""),
|
||||||
|
name: f.realFileName || f.real_file_name || f.name || "파일",
|
||||||
|
ext: f.fileExt || f.file_ext || "",
|
||||||
|
size: f.fileSize || f.file_size || 0,
|
||||||
|
}));
|
||||||
|
if (mounted) {
|
||||||
|
setFileInfos(infos);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON 파싱 실패 → objid 문자열로 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
// 콤마 구분 objid 또는 단일 objid
|
||||||
|
const objids = rawValue.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
if (objids.length === 0) {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
objids.map(async (oid) => {
|
||||||
|
try {
|
||||||
|
const { getFileInfoByObjid } = await import("@/lib/api/file");
|
||||||
|
const res = await getFileInfoByObjid(oid);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return {
|
||||||
|
objid: oid,
|
||||||
|
name: res.data.realFileName || "파일",
|
||||||
|
ext: res.data.fileExt || "",
|
||||||
|
size: res.data.fileSize || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { objid: oid, name: `파일(${oid})`, ext: "" };
|
||||||
|
})
|
||||||
|
).then((results) => {
|
||||||
|
if (mounted) {
|
||||||
|
setFileInfos(results);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <span className="text-muted-foreground text-xs animate-pulse">...</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInfos.length === 0) {
|
||||||
|
return <span className="text-muted-foreground text-xs">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Paperclip, Download: DownloadIcon, FileText: FileTextIcon } = require("lucide-react");
|
||||||
|
const fileNames = fileInfos.map(f => f.name).join(", ");
|
||||||
|
|
||||||
|
const getFileIconClass = (ext: string) => {
|
||||||
|
const e = (ext || "").toLowerCase().replace(".", "");
|
||||||
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(e)) return "text-primary";
|
||||||
|
if (["pdf"].includes(e)) return "text-destructive";
|
||||||
|
if (["doc", "docx", "hwp", "hwpx"].includes(e)) return "text-blue-500";
|
||||||
|
if (["xls", "xlsx"].includes(e)) return "text-emerald-500";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (file: { objid: string; name: string }) => {
|
||||||
|
if (!file.objid) return;
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.get(`/files/download/${file.objid}`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = file.name || "download";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("파일 다운로드 오류:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex max-w-full cursor-pointer items-center gap-1.5 text-sm hover:underline"
|
||||||
|
title={`클릭하여 첨부파일 보기`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4 shrink-0 text-amber-500" />
|
||||||
|
<span className="truncate text-blue-600">
|
||||||
|
{fileInfos.length === 1 ? fileNames : `첨부파일 ${fileInfos.length}건`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg border bg-card p-0 shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Paperclip className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="text-sm font-semibold">첨부파일 ({fileInfos.length})</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="sr-only">닫기</span>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto p-2">
|
||||||
|
{fileInfos.map((file, idx) => (
|
||||||
|
<div
|
||||||
|
key={file.objid || idx}
|
||||||
|
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<FileTextIcon className={`h-5 w-5 shrink-0 ${getFileIconClass(file.ext)}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||||
|
{file.size ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{file.size > 1048576
|
||||||
|
? `${(file.size / 1048576).toFixed(1)} MB`
|
||||||
|
: `${(file.size / 1024).toFixed(0)} KB`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="다운로드"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDownload(file); }}
|
||||||
|
className="shrink-0 rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TableCellFile.displayName = "TableCellFile";
|
||||||
|
|
||||||
// 이미지 blob 로딩 헬퍼
|
// 이미지 blob 로딩 헬퍼
|
||||||
function loadImageBlob(
|
function loadImageBlob(
|
||||||
objid: string,
|
objid: string,
|
||||||
|
|
@ -2816,7 +3006,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
|
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
|
||||||
|
|
||||||
// 파일명 생성
|
// 파일명 생성
|
||||||
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
const _en = new Date();
|
||||||
|
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`;
|
||||||
|
|
||||||
// 파일 다운로드
|
// 파일 다운로드
|
||||||
XLSX.writeFile(wb, fileName);
|
XLSX.writeFile(wb, fileName);
|
||||||
|
|
@ -4303,8 +4494,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return <TableCellImage value={String(value)} />;
|
return <TableCellImage value={String(value)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
|
// 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원)
|
||||||
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
|
|
||||||
const isAttachmentColumn =
|
const isAttachmentColumn =
|
||||||
inputType === "file" ||
|
inputType === "file" ||
|
||||||
inputType === "attachment" ||
|
inputType === "attachment" ||
|
||||||
|
|
@ -4312,41 +4502,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
column.columnName?.toLowerCase().includes("attachment") ||
|
column.columnName?.toLowerCase().includes("attachment") ||
|
||||||
column.columnName?.toLowerCase().includes("file");
|
column.columnName?.toLowerCase().includes("file");
|
||||||
|
|
||||||
if (isAttachmentColumn) {
|
if (isAttachmentColumn && value) {
|
||||||
// JSONB 배열 또는 JSON 문자열 파싱
|
return <TableCellFile value={String(value)} />;
|
||||||
let files: any[] = [];
|
}
|
||||||
try {
|
if (isAttachmentColumn && !value) {
|
||||||
if (typeof value === "string" && value.trim()) {
|
return <span className="text-muted-foreground text-xs">-</span>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ export function TimelineSchedulerComponent({
|
||||||
if (onCellClick) {
|
if (onCellClick) {
|
||||||
onCellClick({
|
onCellClick({
|
||||||
resourceId,
|
resourceId,
|
||||||
date: date.toISOString().split("T")[0],
|
date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -343,7 +343,7 @@ export function TimelineSchedulerComponent({
|
||||||
if (onAddSchedule && effectiveResources.length > 0) {
|
if (onAddSchedule && effectiveResources.length > 0) {
|
||||||
onAddSchedule(
|
onAddSchedule(
|
||||||
effectiveResources[0].id,
|
effectiveResources[0].id,
|
||||||
new Date().toISOString().split("T")[0]
|
(() => { const _n = new Date(); return `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; })()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [onAddSchedule, effectiveResources]);
|
}, [onAddSchedule, effectiveResources]);
|
||||||
|
|
@ -383,7 +383,8 @@ export function TimelineSchedulerComponent({
|
||||||
const items = Array.from(grouped.entries()).map(([code, rows]) => {
|
const items = Array.from(grouped.entries()).map(([code, rows]) => {
|
||||||
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0);
|
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0);
|
||||||
const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort();
|
const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort();
|
||||||
const earliestDate = dates[0] || new Date().toISOString().split("T")[0];
|
const _dn = new Date();
|
||||||
|
const earliestDate = dates[0] || `${_dn.getFullYear()}-${String(_dn.getMonth() + 1).padStart(2, "0")}-${String(_dn.getDate()).padStart(2, "0")}`;
|
||||||
const first = rows[0];
|
const first = rows[0];
|
||||||
return {
|
return {
|
||||||
item_code: code,
|
item_code: code,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ interface ItemTimelineCardProps {
|
||||||
onScheduleClick?: (schedule: ScheduleItem) => void;
|
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDateString = (d: Date) => d.toISOString().split("T")[0];
|
const toDateString = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
const addDays = (d: Date, n: number) => {
|
const addDays = (d: Date, n: number) => {
|
||||||
const r = new Date(d);
|
const r = new Date(d);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const SCHEDULE_TABLE = "schedule_mng";
|
||||||
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
||||||
*/
|
*/
|
||||||
const toDateString = (date: Date): string => {
|
const toDateString = (date: Date): string => {
|
||||||
return date.toISOString().split("T")[0];
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -54,5 +54,5 @@ export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
|
||||||
export function addDaysToDateString(dateStr: string, days: number): string {
|
export function addDaysToDateString(dateStr: string, days: number): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
date.setDate(date.getDate() + days);
|
date.setDate(date.getDate() + days);
|
||||||
return date.toISOString().split("T")[0];
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ export function computeDateRange(
|
||||||
preset: DatePresetOption
|
preset: DatePresetOption
|
||||||
): { preset: DatePresetOption; from: string; to: string } | null {
|
): { preset: DatePresetOption; from: string; to: string } | null {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fmt = (d: Date) => d.toISOString().split("T")[0];
|
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
case "today":
|
case "today":
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,7 @@ export class EnhancedFormService {
|
||||||
|
|
||||||
if (lowerDataType.includes("date")) {
|
if (lowerDataType.includes("date")) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
|
return isNaN(date.getTime()) ? null : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lowerDataType.includes("time")) {
|
if (lowerDataType.includes("time")) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
|
import { toLocalDate, toLocalTime, toLocalDateTime } from "@/lib/utils/localDate";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동생성 값 생성 유틸리티
|
* 자동생성 값 생성 유틸리티
|
||||||
|
|
@ -52,19 +53,19 @@ export class AutoGenerationUtils {
|
||||||
let result: string;
|
let result: string;
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "date":
|
case "date":
|
||||||
result = now.toISOString().split("T")[0]; // YYYY-MM-DD
|
result = toLocalDate(now);
|
||||||
break;
|
break;
|
||||||
case "time":
|
case "time":
|
||||||
result = now.toTimeString().split(" ")[0]; // HH:mm:ss
|
result = toLocalTime(now);
|
||||||
break;
|
break;
|
||||||
case "datetime":
|
case "datetime":
|
||||||
result = now.toISOString().replace("T", " ").split(".")[0]; // YYYY-MM-DD HH:mm:ss
|
result = toLocalDateTime(now);
|
||||||
break;
|
break;
|
||||||
case "timestamp":
|
case "timestamp":
|
||||||
result = now.getTime().toString();
|
result = now.getTime().toString();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
result = now.toISOString(); // ISO 8601 format
|
result = toLocalDateTime(now);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5156,7 +5156,8 @@ export class ButtonActionExecutor {
|
||||||
const menuName = localStorage.getItem("currentMenuName");
|
const menuName = localStorage.getItem("currentMenuName");
|
||||||
if (menuName) defaultFileName = menuName;
|
if (menuName) defaultFileName = menuName;
|
||||||
}
|
}
|
||||||
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
const _xd = new Date();
|
||||||
|
const fileName = config.excelFileName || `${defaultFileName}_${_xd.getFullYear()}-${String(_xd.getMonth() + 1).padStart(2, "0")}-${String(_xd.getDate()).padStart(2, "0")}.xlsx`;
|
||||||
const sheetName = config.excelSheetName || "Sheet1";
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
|
|
||||||
await exportToExcel(dataToExport, fileName, sheetName, true);
|
await exportToExcel(dataToExport, fileName, sheetName, true);
|
||||||
|
|
@ -5262,7 +5263,8 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
const _xd2 = new Date();
|
||||||
|
const fileName = config.excelFileName || `${defaultFileName}_${_xd2.getFullYear()}-${String(_xd2.getMonth() + 1).padStart(2, "0")}-${String(_xd2.getDate()).padStart(2, "0")}.xlsx`;
|
||||||
const sheetName = config.excelSheetName || "Sheet1";
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")}` };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
@ -91,8 +91,8 @@ function getDefaultPeriod(): { start: string; end: string } {
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
return {
|
return {
|
||||||
start: start.toISOString().split("T")[0],
|
start: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`,
|
||||||
end: end.toISOString().split("T")[0],
|
end: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}-${String(end.getDate()).padStart(2, "0")}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue