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:
commit
593209e26e
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}개 항목`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 해상도 변경 핸들러 (컴포넌트 크기/위치 유지)
|
// 해상도 변경 핸들러 (컴포넌트 크기/위치 유지)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>>({});
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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/*"}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, // 🆕 화면 정보
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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("✅ 수정 모달 열기:", {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue