Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542 2026-02-04 11:27:03 +09:00
commit 593209e26e
20 changed files with 1436 additions and 243 deletions

View File

@ -793,8 +793,9 @@ export const previewFile = async (
return; return;
} }
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 및 공개 접근 제외)
if (companyCode !== "*" && fileRecord.company_code !== companyCode) { // 공개 접근(req.user가 없는 경우)은 미리보기 허용 (이미지 표시용)
if (companyCode && companyCode !== "*" && fileRecord.company_code !== companyCode) {
console.warn("⚠️ 다른 회사 파일 접근 시도:", { console.warn("⚠️ 다른 회사 파일 접근 시도:", {
userId: req.user?.userId, userId: req.user?.userId,
userCompanyCode: companyCode, userCompanyCode: companyCode,

View File

@ -24,6 +24,13 @@ const router = Router();
*/ */
router.get("/public/:token", getFileByToken); router.get("/public/:token", getFileByToken);
/**
* @route GET /api/files/preview/:objid
* @desc ( ) -
* @access Public
*/
router.get("/preview/:objid", previewFile);
// 모든 파일 API는 인증 필요 // 모든 파일 API는 인증 필요
router.use(authenticateToken); router.use(authenticateToken);
@ -64,12 +71,7 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles);
*/ */
router.delete("/:objid", deleteFile); router.delete("/:objid", deleteFile);
/** // preview 라우트는 상단 공개 접근 구역으로 이동됨
* @route GET /api/files/preview/:objid
* @desc ( )
* @access Private
*/
router.get("/preview/:objid", previewFile);
/** /**
* @route GET /api/files/download/:objid * @route GET /api/files/download/:objid

View File

@ -0,0 +1,557 @@
{
"version": "2.0",
"screenResolution": {
"width": 1400,
"height": 900,
"name": "수주등록 모달",
"category": "modal"
},
"components": [
{
"id": "section-options",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 20, "z": 1 },
"size": { "width": 1360, "height": 80 },
"overrides": {
"componentConfig": {
"title": "",
"showHeader": false,
"padding": "md",
"borderStyle": "solid"
}
},
"displayOrder": 0
},
{
"id": "select-input-method",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 40, "y": 35, "z": 2 },
"size": { "width": 300, "height": 40 },
"overrides": {
"label": "입력 방식",
"columnName": "input_method",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "customer_first", "label": "거래처 우선" },
{ "value": "item_first", "label": "품목 우선" }
],
"placeholder": "입력 방식 선택"
},
"displayOrder": 1
},
{
"id": "select-sales-type",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 360, "y": 35, "z": 2 },
"size": { "width": 300, "height": 40 },
"overrides": {
"label": "판매 유형",
"columnName": "sales_type",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "domestic", "label": "국내 판매" },
{ "value": "overseas", "label": "해외 판매" }
],
"placeholder": "판매 유형 선택"
},
"displayOrder": 2
},
{
"id": "select-price-method",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 680, "y": 35, "z": 2 },
"size": { "width": 250, "height": 40 },
"overrides": {
"label": "단가 방식",
"columnName": "price_method",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "standard", "label": "기준 단가" },
{ "value": "contract", "label": "계약 단가" },
{ "value": "custom", "label": "개별 입력" }
],
"placeholder": "단가 방식"
},
"displayOrder": 3
},
{
"id": "checkbox-price-edit",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 950, "y": 35, "z": 2 },
"size": { "width": 150, "height": 40 },
"overrides": {
"label": "단가 수정 허용",
"columnName": "allow_price_edit",
"mode": "check",
"source": "static",
"options": [{ "value": "Y", "label": "허용" }]
},
"displayOrder": 4
},
{
"id": "section-customer-info",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 110, "z": 1 },
"size": { "width": 1360, "height": 120 },
"overrides": {
"componentConfig": {
"title": "거래처 정보",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 5
},
{
"id": "select-customer",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 40, "y": 155, "z": 3 },
"size": { "width": 320, "height": 40 },
"overrides": {
"label": "거래처 *",
"columnName": "partner_id",
"mode": "dropdown",
"source": "entity",
"entityTable": "customer_mng",
"entityValueColumn": "customer_code",
"entityLabelColumn": "customer_name",
"searchable": true,
"placeholder": "거래처명 입력하여 검색",
"required": true,
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 6
},
{
"id": "input-manager",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 380, "y": 155, "z": 3 },
"size": { "width": 240, "height": 40 },
"overrides": {
"label": "담당자",
"columnName": "manager_name",
"placeholder": "담당자",
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 7
},
{
"id": "input-delivery-partner",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 640, "y": 155, "z": 3 },
"size": { "width": 240, "height": 40 },
"overrides": {
"label": "납품처",
"columnName": "delivery_partner_id",
"placeholder": "납품처",
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 8
},
{
"id": "input-delivery-address",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 900, "y": 155, "z": 3 },
"size": { "width": 460, "height": 40 },
"overrides": {
"label": "납품장소",
"columnName": "delivery_address",
"placeholder": "납품장소",
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 9
},
{
"id": "section-item-first",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 110, "z": 1 },
"size": { "width": 1360, "height": 200 },
"overrides": {
"componentConfig": {
"title": "품목 및 거래처별 수주",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "item_first",
"action": "show"
}
},
"displayOrder": 10
},
{
"id": "section-items",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 240, "z": 1 },
"size": { "width": 1360, "height": 280 },
"overrides": {
"componentConfig": {
"title": "추가된 품목",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 11
},
{
"id": "btn-item-search",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1140, "y": 245, "z": 5 },
"size": { "width": 100, "height": 36 },
"overrides": {
"label": "품목 검색",
"action": {
"type": "openModal",
"modalType": "itemSelection"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 12
},
{
"id": "btn-shipping-plan",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1250, "y": 245, "z": 5 },
"size": { "width": 100, "height": 36 },
"overrides": {
"label": "출하계획",
"webTypeConfig": {
"variant": "destructive"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 13
},
{
"id": "repeater-items",
"url": "@/lib/registry/components/v2-repeater",
"position": { "x": 40, "y": 290, "z": 3 },
"size": { "width": 1320, "height": 200 },
"overrides": {
"renderMode": "modal",
"dataSource": {
"tableName": "sales_order_detail",
"foreignKey": "order_no",
"referenceKey": "order_no"
},
"columns": [
{ "field": "part_code", "header": "품번", "width": 100 },
{ "field": "part_name", "header": "품명", "width": 150 },
{ "field": "spec", "header": "규격", "width": 100 },
{ "field": "unit", "header": "단위", "width": 80 },
{ "field": "qty", "header": "수량", "width": 100, "editable": true },
{ "field": "unit_price", "header": "단가", "width": 100, "editable": true },
{ "field": "amount", "header": "금액", "width": 100 },
{ "field": "due_date", "header": "납기일", "width": 120, "editable": true }
],
"modal": {
"sourceTable": "item_info",
"sourceColumns": ["part_code", "part_name", "spec", "material", "unit_price"],
"filterCondition": {}
},
"features": {
"showAddButton": false,
"showDeleteButton": true,
"inlineEdit": true
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 14
},
{
"id": "section-trade-info",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 530, "z": 1 },
"size": { "width": 1360, "height": 150 },
"overrides": {
"componentConfig": {
"title": "무역 정보",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 15
},
{
"id": "select-incoterms",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 40, "y": 575, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "인코텀즈",
"columnName": "incoterms",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "FOB", "label": "FOB" },
{ "value": "CIF", "label": "CIF" },
{ "value": "EXW", "label": "EXW" },
{ "value": "DDP", "label": "DDP" }
],
"placeholder": "선택",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 16
},
{
"id": "select-payment-term",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 260, "y": 575, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "결제 조건",
"columnName": "payment_term",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "TT", "label": "T/T" },
{ "value": "LC", "label": "L/C" },
{ "value": "DA", "label": "D/A" },
{ "value": "DP", "label": "D/P" }
],
"placeholder": "선택",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 17
},
{
"id": "select-currency",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 480, "y": 575, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "통화",
"columnName": "currency",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "KRW", "label": "KRW (원)" },
{ "value": "USD", "label": "USD (달러)" },
{ "value": "EUR", "label": "EUR (유로)" },
{ "value": "JPY", "label": "JPY (엔)" },
{ "value": "CNY", "label": "CNY (위안)" }
],
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 18
},
{
"id": "input-port-loading",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 40, "y": 625, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "선적항",
"columnName": "port_of_loading",
"placeholder": "선적항",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 19
},
{
"id": "input-port-discharge",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 260, "y": 625, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "도착항",
"columnName": "port_of_discharge",
"placeholder": "도착항",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 20
},
{
"id": "input-hs-code",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 480, "y": 625, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "HS Code",
"columnName": "hs_code",
"placeholder": "HS Code",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 21
},
{
"id": "section-additional",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 690, "z": 1 },
"size": { "width": 1360, "height": 130 },
"overrides": {
"componentConfig": {
"title": "추가 정보",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
}
},
"displayOrder": 22
},
{
"id": "input-memo",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 40, "y": 735, "z": 3 },
"size": { "width": 1320, "height": 70 },
"overrides": {
"label": "메모",
"columnName": "memo",
"type": "textarea",
"placeholder": "메모를 입력하세요"
},
"displayOrder": 23
},
{
"id": "btn-cancel",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1180, "y": 840, "z": 5 },
"size": { "width": 90, "height": 40 },
"overrides": {
"label": "취소",
"webTypeConfig": {
"variant": "outline"
},
"action": {
"type": "close"
}
},
"displayOrder": 24
},
{
"id": "btn-save",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1280, "y": 840, "z": 5 },
"size": { "width": 90, "height": 40 },
"overrides": {
"label": "저장",
"action": {
"type": "save"
}
},
"displayOrder": 25
}
],
"gridSettings": {
"columns": 12,
"gap": 16,
"padding": 20,
"snapToGrid": true,
"showGrid": false
}
}

View File

@ -238,7 +238,8 @@ function ScreenViewPage() {
compType?.includes("select") || compType?.includes("select") ||
compType?.includes("textarea") || compType?.includes("textarea") ||
compType?.includes("v2-input") || compType?.includes("v2-input") ||
compType?.includes("v2-select"); compType?.includes("v2-select") ||
compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가
const hasColumnName = !!(comp as any).columnName; const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName; return isInputType && hasColumnName;
}); });

View File

@ -622,23 +622,135 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
height: `${screenDimensions?.height || 600}px`, height: `${screenDimensions?.height || 600}px`,
}} }}
> >
{screenData.components.map((component) => { {(() => {
// 🆕 동적 y 좌표 조정을 위해 먼저 숨겨지는 컴포넌트들 파악
const isComponentHidden = (comp: any) => {
const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig;
if (!cc?.enabled || !formData) return false;
const { field, operator, value, action } = cc;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
default:
conditionMet = fieldValue === value;
}
return (action === "show" && !conditionMet) || (action === "hide" && conditionMet);
};
// 표시되는 컴포넌트들의 y 범위 수집
const visibleRanges: { y: number; bottom: number }[] = [];
screenData.components.forEach((comp: any) => {
if (!isComponentHidden(comp)) {
const y = parseFloat(comp.position?.y?.toString() || "0");
const height = parseFloat(comp.size?.height?.toString() || "0");
visibleRanges.push({ y, bottom: y + height });
}
});
// 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역)
const getActualGap = (hiddenY: number, hiddenBottom: number): number => {
// 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외
let gapStart = hiddenY;
let gapEnd = hiddenBottom;
for (const visible of visibleRanges) {
// 겹치는 영역 확인
if (visible.y < gapEnd && visible.bottom > gapStart) {
// 겹치는 부분을 제외
if (visible.y <= gapStart && visible.bottom >= gapEnd) {
// 완전히 덮힘 - 빈 공간 없음
return 0;
} else if (visible.y <= gapStart) {
// 위쪽이 덮힘
gapStart = visible.bottom;
} else if (visible.bottom >= gapEnd) {
// 아래쪽이 덮힘
gapEnd = visible.y;
}
}
}
return Math.max(0, gapEnd - gapStart);
};
// 숨겨지는 컴포넌트들의 실제 빈 공간 수집
const hiddenGaps: { bottom: number; gap: number }[] = [];
screenData.components.forEach((comp: any) => {
if (isComponentHidden(comp)) {
const y = parseFloat(comp.position?.y?.toString() || "0");
const height = parseFloat(comp.size?.height?.toString() || "0");
const bottom = y + height;
const gap = getActualGap(y, bottom);
if (gap > 0) {
hiddenGaps.push({ bottom, gap });
}
}
});
// bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지)
const mergedGaps = new Map<number, number>();
hiddenGaps.forEach(({ bottom, gap }) => {
const existing = mergedGaps.get(bottom) || 0;
mergedGaps.set(bottom, Math.max(existing, gap));
});
const sortedGaps = Array.from(mergedGaps.entries())
.map(([bottom, gap]) => ({ bottom, gap }))
.sort((a, b) => a.bottom - b.bottom);
console.log('🔍 [Y조정] visibleRanges:', visibleRanges.filter(r => r.bottom - r.y > 50).map(r => `${r.y}~${r.bottom}`));
console.log('🔍 [Y조정] hiddenGaps:', sortedGaps);
// 각 컴포넌트의 y 조정값 계산 함수
const getYOffset = (compY: number, compId?: string) => {
let offset = 0;
for (const { bottom, gap } of sortedGaps) {
// 컴포넌트가 숨겨진 영역 아래에 있으면 그 빈 공간만큼 위로 이동
if (compY > bottom) {
offset += gap;
}
}
if (offset > 0 && compId) {
console.log(`🔍 [Y조정] ${compId}: y=${compY}${compY - offset} (offset=${offset})`);
}
return offset;
};
return screenData.components.map((component: any) => {
// 숨겨지는 컴포넌트는 렌더링 안함
if (isComponentHidden(component)) {
return null;
}
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0; const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0; const offsetY = screenDimensions?.offsetY || 0;
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
const compY = parseFloat(component.position?.y?.toString() || "0");
const yAdjustment = getYOffset(compY, component.id);
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = const adjustedComponent = {
offsetX === 0 && offsetY === 0 ...component,
? component position: {
: { ...component.position,
...component, x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
position: { y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
...component.position, },
x: parseFloat(component.position?.x?.toString() || "0") - offsetX, };
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
@ -670,7 +782,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
companyCode={user?.companyCode} companyCode={user?.companyCode}
/> />
); );
})} });
})()}
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider> </ActiveTabProvider>

View File

@ -335,13 +335,42 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 동적 대화형 위젯 렌더링 // 동적 대화형 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => { const renderInteractiveWidget = (comp: ComponentData) => {
// 조건부 표시 평가 // 조건부 표시 평가 (기존 conditional 시스템)
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents); const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
// 조건에 따라 숨김 처리 // 조건에 따라 숨김 처리
if (!conditionalResult.visible) { if (!conditionalResult.visible) {
return null; return null;
} }
// 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용)
const conditionalConfig = (comp as any).componentConfig?.conditionalConfig;
if (conditionalConfig?.enabled && formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
default:
conditionMet = fieldValue === value;
}
if (action === "show" && !conditionMet) {
return null;
}
if (action === "hide" && conditionMet) {
return null;
}
}
// 데이터 테이블 컴포넌트 처리 // 데이터 테이블 컴포넌트 처리
if (isDataTableComponent(comp)) { if (isDataTableComponent(comp)) {
@ -533,11 +562,26 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
try { try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, v2-media 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {}; const masterFormData: Record<string, any> = {};
// v2-media 컴포넌트의 columnName 목록 수집
const mediaColumnNames = new Set(
allComponents
.filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media"))
.map((c: any) => c.columnName || c.componentConfig?.columnName)
.filter(Boolean)
);
Object.entries(formData).forEach(([key, value]) => { Object.entries(formData).forEach(([key, value]) => {
// 배열 데이터는 리피터 데이터이므로 제외
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value; masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else { } else {
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`); console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
} }

View File

@ -1623,55 +1623,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}; };
}, [MIN_ZOOM, MAX_ZOOM]); }, [MIN_ZOOM, MAX_ZOOM]);
// 격자 설정 업데이트 및 컴포넌트 자동 스냅 // 격자 설정 업데이트 (컴포넌트 자동 조정 제거됨)
const updateGridSettings = useCallback( const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => { (newGridSettings: GridSettings) => {
const newLayout = { ...layout, gridSettings: newGridSettings }; const newLayout = { ...layout, gridSettings: newGridSettings };
// 🆕 격자 설정 변경 시 컴포넌트 크기/위치 자동 조정 로직 제거됨
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정 // 사용자가 명시적으로 "격자 재조정" 버튼을 클릭해야만 조정됨
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: true, // 항상 10px 스냅 활성화
};
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
};
});
newLayout.components = adjustedComponents;
// console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
// console.log("새로운 격자 정보:", newGridInfo);
}
setLayout(newLayout); setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
}, },
[layout, screenResolution, saveToHistory], [layout, saveToHistory],
); );
// 해상도 변경 핸들러 (컴포넌트 크기/위치 유지) // 해상도 변경 핸들러 (컴포넌트 크기/위치 유지)

View File

@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
import { SelectTypeConfig } from "@/types/screen"; import { SelectTypeConfig } from "@/types/screen";
@ -22,6 +23,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
placeholder: "", placeholder: "",
allowClear: false, allowClear: false,
maxSelections: undefined, maxSelections: undefined,
defaultValue: "",
...config, ...config,
}; };
@ -32,6 +34,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
allowClear: safeConfig.allowClear, allowClear: safeConfig.allowClear,
maxSelections: safeConfig.maxSelections?.toString() || "", maxSelections: safeConfig.maxSelections?.toString() || "",
defaultValue: safeConfig.defaultValue || "",
}); });
const [newOption, setNewOption] = useState({ label: "", value: "" }); const [newOption, setNewOption] = useState({ label: "", value: "" });
@ -53,6 +56,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
allowClear: safeConfig.allowClear, allowClear: safeConfig.allowClear,
maxSelections: safeConfig.maxSelections?.toString() || "", maxSelections: safeConfig.maxSelections?.toString() || "",
defaultValue: safeConfig.defaultValue || "",
}); });
setLocalOptions( setLocalOptions(
@ -68,6 +72,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
safeConfig.placeholder, safeConfig.placeholder,
safeConfig.allowClear, safeConfig.allowClear,
safeConfig.maxSelections, safeConfig.maxSelections,
safeConfig.defaultValue,
JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지 JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지
]); ]);
@ -174,6 +179,30 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
/> />
</div> </div>
{/* 기본값 설정 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Select
value={localValues.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{localOptions.map((option, index) => (
<SelectItem key={`default-${option.value}-${index}`} value={option.value}>
{option.label} ({option.value})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
{/* 다중 선택 */} {/* 다중 선택 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium"> <Label htmlFor="multiple" className="text-sm font-medium">

View File

@ -66,6 +66,33 @@ export function TabsWidget({
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]); const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()])); const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
useEffect(() => {
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
const validTabs = (tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled);
const firstValidTabId = validTabs[0]?.id;
if (firstValidTabId) {
// 선택된 탭이 없거나 유효하지 않으면 첫 번째 탭으로 설정
setSelectedTab((currentSelected) => {
if (!currentSelected || !validTabs.some((t) => t.id === currentSelected)) {
return firstValidTabId;
}
return currentSelected;
});
// 첫 번째 탭이 mountedTabs에 없으면 추가
setMountedTabs((prev) => {
const newSet = new Set(prev);
// 첫 번째 탭 추가
if (firstValidTabId && !newSet.has(firstValidTabId)) {
newSet.add(firstValidTabId);
}
return newSet;
});
}
}, [tabs]); // tabs가 변경될 때마다 실행
// screenId 기반 화면 로드 상태 // screenId 기반 화면 로드 상태
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({}); const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({}); const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});

View File

@ -361,8 +361,17 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false); const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
const hasGeneratedNumberingRef = useRef(false); const hasGeneratedNumberingRef = useRef(false);
// tableName 추출 (props에서 전달받거나 config에서) // tableName 추출 (여러 소스에서 확인)
const tableName = (props as any).tableName || (config as any).tableName; // 1. props에서 직접 전달받은 값
// 2. config에서 설정된 값
// 3. 컴포넌트 overrides에서 설정된 값 (V2 레이아웃)
// 4. screenInfo에서 화면 테이블명
const tableName =
(props as any).tableName ||
(config as any).tableName ||
(props as any).component?.tableName ||
(props as any).component?.overrides?.tableName ||
(props as any).screenInfo?.tableName;
// 수정 모드 여부 확인 // 수정 모드 여부 확인
const originalData = (props as any).originalData || (props as any)._originalData; const originalData = (props as any).originalData || (props as any)._originalData;
@ -445,8 +454,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시) // formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지) // 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
// inputType을 여러 소스에서 확인
const propsInputType = (props as any).inputType;
const categoryValuesForNumbering = useMemo(() => { const categoryValuesForNumbering = useMemo(() => {
const inputType = config.inputType || config.type || "text"; const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return ""; if (inputType !== "numbering") return "";
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외) // formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
const categoryFields: Record<string, string> = {}; const categoryFields: Record<string, string> = {};
@ -458,12 +469,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
} }
} }
return JSON.stringify(categoryFields); return JSON.stringify(categoryFields);
}, [config.inputType, config.type, formData, columnName]); }, [propsInputType, config.inputType, config.type, formData, columnName]);
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용) // 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
useEffect(() => { useEffect(() => {
const generateNumberingCode = async () => { const generateNumberingCode = async () => {
const inputType = config.inputType || config.type || "text"; // inputType을 여러 소스에서 확인 (props에서 직접 전달받거나 config에서)
const inputType = (props as any).inputType || config.inputType || config.type || "text";
// numbering 타입이 아니면 스킵 // numbering 타입이 아니면 스킵
if (inputType !== "numbering") { if (inputType !== "numbering") {
@ -524,9 +536,12 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
} }
// detailSettings에서 numberingRuleId 추출 // detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") { if (targetColumn.detailSettings) {
try { try {
const parsed = JSON.parse(targetColumn.detailSettings); // 문자열이면 파싱, 객체면 그대로 사용
const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null; numberingRuleIdRef.current = parsed.numberingRuleId || null;
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해) // 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
@ -618,7 +633,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 타입별 입력 컴포넌트 렌더링 // 타입별 입력 컴포넌트 렌더링
const renderInput = () => { const renderInput = () => {
const inputType = config.inputType || config.type || "text"; const inputType = propsInputType || config.inputType || config.type || "text";
switch (inputType) { switch (inputType) {
case "text": case "text":
return ( return (

View File

@ -10,12 +10,13 @@
* - audio: 오디오 * - audio: 오디오
*/ */
import React, { forwardRef, useCallback, useRef, useState } from "react"; import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { V2MediaProps } from "@/types/v2-components"; import { V2MediaProps } from "@/types/v2-components";
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2 } from "lucide-react"; import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus } from "lucide-react";
import { apiClient } from "@/lib/api/client";
/** /**
* *
@ -57,15 +58,42 @@ const FileUploader = forwardRef<HTMLDivElement, {
accept = "*", accept = "*",
maxSize = 10485760, // 10MB maxSize = 10485760, // 10MB
disabled, disabled,
uploadEndpoint = "/api/upload", uploadEndpoint = "/files/upload",
className className
}, ref) => { }, ref) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 업로드 직후 미리보기를 위한 로컬 상태
const [localPreviewUrls, setLocalPreviewUrls] = useState<string[]>([]);
const files = Array.isArray(value) ? value : value ? [value] : []; // objid를 미리보기 URL로 변환
const toPreviewUrl = (val: any): string => {
if (!val) return "";
const strVal = String(val);
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`;
return strVal;
};
// value를 URL 형태의 files 배열로 변환
const rawFiles = Array.isArray(value) ? value : value ? [value] : [];
const filesFromValue = rawFiles.map(toPreviewUrl).filter(Boolean);
console.log("[FileUploader] value:", value, "rawFiles:", rawFiles, "filesFromValue:", filesFromValue, "localPreviewUrls:", localPreviewUrls);
// value가 변경되면 로컬 상태 초기화
useEffect(() => {
if (filesFromValue.length > 0) {
setLocalPreviewUrls([]);
}
}, [filesFromValue.length]);
// 최종 files: value에서 온 파일 + 로컬 미리보기 (중복 제거)
const files = filesFromValue.length > 0 ? filesFromValue : localPreviewUrls;
console.log("[FileUploader] final files:", files);
// 파일 선택 핸들러 // 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
@ -89,36 +117,53 @@ const FileUploader = forwardRef<HTMLDivElement, {
for (const file of fileArray) { for (const file of fileArray) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("files", file);
const response = await fetch(uploadEndpoint, { const response = await apiClient.post(uploadEndpoint, formData, {
method: "POST", headers: {
body: formData, "Content-Type": "multipart/form-data",
},
}); });
if (!response.ok) { const data = response.data;
throw new Error(`업로드 실패: ${file.name}`); console.log("[FileUploader] 업로드 응답:", data);
} // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] }
if (data.success && data.files && data.files.length > 0) {
const data = await response.json(); const uploadedFile = data.files[0];
if (data.success && data.url) { const objid = String(uploadedFile.objid);
uploadedUrls.push(objid);
// 즉시 미리보기를 위해 로컬 상태에 URL 저장
const previewUrl = `/api/files/preview/${objid}`;
setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]);
} else if (data.objid) {
const objid = String(data.objid);
uploadedUrls.push(objid);
const previewUrl = `/api/files/preview/${objid}`;
setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]);
} else if (data.url) {
uploadedUrls.push(data.url); uploadedUrls.push(data.url);
setLocalPreviewUrls(prev => multiple ? [...prev, data.url] : [data.url]);
} else if (data.filePath) { } else if (data.filePath) {
uploadedUrls.push(data.filePath); uploadedUrls.push(data.filePath);
setLocalPreviewUrls(prev => multiple ? [...prev, data.filePath] : [data.filePath]);
} }
} }
if (multiple) { if (multiple) {
onChange?.([...files, ...uploadedUrls]); const newValue = [...filesFromValue, ...uploadedUrls];
console.log("[FileUploader] onChange called with:", newValue);
onChange?.(newValue);
} else { } else {
onChange?.(uploadedUrls[0] || ""); const newValue = uploadedUrls[0] || "";
console.log("[FileUploader] onChange called with:", newValue);
onChange?.(newValue);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다"); setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
}, [files, multiple, maxSize, uploadEndpoint, onChange]); }, [filesFromValue, multiple, maxSize, uploadEndpoint, onChange]);
// 드래그 앤 드롭 핸들러 // 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
@ -139,21 +184,33 @@ const FileUploader = forwardRef<HTMLDivElement, {
// 파일 삭제 핸들러 // 파일 삭제 핸들러
const handleRemove = useCallback((index: number) => { const handleRemove = useCallback((index: number) => {
const newFiles = files.filter((_, i) => i !== index); // 로컬 미리보기도 삭제
setLocalPreviewUrls(prev => prev.filter((_, i) => i !== index));
// value에서 온 파일 삭제
const newFiles = filesFromValue.filter((_, i) => i !== index);
onChange?.(multiple ? newFiles : ""); onChange?.(multiple ? newFiles : "");
}, [files, multiple, onChange]); }, [filesFromValue, multiple, onChange]);
// 첫 번째 파일이 이미지인지 확인
const firstFile = files[0];
const isFirstFileImage = firstFile && (
/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(firstFile) ||
firstFile.includes("/preview/") ||
firstFile.includes("/api/files/preview/")
);
return ( return (
<div ref={ref} className={cn("space-y-3", className)}> <div ref={ref} className={cn("flex flex-col h-full w-full gap-2", className)}>
{/* 업로드 영역 */} {/* 메인 업로드 박스 - 이미지가 있으면 박스 안에 표시 */}
<div <div
className={cn( className={cn(
"border-2 border-dashed rounded-lg p-6 text-center transition-colors", "relative flex flex-1 flex-col items-center justify-center border-2 border-dashed rounded-lg text-center transition-colors overflow-hidden min-h-[120px]",
isDragging && "border-primary bg-primary/5", isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed", disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50" !disabled && !firstFile && "cursor-pointer hover:border-primary/50",
firstFile && "border-solid border-muted"
)} )}
onClick={() => !disabled && inputRef.current?.click()} onClick={() => !disabled && !firstFile && inputRef.current?.click()}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@ -168,13 +225,64 @@ const FileUploader = forwardRef<HTMLDivElement, {
className="hidden" className="hidden"
/> />
{isUploading ? ( {firstFile ? (
<div className="flex flex-col items-center gap-2"> // 파일이 있으면 박스 안에 표시
<div className="relative w-full h-full group flex items-center justify-center">
{isFirstFileImage ? (
// 이미지 미리보기
<img
src={firstFile}
alt="업로드된 이미지"
className="max-w-full max-h-full object-contain"
/>
) : (
// 일반 파일
<div className="flex flex-col items-center gap-2 p-4">
<File className="h-12 w-12 text-muted-foreground" />
<span className="text-sm text-muted-foreground truncate max-w-[200px]">
{firstFile.split("/").pop()}
</span>
</div>
)}
{/* 호버 시 액션 버튼 */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
{isFirstFileImage && (
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); window.open(firstFile, "_blank"); }}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
disabled={disabled}
>
<Upload className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleRemove(0); }}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
) : isUploading ? (
<div className="flex flex-col items-center gap-2 p-4">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" /> <div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span> <span className="text-sm text-muted-foreground"> ...</span>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2 p-4">
<Upload className="h-8 w-8 text-muted-foreground" /> <Upload className="h-8 w-8 text-muted-foreground" />
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-primary"></span> <span className="font-medium text-primary"></span>
@ -193,26 +301,45 @@ const FileUploader = forwardRef<HTMLDivElement, {
<div className="text-sm text-destructive">{error}</div> <div className="text-sm text-destructive">{error}</div>
)} )}
{/* 업로드된 파일 목록 */} {/* 추가 파일 목록 (multiple일 때 2번째 파일부터) */}
{files.length > 0 && ( {multiple && files.length > 1 && (
<div className="space-y-2"> <div className="grid grid-cols-4 gap-2">
{files.map((file, index) => ( {files.slice(1).map((file, index) => {
<div const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(file) ||
key={index} file.includes("/preview/") ||
className="flex items-center gap-2 p-2 bg-muted/50 rounded-md" file.includes("/api/files/preview/");
>
<File className="h-4 w-4 text-muted-foreground" /> return (
<span className="flex-1 text-sm truncate">{file.split("/").pop()}</span> <div key={index} className="relative group rounded-lg overflow-hidden border aspect-square flex items-center justify-center bg-muted/50">
<Button {isImage ? (
variant="ghost" <img src={file} alt={`파일 ${index + 2}`} className="w-full h-full object-cover" />
size="icon" ) : (
className="h-6 w-6" <File className="h-6 w-6 text-muted-foreground" />
onClick={() => handleRemove(index)} )}
> <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<X className="h-3 w-3" /> <Button
</Button> variant="destructive"
</div> size="icon"
))} className="h-6 w-6"
onClick={() => handleRemove(index + 1)}
disabled={disabled}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
{/* 추가 버튼 */}
<div
className={cn(
"flex items-center justify-center border-2 border-dashed rounded-lg aspect-square cursor-pointer hover:border-primary/50",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && inputRef.current?.click()}
>
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
</div> </div>
)} )}
</div> </div>
@ -241,7 +368,7 @@ const ImageUploader = forwardRef<HTMLDivElement, {
maxSize = 10485760, maxSize = 10485760,
preview = true, preview = true,
disabled, disabled,
uploadEndpoint = "/api/upload", uploadEndpoint = "/files/upload",
className className
}, ref) => { }, ref) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -249,7 +376,18 @@ const ImageUploader = forwardRef<HTMLDivElement, {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const images = Array.isArray(value) ? value : value ? [value] : []; // objid를 미리보기 URL로 변환
const toPreviewUrl = (val: any): string => {
if (!val) return "";
const strVal = String(val);
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`;
return strVal;
};
// value를 URL 형태의 images 배열로 변환
const rawImages = Array.isArray(value) ? value : value ? [value] : [];
const images = rawImages.map(toPreviewUrl).filter(Boolean);
// 파일 선택 핸들러 // 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
@ -270,20 +408,30 @@ const ImageUploader = forwardRef<HTMLDivElement, {
} }
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("files", file);
const response = await fetch(uploadEndpoint, { try {
method: "POST", const response = await apiClient.post(uploadEndpoint, formData, {
body: formData, headers: {
}); "Content-Type": "multipart/form-data",
},
});
if (response.ok) { const data = response.data;
const data = await response.json(); // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] }
if (data.success && data.url) { if (data.success && data.files && data.files.length > 0) {
const uploadedFile = data.files[0];
// objid만 저장 (DB 저장용) - 표시는 V2MediaRenderer에서 URL로 변환
uploadedUrls.push(String(uploadedFile.objid));
} else if (data.objid) {
uploadedUrls.push(String(data.objid));
} else if (data.url) {
uploadedUrls.push(data.url); uploadedUrls.push(data.url);
} else if (data.filePath) { } else if (data.filePath) {
uploadedUrls.push(data.filePath); uploadedUrls.push(data.filePath);
} }
} catch (err) {
console.error("이미지 업로드 실패:", err);
} }
} }
@ -304,82 +452,126 @@ const ImageUploader = forwardRef<HTMLDivElement, {
onChange?.(multiple ? newImages : ""); onChange?.(multiple ? newImages : "");
}, [images, multiple, onChange]); }, [images, multiple, onChange]);
// 첫 번째 이미지 (메인 박스에 표시)
const mainImage = images[0];
// 추가 이미지들 (multiple일 때만)
const additionalImages = multiple ? images.slice(1) : [];
return ( return (
<div ref={ref} className={cn("flex h-full w-full flex-col", className)}> <div ref={ref} className={cn("flex h-full w-full flex-col gap-2", className)}>
{/* 이미지 미리보기 */} {/* 메인 업로드 박스 - 첫 번째 이미지가 있으면 박스 안에 표시 */}
{preview && images.length > 0 && ( <div
<div className={cn( className={cn(
"grid gap-2 flex-1", "relative flex flex-1 flex-col items-center justify-center border-2 border-dashed rounded-lg text-center transition-colors overflow-hidden",
multiple ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" : "grid-cols-1" isDragging && "border-primary bg-primary/5",
)}> disabled && "opacity-50 cursor-not-allowed",
{images.map((src, index) => ( !disabled && !mainImage && "cursor-pointer hover:border-primary/50",
<div key={index} className="relative group rounded-lg overflow-hidden border h-full"> mainImage && "border-solid border-muted"
)}
onClick={() => !disabled && !mainImage && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{mainImage ? (
// 이미지가 있으면 박스 안에 표시
<div className="relative w-full h-full group">
<img
src={mainImage}
alt="업로드된 이미지"
className="w-full h-full object-contain"
/>
{/* 호버 시 액션 버튼 */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); window.open(mainImage, "_blank"); }}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
disabled={disabled}
>
<Upload className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleRemove(0); }}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
) : isUploading ? (
<div className="flex items-center justify-center gap-2 p-4">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 p-4">
<Upload className="h-8 w-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
</span>
<span className="text-xs text-muted-foreground">
{Math.round(maxSize / 1024 / 1024)} MB (*/*)
</span>
</div>
)}
</div>
{/* 추가 이미지 목록 (multiple일 때만) */}
{multiple && additionalImages.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{additionalImages.map((src, index) => (
<div key={index} className="relative group rounded-lg overflow-hidden border aspect-square">
<img <img
src={src} src={src}
alt={`이미지 ${index + 1}`} alt={`이미지 ${index + 2}`}
className="w-full h-full object-contain" className="w-full h-full object-cover"
/> />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2"> <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={() => window.open(src, "_blank")}
>
<Eye className="h-4 w-4" />
</Button>
<Button <Button
variant="destructive" variant="destructive"
size="icon" size="icon"
className="h-8 w-8" className="h-6 w-6"
onClick={() => handleRemove(index)} onClick={() => handleRemove(index + 1)}
disabled={disabled} disabled={disabled}
> >
<Trash2 className="h-4 w-4" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
</div> </div>
))} ))}
</div> {/* 추가 버튼 */}
)} <div
className={cn(
{/* 업로드 버튼 */} "flex items-center justify-center border-2 border-dashed rounded-lg aspect-square cursor-pointer hover:border-primary/50",
{(!images.length || multiple) && ( disabled && "opacity-50 cursor-not-allowed"
<div )}
className={cn( onClick={() => !disabled && inputRef.current?.click()}
"flex flex-1 flex-col items-center justify-center border-2 border-dashed rounded-lg p-4 text-center transition-colors", >
isDragging && "border-primary bg-primary/5", <Plus className="h-6 w-6 text-muted-foreground" />
disabled && "opacity-50 cursor-not-allowed", </div>
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{isUploading ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{multiple ? "추가" : "선택"}
</span>
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -473,6 +665,22 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
// config가 없으면 기본값 사용 // config가 없으면 기본값 사용
const config = configProp || { type: "image" as const }; const config = configProp || { type: "image" as const };
// objid를 미리보기 URL로 변환하는 함수
const toPreviewUrl = (val: any): string => {
if (!val) return "";
const strVal = String(val);
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`;
return strVal;
};
// value를 URL로 변환 (배열 또는 단일 값)
const convertedValue = Array.isArray(value)
? value.map(toPreviewUrl)
: value ? toPreviewUrl(value) : value;
console.log("[V2Media] original value:", value, "-> converted:", convertedValue, "onChange:", typeof onChange);
// 타입별 미디어 컴포넌트 렌더링 // 타입별 미디어 컴포넌트 렌더링
const renderMedia = () => { const renderMedia = () => {
@ -483,7 +691,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
case "file": case "file":
return ( return (
<FileUploader <FileUploader
value={value} value={convertedValue}
onChange={onChange} onChange={onChange}
multiple={config.multiple} multiple={config.multiple}
accept={config.accept} accept={config.accept}
@ -496,7 +704,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
case "image": case "image":
return ( return (
<ImageUploader <ImageUploader
value={value} value={convertedValue}
onChange={onChange} onChange={onChange}
multiple={config.multiple} multiple={config.multiple}
accept={config.accept || "image/*"} accept={config.accept || "image/*"}

View File

@ -194,6 +194,32 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
</p> </p>
)} )}
</div> </div>
{/* 기본값 설정 */}
{options.length > 0 && (
<div className="mt-3 pt-2 border-t">
<Label className="text-xs font-medium"></Label>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{options.map((option: any, index: number) => (
<SelectItem key={`default-${index}`} value={option.value || `_idx_${index}`}>
{option.label || option.value || `옵션 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
)}
</div> </div>
)} )}

View File

@ -207,6 +207,88 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 변환 완료 // 컴포넌트 타입 변환 완료
// 🆕 조건부 렌더링 체크 (conditionalConfig)
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
// 디버그: 조건부 렌더링 설정 확인
if (conditionalConfig?.enabled) {
console.log(`🔍 [조건부 렌더링] ${component.id}:`, {
conditionalConfig,
formData: props.formData,
hasFormData: !!props.formData
});
}
if (conditionalConfig?.enabled && props.formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = props.formData[field];
console.log(`🔍 [조건부 렌더링 평가] ${component.id}:`, {
field,
fieldValue,
operator,
expectedValue: value,
action
});
// 조건 평가
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
case ">":
conditionMet = Number(fieldValue) > Number(value);
break;
case "<":
conditionMet = Number(fieldValue) < Number(value);
break;
case ">=":
conditionMet = Number(fieldValue) >= Number(value);
break;
case "<=":
conditionMet = Number(fieldValue) <= Number(value);
break;
case "contains":
conditionMet = String(fieldValue || "").includes(String(value));
break;
case "empty":
conditionMet = !fieldValue || fieldValue === "";
break;
case "notEmpty":
conditionMet = !!fieldValue && fieldValue !== "";
break;
default:
conditionMet = fieldValue === value;
}
// 액션에 따라 렌더링 결정
console.log(`🔍 [조건부 렌더링 결과] ${component.id}:`, {
conditionMet,
action,
shouldRender: action === "show" ? conditionMet : !conditionMet
});
if (action === "show" && !conditionMet) {
// "show" 액션: 조건이 충족되지 않으면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (show 조건 불충족)`);
return null;
}
if (action === "hide" && conditionMet) {
// "hide" 액션: 조건이 충족되면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (hide 조건 충족)`);
return null;
}
// "enable"/"disable" 액션은 conditionalDisabled props로 전달
}
// 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리 // 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리) // (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
@ -343,7 +425,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const safeProps = filterDOMProps(restProps); const safeProps = filterDOMProps(restProps);
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 🔍 V2Media 디버깅
if (componentType === "v2-media") {
console.log("[DynamicComponentRenderer] v2-media:", {
componentId: component.id,
columnName: (component as any).columnName,
configColumnName: (component as any).componentConfig?.columnName,
fieldName,
formDataValue: props.formData?.[fieldName],
formDataKeys: props.formData ? Object.keys(props.formData) : []
});
}
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화 // 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue; let currentValue;
@ -412,10 +506,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}; };
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
// 🆕 v2-input도 포함 (채번 규칙 조회 시 tableName 필요)
const useConfigTableName = const useConfigTableName =
componentType === "entity-search-input" || componentType === "entity-search-input" ||
componentType === "autocomplete-search-input" || componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table"; componentType === "modal-repeater-table" ||
componentType === "v2-input";
const rendererProps = { const rendererProps = {
component, component,
@ -430,9 +526,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentConfig: component.componentConfig, componentConfig: component.componentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}), ...(component.componentConfig || {}),
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
inputType: (component as any).inputType || component.componentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,
value: currentValue, // formData에서 추출한 현재 값 전달 value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달 // 새로운 기능들 전달
autoGeneration: component.autoGeneration || component.componentConfig?.autoGeneration, // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
autoGeneration: component.autoGeneration ||
component.componentConfig?.autoGeneration ||
((component as any).webTypeConfig?.numberingRuleId ? {
type: "numbering_rule" as const,
enabled: true,
options: {
numberingRuleId: (component as any).webTypeConfig.numberingRuleId,
},
} : undefined),
hidden: hiddenValue, hidden: hiddenValue,
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음) // React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
isInteractive, isInteractive,
@ -440,7 +548,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onFormDataChange, onFormDataChange,
onChange: handleChange, // 개선된 onChange 핸들러 전달 onChange: handleChange, // 개선된 onChange 핸들러 전달
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용 // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
tableName: useConfigTableName ? component.componentConfig?.tableName || tableName : tableName, // 🆕 component.tableName도 확인 (V2 레이아웃에서 overrides.tableName이 복원됨)
tableName: useConfigTableName
? component.componentConfig?.tableName || (component as any).tableName || tableName
: tableName,
menuId, // 🆕 메뉴 ID menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보 selectedScreen, // 🆕 화면 정보

View File

@ -654,11 +654,11 @@ export function RepeaterTable({
<thead className="sticky top-0 z-20 bg-gray-50"> <thead className="sticky top-0 z-20 bg-gray-50">
<tr> <tr>
{/* 드래그 핸들 헤더 - 좌측 고정 */} {/* 드래그 핸들 헤더 - 좌측 고정 */}
<th className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700"> <th key="header-drag" className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700">
<span className="sr-only"></span> <span className="sr-only"></span>
</th> </th>
{/* 체크박스 헤더 - 좌측 고정 */} {/* 체크박스 헤더 - 좌측 고정 */}
<th className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700"> <th key="header-checkbox" className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700">
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
// @ts-expect-error - indeterminate는 HTML 속성 // @ts-expect-error - indeterminate는 HTML 속성
@ -667,7 +667,7 @@ export function RepeaterTable({
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")} className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/> />
</th> </th>
{visibleColumns.map((col) => { {visibleColumns.map((col, colIndex) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource const activeOption = hasDynamicSource
@ -677,7 +677,7 @@ export function RepeaterTable({
return ( return (
<th <th
key={col.field} key={`header-col-${col.field || colIndex}`}
className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none" className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none"
style={{ width: `${columnWidths[col.field]}px` }} style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)} onDoubleClick={() => handleDoubleClick(col.field)}
@ -765,8 +765,9 @@ export function RepeaterTable({
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}> <SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<tbody className="bg-white"> <tbody className="bg-white">
{data.length === 0 ? ( {data.length === 0 ? (
<tr> <tr key="empty-row">
<td <td
key="empty-cell"
colSpan={visibleColumns.length + 2} colSpan={visibleColumns.length + 2}
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500" className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
> >
@ -787,6 +788,7 @@ export function RepeaterTable({
<> <>
{/* 드래그 핸들 - 좌측 고정 */} {/* 드래그 핸들 - 좌측 고정 */}
<td <td
key={`drag-${rowIndex}`}
className={cn( className={cn(
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center", "sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white", selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
@ -806,6 +808,7 @@ export function RepeaterTable({
</td> </td>
{/* 체크박스 - 좌측 고정 */} {/* 체크박스 - 좌측 고정 */}
<td <td
key={`check-${rowIndex}`}
className={cn( className={cn(
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center", "sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white", selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
@ -818,9 +821,9 @@ export function RepeaterTable({
/> />
</td> </td>
{/* 데이터 컬럼들 */} {/* 데이터 컬럼들 */}
{visibleColumns.map((col) => ( {visibleColumns.map((col, colIndex) => (
<td <td
key={col.field} key={`${rowIndex}-${col.field || colIndex}`}
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1" className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
style={{ style={{
width: `${columnWidths[col.field]}px`, width: `${columnWidths[col.field]}px`,

View File

@ -684,13 +684,13 @@ export function SimpleRepeaterTableComponent({
<thead className="bg-muted sticky top-0 z-10"> <thead className="bg-muted sticky top-0 z-10">
<tr> <tr>
{showRowNumber && ( {showRowNumber && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12"> <th key="header-rownum" className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
# #
</th> </th>
)} )}
{columns.map((col) => ( {columns.map((col) => (
<th <th
key={col.field} key={`header-${col.field}`}
className="px-4 py-2 text-left font-medium text-muted-foreground" className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }} style={{ width: col.width }}
> >
@ -699,7 +699,7 @@ export function SimpleRepeaterTableComponent({
</th> </th>
))} ))}
{!readOnly && allowDelete && ( {!readOnly && allowDelete && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20"> <th key="header-delete" className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th> </th>
)} )}
@ -707,8 +707,9 @@ export function SimpleRepeaterTableComponent({
</thead> </thead>
<tbody className="bg-background"> <tbody className="bg-background">
{value.length === 0 ? ( {value.length === 0 ? (
<tr> <tr key="empty-row">
<td <td
key="empty-cell"
colSpan={totalColumns} colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground" className="px-4 py-8 text-center text-muted-foreground"
> >
@ -724,19 +725,19 @@ export function SimpleRepeaterTableComponent({
</tr> </tr>
) : ( ) : (
value.map((row, rowIndex) => ( value.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50"> <tr key={`row-${rowIndex}`} className="border-t hover:bg-accent/50">
{showRowNumber && ( {showRowNumber && (
<td className="px-4 py-2 text-center text-muted-foreground"> <td key={`rownum-${rowIndex}`} className="px-4 py-2 text-center text-muted-foreground">
{rowIndex + 1} {rowIndex + 1}
</td> </td>
)} )}
{columns.map((col) => ( {columns.map((col) => (
<td key={col.field} className="px-2 py-1"> <td key={`${rowIndex}-${col.field}`} className="px-2 py-1">
{renderCell(row, col, rowIndex)} {renderCell(row, col, rowIndex)}
</td> </td>
))} ))}
{!readOnly && allowDelete && ( {!readOnly && allowDelete && (
<td className="px-4 py-2 text-center"> <td key={`delete-${rowIndex}`} className="px-4 py-2 text-center">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View File

@ -641,19 +641,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// 성공한 경우에만 성공 토스트 표시 // 성공한 경우에만 성공 토스트 표시
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리 // save, delete, submit 액션에서만 성공 메시지 표시
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시) // 그 외 액션은 조용히 처리 (불필요한 "완료되었습니다" 토스트 방지)
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; const successToastActions = ["save", "delete", "submit"];
if (!silentSuccessActions.includes(actionConfig.type)) { if (successToastActions.includes(actionConfig.type)) {
// 기본 성공 메시지 결정 // 기본 성공 메시지 결정
const defaultSuccessMessage = const defaultSuccessMessage =
actionConfig.type === "save" actionConfig.type === "save"
? "저장되었습니다." ? "저장되었습니다."
: actionConfig.type === "delete" : actionConfig.type === "delete"
? "삭제되었습니다." ? "삭제되었습니다."
: actionConfig.type === "submit" : "제출되었습니다.";
? "제출되었습니다."
: "완료되었습니다.";
// 커스텀 메시지 사용 조건: // 커스텀 메시지 사용 조건:
// 1. 커스텀 메시지가 있고 // 1. 커스텀 메시지가 있고
@ -1103,10 +1101,28 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const screenContextFormData = screenContext?.formData || {}; const screenContextFormData = screenContext?.formData || {};
const propsFormData = formData || {}; const propsFormData = formData || {};
// 🔧 디버그: formData 소스 확인
console.log("🔍 [v2-button-primary] formData 소스 확인:", {
propsFormDataKeys: Object.keys(propsFormData),
screenContextFormDataKeys: Object.keys(screenContextFormData),
propsHasCompanyImage: "company_image" in propsFormData,
propsHasCompanyLogo: "company_logo" in propsFormData,
screenHasCompanyImage: "company_image" in screenContextFormData,
screenHasCompanyLogo: "company_logo" in screenContextFormData,
});
// 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드 // 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
let effectiveFormData = { ...propsFormData, ...screenContextFormData }; let effectiveFormData = { ...propsFormData, ...screenContextFormData };
console.log("🔍 [v2-button-primary] effectiveFormData 병합 결과:", {
keys: Object.keys(effectiveFormData),
hasCompanyImage: "company_image" in effectiveFormData,
hasCompanyLogo: "company_logo" in effectiveFormData,
companyImageValue: effectiveFormData.company_image,
companyLogoValue: effectiveFormData.company_logo,
});
// 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 // 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
effectiveFormData = { ...splitPanelParentData }; effectiveFormData = { ...splitPanelParentData };

View File

@ -22,7 +22,32 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
const tableName = component.tableName || this.props.tableName; const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기 // formData에서 현재 값 가져오기
const currentValue = formData?.[columnName] ?? component.value ?? ""; const rawValue = formData?.[columnName] ?? component.value ?? "";
// objid를 미리보기 URL로 변환하는 함수 (number/string 모두 처리)
const convertToPreviewUrl = (val: any): string => {
if (val === null || val === undefined || val === "") return "";
// number면 string으로 변환
const strVal = String(val);
// 이미 URL 형태면 그대로 반환
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
// 숫자로만 이루어진 문자열이면 objid로 간주하고 미리보기 URL 생성
if (/^\d+$/.test(strVal)) {
return `/api/files/preview/${strVal}`;
}
return strVal;
};
// 배열 또는 단일 값 처리
const currentValue = Array.isArray(rawValue)
? rawValue.map(convertToPreviewUrl)
: convertToPreviewUrl(rawValue);
console.log("[V2Media] rawValue:", rawValue, "-> currentValue:", currentValue);
// 값 변경 핸들러 // 값 변경 핸들러
const handleChange = (value: any) => { const handleChange = (value: any) => {
@ -54,7 +79,7 @@ export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
preview: config.preview ?? true, preview: config.preview ?? true,
maxSize: maxSizeBytes, maxSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType), accept: config.accept || this.getDefaultAccept(mediaType),
uploadEndpoint: config.uploadEndpoint || "/api/upload", uploadEndpoint: config.uploadEndpoint || "/files/upload",
}} }}
style={component.style} style={component.style}
size={component.size} size={component.size}

View File

@ -20,8 +20,20 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
const columnName = component.columnName; const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName; const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기 // formData에서 현재 값 가져오기 (기본값 지원)
const currentValue = formData?.[columnName] ?? component.value ?? ""; const defaultValue = config.defaultValue || "";
let currentValue = formData?.[columnName] ?? component.value ?? "";
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
if ((currentValue === "" || currentValue === undefined || currentValue === null) && defaultValue && isInteractive && onFormDataChange && columnName) {
// 초기 렌더링 시 기본값을 formData에 설정
setTimeout(() => {
if (!formData?.[columnName]) {
onFormDataChange(columnName, defaultValue);
}
}, 0);
currentValue = defaultValue;
}
// 값 변경 핸들러 // 값 변경 핸들러
const handleChange = (value: any) => { const handleChange = (value: any) => {

View File

@ -1676,7 +1676,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 커스텀 모달 화면 열기 // 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || ""; const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) // Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
let primaryKeyName = "id"; let primaryKeyName = "id";
let primaryKeyValue: any; let primaryKeyValue: any;
@ -1686,11 +1686,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} else if (item.ID !== undefined && item.ID !== null) { } else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID"; primaryKeyName = "ID";
primaryKeyValue = item.ID; primaryKeyValue = item.ID;
} else if (item.user_id !== undefined && item.user_id !== null) {
// user_info 테이블 등 user_id를 Primary Key로 사용하는 경우
primaryKeyName = "user_id";
primaryKeyValue = item.user_id;
} else { } else {
// 첫 번째 필드를 Primary Key로 간주 // 테이블명_id 패턴 확인 (예: dept_id, item_id 등)
const firstKey = Object.keys(item)[0]; const tableIdKey = rightTableName ? `${rightTableName.replace(/_info$/, "")}_id` : "";
primaryKeyName = firstKey; if (tableIdKey && item[tableIdKey] !== undefined && item[tableIdKey] !== null) {
primaryKeyValue = item[firstKey]; primaryKeyName = tableIdKey;
primaryKeyValue = item[tableIdKey];
} else {
// 마지막으로 첫 번째 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0];
primaryKeyName = firstKey;
primaryKeyValue = item[firstKey];
}
} }
console.log("✅ 수정 모달 열기:", { console.log("✅ 수정 모달 열기:", {

View File

@ -484,6 +484,15 @@ export class ButtonActionExecutor {
this.saveCallCount++; this.saveCallCount++;
const callId = this.saveCallCount; const callId = this.saveCallCount;
// 🔧 디버그: context.formData 확인 (handleSave 진입 시점)
console.log("🔍 [handleSave] 진입 시 context.formData:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
hasCompanyLogo: "company_logo" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
companyLogoValue: context.formData?.company_logo,
});
const { formData, originalData, tableName, screenId, onSave } = context; const { formData, originalData, tableName, screenId, onSave } = context;
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시 // 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
@ -524,6 +533,14 @@ export class ButtonActionExecutor {
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 // skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
// 🔧 디버그: beforeFormSave 이벤트 전 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
const beforeSaveEventDetail = { const beforeSaveEventDetail = {
formData: context.formData, formData: context.formData,
skipDefaultSave: false, skipDefaultSave: false,
@ -538,6 +555,13 @@ export class ButtonActionExecutor {
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// 검증 실패 시 저장 중단 // 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) { if (beforeSaveEventDetail.validationFailed) {
@ -668,6 +692,10 @@ export class ButtonActionExecutor {
return await this.handleBatchSave(config, context, selectedItemsKeys); return await this.handleBatchSave(config, context, selectedItemsKeys);
} else { } else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행"); console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
// 🔧 디버그: formData 상세 확인
console.log("🔍 [handleSave] formData 키 목록:", Object.keys(context.formData || {}));
console.log("🔍 [handleSave] formData.company_image:", context.formData?.company_image);
console.log("🔍 [handleSave] formData.company_logo:", context.formData?.company_logo);
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
} }
@ -3015,8 +3043,12 @@ export class ButtonActionExecutor {
} }
// 4. 모달 열기 이벤트 발생 // 4. 모달 열기 이벤트 발생
// passSelectedData가 true이면 editData로 전달 (수정 모드처럼 모든 필드 표시) // 🔧 수정: openModalWithData는 "신규 등록 + 연결 데이터 전달"용이므로
// editData가 아닌 splitPanelParentData로 전달해야 채번 등이 정상 작동함
const isPassDataMode = passSelectedData && selectedData.length > 0; const isPassDataMode = passSelectedData && selectedData.length > 0;
// 🔧 isEditMode 옵션이 명시적으로 true인 경우에만 수정 모드로 처리
const useAsEditData = config.isEditMode === true;
const modalEvent = new CustomEvent("openScreenModal", { const modalEvent = new CustomEvent("openScreenModal", {
detail: { detail: {
@ -3026,19 +3058,18 @@ export class ButtonActionExecutor {
size: config.modalSize || "md", size: config.modalSize || "md",
selectedData: selectedData, selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean), selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 🆕 데이터 전달 모드일 때는 editData로 전달하여 모든 필드가 표시되도록 함 // 🔧 수정: isEditMode가 명시적으로 true인 경우에만 editData로 전달
editData: isPassDataMode ? parentData : undefined, // 기본적으로는 splitPanelParentData로 전달하여 신규 등록 + 연결 데이터 모드
splitPanelParentData: isPassDataMode ? undefined : parentData, editData: useAsEditData && isPassDataMode ? parentData : undefined,
splitPanelParentData: isPassDataMode ? parentData : undefined,
urlParams: dataSourceId ? { dataSourceId } : undefined, urlParams: dataSourceId ? { dataSourceId } : undefined,
}, },
}); });
window.dispatchEvent(modalEvent); window.dispatchEvent(modalEvent);
// 성공 메시지 (autoDetectDataSource 모드에서만) // 모달 열기는 UI 전환이므로 성공 토스트를 표시하지 않음
if (autoDetectDataSource && config.successMessage) { // (저장 등 실제 액션 완료 시에만 토스트 표시)
toast.success(config.successMessage);
}
return true; return true;
} }
@ -3227,8 +3258,7 @@ export class ButtonActionExecutor {
window.dispatchEvent(modalEvent); window.dispatchEvent(modalEvent);
// 성공 메시지 (간단하게) // 모달 열기는 UI 전환이므로 성공 토스트를 표시하지 않음
toast.success(config.successMessage || "다음 단계로 진행합니다.");
return true; return true;
} else { } else {
@ -7094,7 +7124,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
modalSize: "md", modalSize: "md",
passSelectedData: true, passSelectedData: true,
autoDetectDataSource: true, autoDetectDataSource: true,
successMessage: "다음 단계로 진행합니다.", // 모달 열기는 UI 전환이므로 successMessage 제거
}, },
modal: { modal: {
type: "modal", type: "modal",