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