Compare commits

..

No commits in common. "832e80cd7f1e43f8cec44251a7f03836368b13d3" and "7cc325edd5576d74576155b736bfa47a2291d346" have entirely different histories.

44 changed files with 698 additions and 2230 deletions

View File

@ -1,279 +0,0 @@
# inputType 사용 가이드
## 핵심 원칙
**컬럼 타입 판단 시 반드시 `inputType`을 사용해야 합니다. `webType`은 레거시이며 더 이상 사용하지 않습니다.**
---
## 올바른 사용법
### ✅ inputType 사용 (권장)
```typescript
// 카테고리 타입 체크
if (columnMeta.inputType === "category") {
// 카테고리 처리 로직
}
// 코드 타입 체크
if (meta.inputType === "code") {
// 코드 처리 로직
}
// 필터링
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
```
### ❌ webType 사용 (금지)
```typescript
// ❌ 절대 사용 금지!
if (columnMeta.webType === "category") { ... }
// ❌ 이것도 금지!
const categoryColumns = columns.filter(col => col.webType === "category");
```
---
## API에서 inputType 가져오기
### Backend API
```typescript
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableName);
// inputType 맵 생성
const inputTypeMap: Record<string, string> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
```
### columnMeta 구조
```typescript
interface ColumnMeta {
webType?: string; // 레거시, 사용 금지
codeCategory?: string;
inputType?: string; // ✅ 반드시 이것 사용!
}
const columnMeta: Record<string, ColumnMeta> = {
material: {
webType: "category", // 무시
codeCategory: "",
inputType: "category", // ✅ 이것만 사용
},
};
```
---
## 캐시 사용 시 주의사항
### ❌ 잘못된 캐시 처리 (inputType 누락)
```typescript
const cached = tableColumnCache.get(cacheKey);
if (cached) {
const meta: Record<string, ColumnMeta> = {};
cached.columns.forEach((col: any) => {
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
// ❌ inputType 누락!
};
});
}
```
### ✅ 올바른 캐시 처리 (inputType 포함)
```typescript
const cached = tableColumnCache.get(cacheKey);
if (cached) {
const meta: Record<string, ColumnMeta> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
}
cached.columns.forEach((col: any) => {
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], // ✅ inputType 포함!
};
});
}
```
---
## 주요 inputType 종류
| inputType | 설명 | 사용 예시 |
| ---------- | ---------------- | ------------------ |
| `text` | 일반 텍스트 입력 | 이름, 설명 등 |
| `number` | 숫자 입력 | 금액, 수량 등 |
| `date` | 날짜 입력 | 생성일, 수정일 등 |
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
| `category` | 카테고리 선택 | 분류, 상태 등 |
| `code` | 공통 코드 선택 | 코드 마스터 데이터 |
| `boolean` | 예/아니오 | 활성화 여부 등 |
| `email` | 이메일 입력 | 이메일 주소 |
| `url` | URL 입력 | 웹사이트 주소 |
| `image` | 이미지 업로드 | 프로필 사진 등 |
| `file` | 파일 업로드 | 첨부파일 등 |
---
## 실제 적용 사례
### 1. TableListComponent - 카테고리 매핑 로드
```typescript
// ✅ inputType으로 카테고리 컬럼 필터링
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
// 각 카테고리 컬럼의 값 목록 조회
for (const columnName of categoryColumns) {
const response = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values`
);
// 매핑 처리...
}
```
### 2. InteractiveDataTable - 셀 값 렌더링
```typescript
// ✅ inputType으로 렌더링 분기
const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) {
case "category":
// 카테고리 배지 렌더링
return <Badge>{categoryLabel}</Badge>;
case "code":
// 코드명 표시
return codeName;
case "date":
// 날짜 포맷팅
return formatDate(value);
default:
return value;
}
```
### 3. 검색 필터 생성
```typescript
// ✅ inputType에 따라 다른 검색 UI 제공
const renderSearchInput = (column: ColumnConfig) => {
const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) {
case "category":
return <CategorySelect column={column} />;
case "code":
return <CodeSelect column={column} />;
case "date":
return <DateRangePicker column={column} />;
case "number":
return <NumberRangeInput column={column} />;
default:
return <TextInput column={column} />;
}
};
```
---
## 마이그레이션 체크리스트
기존 코드에서 `webType`을 `inputType`으로 전환할 때:
- [ ] `webType` 참조를 모두 `inputType`으로 변경
- [ ] API 호출 시 `getColumnInputTypes()` 포함 확인
- [ ] 캐시 사용 시 `cached.inputTypes` 매핑 확인
- [ ] 타입 정의에서 `inputType` 필드 포함
- [ ] 조건문에서 `inputType` 체크로 변경
- [ ] 테스트 실행하여 정상 동작 확인
---
## 디버깅 팁
### inputType이 undefined인 경우
```typescript
// 디버깅 로그 추가
console.log("columnMeta:", columnMeta);
console.log("inputType:", columnMeta[columnName]?.inputType);
// 체크 포인트:
// 1. getColumnInputTypes() 호출 확인
// 2. inputTypeMap 생성 확인
// 3. meta 객체에 inputType 할당 확인
// 4. 캐시 사용 시 cached.inputTypes 확인
```
### webType만 있고 inputType이 없는 경우
```typescript
// ❌ 잘못된 데이터 구조
{
material: {
webType: "category",
codeCategory: "",
// inputType 누락!
}
}
// ✅ 올바른 데이터 구조
{
material: {
webType: "category", // 레거시, 무시됨
codeCategory: "",
inputType: "category" // ✅ 필수!
}
}
```
---
## 참고 자료
- **컴포넌트**: `/frontend/lib/registry/components/table-list/TableListComponent.tsx`
- **API 클라이언트**: `/frontend/lib/api/tableType.ts`
- **타입 정의**: `/frontend/types/table.ts`
---
## 요약
1. **항상 `inputType` 사용**, `webType` 사용 금지
2. **API에서 `getColumnInputTypes()` 호출** 필수
3. **캐시 사용 시 `inputTypes` 포함** 확인
4. **디버깅 시 `inputType` 값 확인**
5. **기존 코드 마이그레이션** 시 체크리스트 활용

View File

@ -67,12 +67,6 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
description: "단일 선택", description: "단일 선택",
category: "selection", category: "selection",
}, },
{
value: "image",
label: "이미지",
description: "이미지 표시",
category: "basic",
},
]; ];
// 입력 타입 검증 함수 // 입력 타입 검증 함수

View File

@ -1,48 +0,0 @@
# 마이그레이션 043: 이미지 컬럼을 TEXT 타입으로 변경
## 목적
Base64 인코딩된 이미지 데이터를 저장하기 위해 VARCHAR(500) 컬럼을 TEXT 타입으로 변경합니다.
## 영향받는 테이블
- `item_info.image`
- `user_info.image` (존재하는 경우)
- 기타 `image`, `img`, `picture`, `photo` 이름을 가진 VARCHAR 컬럼들
## 실행 방법
### Docker 환경
```bash
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/043_change_image_columns_to_text.sql
```
### 로컬 PostgreSQL
```bash
psql -U postgres -d ilshin -f db/migrations/043_change_image_columns_to_text.sql
```
## 확인 방법
```sql
-- 변경된 컬럼 확인
SELECT
table_name,
column_name,
data_type,
character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name ILIKE '%image%'
ORDER BY table_name, column_name;
```
## 롤백 방법
```sql
-- 필요시 원래대로 되돌리기 (데이터 손실 주의!)
ALTER TABLE item_info ALTER COLUMN image TYPE VARCHAR(500);
```
## 주의사항
- TEXT 타입은 길이 제한이 없으므로 매우 큰 이미지도 저장 가능합니다.
- Base64 인코딩은 원본 파일 크기의 약 1.33배가 됩니다.
- 5MB 이미지 → Base64: 약 6.7MB → 문자열: 약 6.7백만 자
- 성능상 매우 큰 이미지는 파일 서버에 저장하고 URL만 저장하는 것이 좋습니다.

View File

@ -1,86 +0,0 @@
/**
* 브라우저 콘솔에서 실행하는 마이그레이션 스크립트
*
* 사용 방법:
* 1. 브라우저에서 ERP 시스템에 로그인
* 2. F12 눌러서 개발자 도구 열기
* 3. Console 선택
* 4. 아래 코드 전체를 복사해서 붙여넣고 Enter
*/
(async function runMigration() {
console.log("🚀 마이그레이션 043 시작: 이미지 컬럼을 TEXT로 변경");
const sql = `
-- item_info 테이블의 image 컬럼 변경
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'item_info'
AND column_name = 'image'
) THEN
ALTER TABLE item_info
ALTER COLUMN image TYPE TEXT;
RAISE NOTICE 'item_info.image 컬럼을 TEXT 타입으로 변경했습니다.';
ELSE
RAISE NOTICE 'item_info.image 컬럼이 존재하지 않습니다.';
END IF;
END $$;
-- 모든 테이블에서 image 관련 VARCHAR 컬럼을 TEXT로 변경
DO $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT
table_name,
column_name,
character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type IN ('character varying', 'varchar')
AND (
column_name ILIKE '%image%' OR
column_name ILIKE '%img%' OR
column_name ILIKE '%picture%' OR
column_name ILIKE '%photo%'
)
AND character_maximum_length IS NOT NULL
AND character_maximum_length < 10000
LOOP
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I TYPE TEXT', rec.table_name, rec.column_name);
RAISE NOTICE '%.% 컬럼을 TEXT 타입으로 변경했습니다. (이전: VARCHAR(%))',
rec.table_name, rec.column_name, rec.character_maximum_length;
END LOOP;
END $$;
`;
try {
const response = await fetch('/api/dashboards/execute-dml', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: sql }),
credentials: 'include'
});
const result = await response.json();
if (result.success) {
console.log("✅ 마이그레이션 성공!", result);
console.log("📊 이제 이미지를 저장할 수 있습니다!");
} else {
console.error("❌ 마이그레이션 실패:", result);
console.log("💡 수동으로 실행해야 합니다. RUN_043_MIGRATION.md 참고");
}
} catch (error) {
console.error("❌ 마이그레이션 오류:", error);
console.log("💡 수동으로 실행해야 합니다. RUN_043_MIGRATION.md 참고");
}
})();

View File

@ -7,13 +7,13 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import {
ResizableDialog, Dialog,
ResizableDialogContent, DialogContent,
ResizableDialogHeader, DialogHeader,
ResizableDialogTitle, DialogTitle,
ResizableDialogDescription, DialogDescription,
ResizableDialogFooter, DialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -321,20 +321,20 @@ export function CreateTableModal({
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType); const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
return ( return (
<ResizableDialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto"> <DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"} {isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
</ResizableDialogTitle> </DialogTitle>
<ResizableDialogDescription> <DialogDescription>
{isDuplicateMode {isDuplicateMode
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.` ? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요." : "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
} }
</ResizableDialogDescription> </DialogDescription>
</ResizableDialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-6">
{/* 테이블 기본 정보 */} {/* 테이블 기본 정보 */}
@ -452,7 +452,7 @@ export function CreateTableModal({
)} )}
</div> </div>
<ResizableDialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose} disabled={loading}> <Button variant="outline" onClick={onClose} disabled={loading}>
</Button> </Button>
@ -482,8 +482,8 @@ export function CreateTableModal({
isDuplicateMode ? "복제 생성" : "테이블 생성" isDuplicateMode ? "복제 생성" : "테이블 생성"
)} )}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
); );
} }

View File

@ -6,13 +6,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogFooter
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -148,14 +142,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
}; };
return ( return (
<ResizableDialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto"> <DialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> <Database className="h-5 w-5" />
DDL DDL
</ResizableDialogTitle> </DialogTitle>
</ResizableDialogHeader> </DialogHeader>
<Tabs defaultValue="logs" className="w-full"> <Tabs defaultValue="logs" className="w-full">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
@ -407,7 +401,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
)} )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
); );
} }

View File

@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
)} )}
</div> </div>
<ResizableDialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}

View File

@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
)} )}
</div> </div>
<ResizableDialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}

View File

@ -1,13 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogFooter
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -126,14 +120,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
}; };
return ( return (
<ResizableDialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden"> <DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" /> <History className="h-5 w-5" />
{tableName} - {tableName} -
</ResizableDialogTitle> </DialogTitle>
</ResizableDialogHeader> </DialogHeader>
{/* 필터 영역 */} {/* 필터 영역 */}
<div className="space-y-3 rounded-lg border p-4"> <div className="space-y-3 rounded-lg border p-4">
@ -261,7 +255,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
</Button> </Button>
</div> </div>
</div> </div>
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
); );
} }

View File

@ -2,12 +2,12 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import {
ResizableDialog, Dialog,
ResizableDialogContent, DialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription, DialogHeader,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -119,10 +119,8 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
<ResizableDialog open={isOpen} onOpenChange={handleClose}> <ResizableDialog open={isOpen} onOpenChange={handleClose}>
<ResizableDialogContent className="sm:max-w-[500px]"> <ResizableDialogContent className="sm:max-w-[500px]">
<ResizableDialogHeader> <ResizableDialogHeader>
<div className="flex items-center gap-2">
<ResizableDialogTitle> </ResizableDialogTitle> <ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription>'{dashboardTitle}' .</ResizableDialogDescription> <ResizableDialogDescription>'{dashboardTitle}' .</ResizableDialogDescription>
</div>
</ResizableDialogHeader> </ResizableDialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">

View File

@ -3,12 +3,12 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
ResizableDialog, Dialog,
ResizableDialogContent, DialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription, DialogHeader,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/resizable-dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -67,10 +67,8 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
<ResizableDialog open={isOpen} onOpenChange={handleClose}> <ResizableDialog open={isOpen} onOpenChange={handleClose}>
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}> <ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
<ResizableDialogHeader> <ResizableDialogHeader>
<div className="flex items-center gap-2">
<ResizableDialogTitle> </ResizableDialogTitle> <ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription> </ResizableDialogDescription> <ResizableDialogDescription> </ResizableDialogDescription>
</div>
</ResizableDialogHeader> </ResizableDialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">

View File

@ -395,7 +395,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
maxWidth={1400} maxWidth={1400}
maxHeight={900} maxHeight={900}
modalId={`excel-upload-${tableName}`} modalId={`excel-upload-${tableName}`}
userId={userId || "guest"} userId={userId}
> >
<ResizableDialogHeader> <ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg"> <ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect } from "react";
import { import {
ResizableDialog, ResizableDialog,
ResizableDialogContent, ResizableDialogContent,
@ -12,7 +12,6 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -27,8 +26,6 @@ interface ScreenModalProps {
} }
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => { export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId } = useAuth();
const [modalState, setModalState] = useState<ScreenModalState>({ const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false, isOpen: false,
screenId: null, screenId: null,
@ -222,87 +219,27 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const modalStyle = getModalStyle(); const modalStyle = getModalStyle();
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
// modalId 생성 및 업데이트
useEffect(() => {
// 모달이 열려있고 screenId가 있을 때만 업데이트
if (!modalState.isOpen) return;
let newModalId: string | undefined;
// 1순위: screenId (가장 안정적)
if (modalState.screenId) {
newModalId = `screen-modal-${modalState.screenId}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "screenId",
screenId: modalState.screenId,
result: newModalId,
});
}
// 2순위: 테이블명
else if (screenData?.screenInfo?.tableName) {
newModalId = `screen-modal-table-${screenData.screenInfo.tableName}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "tableName",
tableName: screenData.screenInfo.tableName,
result: newModalId,
});
}
// 3순위: 화면명
else if (screenData?.screenInfo?.screenName) {
newModalId = `screen-modal-name-${screenData.screenInfo.screenName}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "screenName",
screenName: screenData.screenInfo.screenName,
result: newModalId,
});
}
// 4순위: 제목
else if (modalState.title) {
const titleId = modalState.title.replace(/\s+/g, '-');
newModalId = `screen-modal-title-${titleId}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "title",
title: modalState.title,
result: newModalId,
});
}
if (newModalId) {
setPersistedModalId(newModalId);
}
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
return ( return (
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}> <ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
<ResizableDialogContent <ResizableDialogContent
className={`${modalStyle.className} ${className || ""}`} className={`${modalStyle.className} ${className || ""}`}
style={modalStyle.style} style={modalStyle.style}
defaultWidth={600} defaultWidth={800}
defaultHeight={800} defaultHeight={600}
minWidth={500} minWidth={600}
minHeight={400} minHeight={400}
maxWidth={1600} maxWidth={1400}
maxHeight={1200} maxHeight={1000}
modalId={persistedModalId} modalId={`screen-modal-${modalState.screenId}`}
userId={userId || "guest"}
> >
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3"> <ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle> <ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
{modalState.description && !loading && ( {modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs"> <ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
{modalState.description}
</ResizableDialogDescription>
)} )}
{loading && ( {loading && (
<ResizableDialogDescription className="text-xs"> <ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
{loading ? "화면을 불러오는 중입니다..." : ""}
</ResizableDialogDescription>
)} )}
</div>
</ResizableDialogHeader> </ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto"> <div className="flex flex-1 items-center justify-center overflow-auto">

View File

@ -168,20 +168,20 @@ export function TableOptionsModal({
</ResizableDialogDescription> </ResizableDialogDescription>
</ResizableDialogHeader> </ResizableDialogHeader>
<Tabs defaultValue="columns" className="flex flex-col flex-1 overflow-hidden"> <Tabs defaultValue="columns" className="w-full">
<TabsList className="grid w-full grid-cols-3 flex-shrink-0"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="columns" className="text-xs sm:text-sm"> </TabsTrigger> <TabsTrigger value="columns" className="text-xs sm:text-sm"> </TabsTrigger>
<TabsTrigger value="display" className="text-xs sm:text-sm"> </TabsTrigger> <TabsTrigger value="display" className="text-xs sm:text-sm"> </TabsTrigger>
<TabsTrigger value="view" className="text-xs sm:text-sm"> </TabsTrigger> <TabsTrigger value="view" className="text-xs sm:text-sm"> </TabsTrigger>
</TabsList> </TabsList>
{/* 컬럼 설정 탭 */} {/* 컬럼 설정 탭 */}
<TabsContent value="columns" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4"> <TabsContent value="columns" className="space-y-3 sm:space-y-4 mt-4">
<div className="text-xs sm:text-sm text-muted-foreground mb-2"> <div className="text-xs sm:text-sm text-muted-foreground mb-2">
, / . , / .
</div> </div>
<div className="space-y-2"> <div className="space-y-2 max-h-[400px] overflow-y-auto">
{columns.map((column, index) => ( {columns.map((column, index) => (
<div <div
key={column.columnName} key={column.columnName}
@ -249,7 +249,7 @@ export function TableOptionsModal({
</TabsContent> </TabsContent>
{/* 표시 설정 탭 */} {/* 표시 설정 탭 */}
<TabsContent value="display" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4"> <TabsContent value="display" className="space-y-3 sm:space-y-4 mt-4">
<div className="flex items-center justify-between p-3 sm:p-4 border rounded-md bg-card"> <div className="flex items-center justify-between p-3 sm:p-4 border rounded-md bg-card">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label className="text-xs sm:text-sm font-medium"> </Label> <Label className="text-xs sm:text-sm font-medium"> </Label>
@ -265,7 +265,7 @@ export function TableOptionsModal({
</TabsContent> </TabsContent>
{/* 보기 모드 탭 */} {/* 보기 모드 탭 */}
<TabsContent value="view" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4"> <TabsContent value="view" className="space-y-3 sm:space-y-4 mt-4">
<div className="grid gap-3"> <div className="grid gap-3">
<Button <Button
variant={viewMode === "table" ? "default" : "outline"} variant={viewMode === "table" ? "default" : "outline"}

View File

@ -2,13 +2,13 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import {
Dialog, ResizableDialog,
DialogContent, ResizableDialogContent,
DialogHeader, ResizableDialogHeader,
DialogTitle, ResizableDialogTitle,
DialogDescription, ResizableDialogDescription,
DialogFooter, ResizableDialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -101,17 +101,17 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <ResizableDialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]"> <ResizableDialogContent className="sm:max-w-[500px]">
<DialogHeader> <ResizableDialogHeader>
<DialogTitle className="flex items-center gap-2"> <ResizableDialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" /> <Copy className="h-5 w-5" />
</DialogTitle> </ResizableDialogTitle>
<DialogDescription> <ResizableDialogDescription>
{sourceScreen?.screenName} . . {sourceScreen?.screenName} . .
</DialogDescription> </ResizableDialogDescription>
</DialogHeader> </ResizableDialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* 원본 화면 정보 */} {/* 원본 화면 정보 */}
@ -168,7 +168,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</div> </div>
</div> </div>
<DialogFooter> <ResizableDialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCopying}> <Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button> </Button>
@ -185,8 +185,8 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</> </>
)} )}
</Button> </Button>
</DialogFooter> </ResizableDialogFooter>
</DialogContent> </ResizableDialogContent>
</Dialog> </ResizableDialog>
); );
} }

View File

@ -305,11 +305,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
minHeight={400} minHeight={400}
maxWidth={1400} maxWidth={1400}
maxHeight={1000} maxHeight={1000}
modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined} modalId={`edit-modal-${modalState.screenId}`}
userId={user?.userId} userId={user?.userId}
> >
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3"> <ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle> <ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
{modalState.description && !loading && ( {modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription> <ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
@ -317,7 +316,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
{loading && ( {loading && (
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription> <ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
)} )}
</div>
</ResizableDialogHeader> </ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto"> <div className="flex flex-1 items-center justify-center overflow-auto">

View File

@ -144,8 +144,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터 const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑 const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}}) // 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({}); const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
// 공통코드 옵션 가져오기 // 공통코드 옵션 가져오기
const loadCodeOptions = useCallback( const loadCodeOptions = useCallback(
@ -208,7 +208,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
if (!categoryColumns || categoryColumns.length === 0) return; if (!categoryColumns || categoryColumns.length === 0) return;
// 각 카테고리 컬럼의 값 목록 조회 // 각 카테고리 컬럼의 값 목록 조회
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {}; const mappings: Record<string, Record<string, string>> = {};
for (const col of categoryColumns) { for (const col of categoryColumns) {
try { try {
@ -217,23 +217,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
); );
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
// valueCode -> {label, color} 매핑 생성 // valueCode -> valueLabel 매핑 생성
const mapping: Record<string, { label: string; color?: string }> = {}; const mapping: Record<string, string> = {};
response.data.data.forEach((item: any) => { response.data.data.forEach((item: any) => {
mapping[item.valueCode] = { mapping[item.valueCode] = item.valueLabel;
label: item.valueLabel,
color: item.color,
};
}); });
mappings[col.columnName] = mapping; mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
} }
} catch (error) { } catch (error) {
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error); // 카테고리 값 로드 실패 시 무시
} }
} }
console.log("📊 전체 카테고리 매핑:", mappings);
setCategoryMappings(mappings); setCategoryMappings(mappings);
} catch (error) { } catch (error) {
console.error("카테고리 매핑 로드 실패:", error); console.error("카테고리 매핑 로드 실패:", error);
@ -1916,27 +1911,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨) // 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
switch (actualWebType) { switch (actualWebType) {
case "category": { case "category": {
// 카테고리 타입: 배지로 표시 // 카테고리 타입: 코드값 -> 라벨로 변환
if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)]; if (mapping && value) {
const label = mapping[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 return label || String(value);
const displayLabel = categoryData?.label || String(value); }
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 return String(value || "");
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor
}}
className="text-white"
>
{displayLabel}
</Badge>
);
} }
case "date": case "date":

View File

@ -1,10 +1,9 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback } from "react";
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 { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog"; import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DialogTitle, DialogHeader } from "@/components/ui/dialog";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file"; import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner"; import { toast } from "sonner";
@ -121,67 +120,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
[userName], [userName],
); );
// 🆕 Enter 키로 다음 필드 이동
useEffect(() => {
const handleEnterKey = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
const target = e.target as HTMLElement;
// textarea는 제외 (여러 줄 입력)
if (target.tagName === "TEXTAREA") {
return;
}
// input, select 등의 폼 요소에서만 작동
if (
target.tagName === "INPUT" ||
target.tagName === "SELECT" ||
target.getAttribute("role") === "combobox"
) {
e.preventDefault();
// 모든 포커스 가능한 요소 찾기
const focusableElements = document.querySelectorAll<HTMLElement>(
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])'
);
// 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬
const focusableArray = Array.from(focusableElements).sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
// Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로)
if (Math.abs(rectA.top - rectB.top) > 10) {
return rectA.top - rectB.top;
}
// 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로)
return rectA.left - rectB.left;
});
const currentIndex = focusableArray.indexOf(target);
if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) {
// 다음 요소로 포커스 이동
const nextElement = focusableArray[currentIndex + 1];
nextElement.focus();
// input이면 전체 선택
if (nextElement.tagName === "INPUT") {
(nextElement as HTMLInputElement).select();
}
}
}
}
};
document.addEventListener("keydown", handleEnterKey);
return () => {
document.removeEventListener("keydown", handleEnterKey);
};
}, []);
// 🆕 autoFill 자동 입력 초기화 // 🆕 autoFill 자동 입력 초기화
React.useEffect(() => { React.useEffect(() => {
const initAutoInputFields = async () => { const initAutoInputFields = async () => {
@ -692,17 +630,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}
{popupScreen && ( {popupScreen && (
<ResizableDialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}> <Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<ResizableDialogContent <DialogContent
className="overflow-hidden p-0" className={` ${
defaultWidth={popupScreen.size === "small" ? 600 : popupScreen.size === "large" ? 1400 : 1000} popupScreen.size === "small" ? "max-w-md" : popupScreen.size === "large" ? "max-w-6xl" : "max-w-4xl"
defaultHeight={800} } max-h-[90vh] overflow-y-auto`}
minWidth={500}
minHeight={400}
maxWidth={1600}
maxHeight={1200}
modalId={`popup-screen-${popupScreen.screenId}`}
userId={user?.userId || "guest"}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>{popupScreen.title}</DialogTitle> <DialogTitle>{popupScreen.title}</DialogTitle>
@ -736,8 +668,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
))} ))}
</div> </div>
)} )}
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
)} )}
</> </>
); );

View File

@ -4,10 +4,10 @@ import React, { useState, useEffect, useRef } from "react";
import { import {
ResizableDialog, ResizableDialog,
ResizableDialogContent, ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter, ResizableDialogFooter,
ResizableResizableDialogHeader,
ResizableResizableDialogTitle,
ResizableResizableDialogDescription,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
</div> </div>
</div> </div>
<ResizableDialogFooter> <DialogFooter>
<Button <Button
onClick={() => { onClick={() => {
// 타이머 정리 // 타이머 정리
@ -407,7 +407,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
<Monitor className="mr-2 h-4 w-4" /> <Monitor className="mr-2 h-4 w-4" />
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</> </>
) : ( ) : (
// 기본 할당 화면 // 기본 할당 화면

View File

@ -833,35 +833,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 현재 화면의 테이블 컬럼 정보 조회 // 현재 화면의 테이블 컬럼 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName); const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// 🔍 이미지 타입 디버깅
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
// console.log("🖼️ 이미지 컬럼 발견:", {
// columnName: col.columnName || col.column_name,
// widgetType,
// webType: col.webType || col.web_type,
// rawData: col,
// });
// }
return {
tableName: col.tableName || tableName, tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name, columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType, dataType: col.dataType || col.data_type || col.dbType,
webType: col.webType || col.web_type, webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type, input_type: col.inputType || col.input_type,
widgetType, widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
isNullable: col.isNullable || col.is_nullable, isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default, columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category, codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value, codeValue: col.codeValue || col.code_value,
}; }));
});
const tableInfo: TableInfo = { const tableInfo: TableInfo = {
tableName, tableName,
@ -2607,16 +2593,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
defaultWidth, defaultWidth,
}); });
// 🔍 이미지 타입 드래그앤드롭 디버깅
// if (column.widgetType === "image") {
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
// columnName: column.columnName,
// widgetType: column.widgetType,
// componentId,
// column,
// });
// }
newComponent = { newComponent = {
id: generateComponentId(), id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용 type: "component", // ✅ 새로운 컴포넌트 시스템 사용

View File

@ -26,13 +26,12 @@ import {
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Dialog, ResizableDialog,
DialogContent, ResizableDialogContent,
DialogHeader, ResizableDialogHeader,
DialogTitle, ResizableDialogTitle,
DialogFooter, ResizableDialogFooter,
DialogDescription, } from "@/components/ui/resizable-dialog";
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
@ -457,7 +456,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
}`} }`}
onClick={() => onDesignScreen(screen)} onClick={() => onDesignScreen(screen)}
> >
<TableCell className="h-16 px-6 py-3 cursor-pointer"> <TableCell className="h-16 cursor-pointer px-6 py-3">
<div> <div>
<div className="font-medium">{screen.screenName}</div> <div className="font-medium">{screen.screenName}</div>
{screen.description && ( {screen.description && (
@ -697,7 +696,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{deletedScreens.map((screen) => ( {deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="bg-background hover:bg-muted/50 border-b transition-colors"> <TableRow
key={screen.screenId}
className="bg-background hover:bg-muted/50 border-b transition-colors"
>
<TableCell className="h-16 px-6 py-3"> <TableCell className="h-16 px-6 py-3">
<Checkbox <Checkbox
checked={selectedScreenIds.includes(screen.screenId)} checked={selectedScreenIds.includes(screen.screenId)}
@ -1063,11 +1065,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</AlertDialog> </AlertDialog>
{/* 화면 편집 다이얼로그 */} {/* 화면 편집 다이얼로그 */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}> <ResizableDialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]"> <ResizableDialogContent className="sm:max-w-[500px]">
<DialogHeader> <ResizableDialogHeader>
<DialogTitle> </DialogTitle> <ResizableDialogTitle> </ResizableDialogTitle>
</DialogHeader> </ResizableDialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="edit-screenName"> *</Label> <Label htmlFor="edit-screenName"> *</Label>
@ -1104,23 +1106,23 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</Select> </Select>
</div> </div>
</div> </div>
<DialogFooter> <ResizableDialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}> <Button variant="outline" onClick={() => setEditDialogOpen(false)}>
</Button> </Button>
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}> <Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
</Button> </Button>
</DialogFooter> </ResizableDialogFooter>
</DialogContent> </ResizableDialogContent>
</Dialog> </ResizableDialog>
{/* 화면 미리보기 다이얼로그 */} {/* 화면 미리보기 다이얼로그 */}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> <ResizableDialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="h-[95vh] max-w-[95vw]"> <ResizableDialogContent className="h-[95vh] max-w-[95vw]">
<DialogHeader> <ResizableDialogHeader>
<DialogTitle> - {screenToPreview?.screenName}</DialogTitle> <ResizableDialogTitle> - {screenToPreview?.screenName}</ResizableDialogTitle>
</DialogHeader> </ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6"> <div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
{isLoadingPreview ? ( {isLoadingPreview ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -1270,7 +1272,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크) // 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
if ( if (
(component.type === "widget" && (component as any).widgetType === "button") || (component.type === "widget" && (component as any).widgetType === "button") ||
(component.type === "component" && (component as any).componentType?.includes("button")) (component.type === "component" &&
(component as any).componentType?.includes("button"))
) { ) {
console.log("🔘 ScreenList 버튼 외부 div 스타일:", { console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
id: component.id, id: component.id,
@ -1358,7 +1361,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div> </div>
)} )}
</div> </div>
<DialogFooter> <ResizableDialogFooter>
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}> <Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
</Button> </Button>
@ -1366,9 +1369,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<Palette className="mr-2 h-4 w-4" /> <Palette className="mr-2 h-4 w-4" />
</Button> </Button>
</DialogFooter> </ResizableDialogFooter>
</DialogContent> </ResizableDialogContent>
</Dialog> </ResizableDialog>
</div> </div>
); );
} }

View File

@ -157,7 +157,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="backgroundImage" className="text-xs font-medium"> <Label htmlFor="backgroundImage" className="text-xs font-medium">
(CSS)
</Label> </Label>
<Input <Input
id="backgroundImage" id="backgroundImage"
@ -168,9 +168,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }} style={{ fontSize: "12px" }}
/> />
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -318,10 +318,10 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
}).length; }).length;
return ( return (
<div className={cn(className)}> <div className={cn("space-y-2", className)}>
{/* 필터 그리드 + 초기화 버튼 한 줄 */} {/* 필터 그리드 - 적절한 너비로 조정 */}
{effectiveFilters.length > 0 && ( {effectiveFilters.length > 0 && (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap gap-3">
{effectiveFilters.map((filter: DataTableFilter) => { {effectiveFilters.map((filter: DataTableFilter) => {
// 필터 개수에 따라 적절한 너비 계산 // 필터 개수에 따라 적절한 너비 계산
const getFilterWidth = () => { const getFilterWidth = () => {
@ -338,14 +338,17 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
</div> </div>
); );
})} })}
</div>
)}
{/* 필터 초기화 버튼 - 같은 줄에 배치 */} {/* 필터 상태 및 초기화 버튼 */}
{activeFiltersCount > 0 && ( {activeFiltersCount > 0 && (
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2 flex-shrink-0"> <div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">{activeFiltersCount} </div>
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -66,28 +66,6 @@ export function FlowWidget({
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기 const { user } = useAuth(); // 사용자 정보 가져오기
// 숫자 포맷팅 함수
const formatValue = (value: any): string => {
if (value === null || value === undefined || value === "") {
return "-";
}
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
if (typeof value === "number") {
return value.toLocaleString("ko-KR");
}
if (typeof value === "string") {
const numValue = parseFloat(value);
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
};
// 🆕 전역 상태 관리 // 🆕 전역 상태 관리
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
const resetFlow = useFlowStepStore((state) => state.resetFlow); const resetFlow = useFlowStepStore((state) => state.resetFlow);
@ -114,6 +92,40 @@ export function FlowWidget({
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록 const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터 const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
// 값 포맷팅 함수 (숫자, 카테고리 등)
const formatValue = useCallback((value: any, columnName?: string): string => {
if (value === null || value === undefined || value === "") {
return "-";
}
// 카테고리 타입: 코드값 -> 라벨로 변환
if (columnName && categoryMappings[columnName]) {
const mapping = categoryMappings[columnName];
const label = mapping[String(value)];
if (label) {
return label;
}
}
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
if (typeof value === "number") {
return value.toLocaleString("ko-KR");
}
if (typeof value === "string") {
const numValue = parseFloat(value);
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
}, [categoryMappings]);
// 🆕 그룹 설정 관련 상태 // 🆕 그룹 설정 관련 상태
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록 const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
@ -370,12 +382,6 @@ export function FlowWidget({
}); });
setFilteredData(filtered); setFilteredData(filtered);
console.log("🔍 검색 실행:", {
totalRows: stepData.length,
filteredRows: filtered.length,
searchValues,
hasSearchValue,
});
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행 }, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
// 선택된 스텝의 데이터를 다시 로드하는 함수 // 선택된 스텝의 데이터를 다시 로드하는 함수
@ -459,7 +465,6 @@ export function FlowWidget({
// 프리뷰 모드에서는 샘플 데이터만 표시 // 프리뷰 모드에서는 샘플 데이터만 표시
if (isPreviewMode) { if (isPreviewMode) {
console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시");
setFlowData({ setFlowData({
id: flowId || 0, id: flowId || 0,
flowName: flowName || "샘플 플로우", flowName: flowName || "샘플 플로우",
@ -636,16 +641,9 @@ export function FlowWidget({
try { try {
// 컬럼 라벨 조회 // 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId!, stepId); const labelsResponse = await getStepColumnLabels(flowId!, stepId);
console.log("🏷️ 컬럼 라벨 조회 결과:", {
stepId,
success: labelsResponse.success,
labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0,
labels: labelsResponse.data,
});
if (labelsResponse.success && labelsResponse.data) { if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data); setColumnLabels(labelsResponse.data);
} else { } else {
console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse);
setColumnLabels({}); setColumnLabels({});
} }
@ -677,6 +675,61 @@ export function FlowWidget({
} }
}; };
// 카테고리 타입 컬럼의 값 매핑 로드
useEffect(() => {
const loadCategoryMappings = async () => {
if (!selectedStepId || !steps.length) return;
try {
const currentStep = steps.find((s) => s.id === selectedStepId);
const tableName = currentStep?.stepConfig?.tableName;
if (!tableName) return;
// 테이블 컬럼 정보 조회하여 카테고리 타입 찾기
const apiClient = (await import("@/lib/api/client")).apiClient;
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (!columnsResponse.data?.success) return;
const columns = columnsResponse.data.data?.columns || [];
const categoryColumns = columns.filter((col: any) =>
(col.inputType === "category" || col.input_type === "category")
);
if (categoryColumns.length === 0) return;
// 각 카테고리 컬럼의 값 목록 조회
const mappings: Record<string, Record<string, string>> = {};
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values`
);
if (response.data.success && response.data.data) {
const mapping: Record<string, string> = {};
response.data.data.forEach((item: any) => {
mapping[item.valueCode] = item.valueLabel;
});
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 값 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
} catch (error) {
console.error("FlowWidget 카테고리 매핑 로드 실패:", error);
}
};
loadCategoryMappings();
}, [selectedStepId, steps]);
// 체크박스 토글 // 체크박스 토글
const toggleRowSelection = (rowIndex: number) => { const toggleRowSelection = (rowIndex: number) => {
// 프리뷰 모드에서는 행 선택 차단 // 프리뷰 모드에서는 행 선택 차단
@ -694,13 +747,6 @@ export function FlowWidget({
// 선택된 데이터를 상위로 전달 // 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]); const selectedData = Array.from(newSelected).map((index) => stepData[index]);
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
rowIndex,
newSelectedSize: newSelected.size,
selectedData,
selectedStepId,
hasCallback: !!onSelectedDataChange,
});
onSelectedDataChange?.(selectedData, selectedStepId); onSelectedDataChange?.(selectedData, selectedStepId);
}; };
@ -1017,7 +1063,7 @@ export function FlowWidget({
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2 text-xs"> <div key={col} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span> <span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
<span className="text-foreground truncate">{formatValue(row[col])}</span> <span className="text-foreground truncate">{formatValue(row[col], col)}</span>
</div> </div>
))} ))}
</div> </div>
@ -1095,7 +1141,7 @@ export function FlowWidget({
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap"> <TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col])} {formatValue(row[col], col)}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
@ -1125,7 +1171,7 @@ export function FlowWidget({
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap"> <TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col])} {formatValue(row[col], col)}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>

View File

@ -1,199 +0,0 @@
"use client";
import React, { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X } from "lucide-react";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent } from "@/types/screen";
import { toast } from "sonner";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
export const ImageWidget: React.FC<WebTypeComponentProps> = ({
component,
value,
onChange,
readonly = false,
isDesignMode = false // 디자인 모드 여부
}) => {
const widget = component as WidgetComponent;
const { required, style } = widget;
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 이미지 URL 처리 (백엔드 서버 경로로 변환)
const rawImageUrl = value || widget.value || "";
const imageUrl = rawImageUrl ? getFullImageUrl(rawImageUrl) : "";
// style에서 width, height 제거 (부모 컨테이너 크기 사용)
const filteredStyle = style ? { ...style, width: undefined, height: undefined } : {};
// 파일 선택 처리
const handleFileSelect = () => {
// 디자인 모드에서는 업로드 불가
if (readonly || isDesignMode) return;
fileInputRef.current?.click();
};
// 파일 업로드 처리
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 이미지 파일 검증
if (!file.type.startsWith("image/")) {
toast.error("이미지 파일만 업로드 가능합니다.");
return;
}
// 파일 크기 검증 (5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error("파일 크기는 최대 5MB까지 가능합니다.");
return;
}
setUploading(true);
try {
// FormData 생성
const formData = new FormData();
formData.append("files", file);
formData.append("docType", "IMAGE");
formData.append("docTypeName", "이미지");
// 서버에 업로드 (axios 사용 - 인증 토큰 자동 포함)
const response = await apiClient.post("/files/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
if (response.data.success && response.data.files && response.data.files.length > 0) {
const uploadedFile = response.data.files[0];
const imageUrl = uploadedFile.filePath; // /uploads/company_*/2024/01/01/filename.jpg
onChange?.(imageUrl);
toast.success("이미지가 업로드되었습니다.");
} else {
throw new Error(response.data.message || "업로드 실패");
}
} catch (error: any) {
console.error("이미지 업로드 오류:", error);
const errorMessage = error.response?.data?.message || error.message || "이미지 업로드에 실패했습니다.";
toast.error(errorMessage);
} finally {
setUploading(false);
}
};
// 이미지 제거
const handleRemove = () => {
// 디자인 모드에서는 제거 불가
if (readonly || isDesignMode) return;
onChange?.("");
toast.success("이미지가 제거되었습니다.");
};
// 드래그 앤 드롭 처리
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 디자인 모드에서는 드롭 불가
if (readonly || isDesignMode) return;
const file = e.dataTransfer.files[0];
if (!file) return;
// 파일 input에 파일 설정
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
if (fileInputRef.current) {
fileInputRef.current.files = dataTransfer.files;
handleFileChange({ target: fileInputRef.current } as any);
}
};
return (
<div className="h-full w-full">
{imageUrl ? (
// 이미지 표시 모드
<div
className="group relative h-full w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md"
style={filteredStyle}
>
<img
src={imageUrl}
alt="업로드된 이미지"
className="h-full w-full object-contain"
onError={(e) => {
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
}}
/>
{/* 호버 시 제거 버튼 */}
{!readonly && !isDesignMode && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="sm"
variant="destructive"
onClick={handleRemove}
className="gap-2"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
) : (
// 업로드 영역
<div
className={`group relative flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
isDesignMode
? "cursor-default border-gray-200 bg-gray-50"
: "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md"
}`}
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={filteredStyle}
>
{uploading ? (
<p className="text-xs font-medium text-blue-600"> ...</p>
) : readonly ? (
<p className="text-xs font-medium text-gray-500"> </p>
) : isDesignMode ? (
<p className="text-xs font-medium text-gray-400"> </p>
) : (
<p className="text-xs font-medium text-gray-700 transition-colors duration-300 group-hover:text-blue-600">
</p>
)}
</div>
)}
{/* 숨겨진 파일 input */}
<Input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
disabled={readonly || uploading}
/>
{/* 필수 필드 경고 */}
{required && !imageUrl && (
<div className="text-xs text-red-500">* </div>
)}
</div>
);
};
ImageWidget.displayName = "ImageWidget";

View File

@ -11,7 +11,6 @@ import { TextareaWidget } from "./TextareaWidget";
import { CheckboxWidget } from "./CheckboxWidget"; import { CheckboxWidget } from "./CheckboxWidget";
import { RadioWidget } from "./RadioWidget"; import { RadioWidget } from "./RadioWidget";
import { FileWidget } from "./FileWidget"; import { FileWidget } from "./FileWidget";
import { ImageWidget } from "./ImageWidget";
import { CodeWidget } from "./CodeWidget"; import { CodeWidget } from "./CodeWidget";
import { EntityWidget } from "./EntityWidget"; import { EntityWidget } from "./EntityWidget";
import { RatingWidget } from "./RatingWidget"; import { RatingWidget } from "./RatingWidget";
@ -25,7 +24,6 @@ export { TextareaWidget } from "./TextareaWidget";
export { CheckboxWidget } from "./CheckboxWidget"; export { CheckboxWidget } from "./CheckboxWidget";
export { RadioWidget } from "./RadioWidget"; export { RadioWidget } from "./RadioWidget";
export { FileWidget } from "./FileWidget"; export { FileWidget } from "./FileWidget";
export { ImageWidget } from "./ImageWidget";
export { CodeWidget } from "./CodeWidget"; export { CodeWidget } from "./CodeWidget";
export { EntityWidget } from "./EntityWidget"; export { EntityWidget } from "./EntityWidget";
export { RatingWidget } from "./RatingWidget"; export { RatingWidget } from "./RatingWidget";
@ -49,8 +47,6 @@ export const getWidgetComponentByName = (componentName: string): React.Component
return RadioWidget; return RadioWidget;
case "FileWidget": case "FileWidget":
return FileWidget; return FileWidget;
case "ImageWidget":
return ImageWidget;
case "CodeWidget": case "CodeWidget":
return CodeWidget; return CodeWidget;
case "EntityWidget": case "EntityWidget":
@ -109,12 +105,6 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp
case "attachment": case "attachment":
return FileWidget; return FileWidget;
case "image":
case "img":
case "picture":
case "photo":
return ImageWidget;
case "code": case "code":
case "script": case "script":
return CodeWidget; return CodeWidget;
@ -165,7 +155,6 @@ export const WebTypeComponents: Record<string, React.ComponentType<WebTypeCompon
checkbox: CheckboxWidget, checkbox: CheckboxWidget,
radio: RadioWidget, radio: RadioWidget,
file: FileWidget, file: FileWidget,
image: ImageWidget,
code: CodeWidget, code: CodeWidget,
entity: EntityWidget, entity: EntityWidget,
rating: RatingWidget, rating: RatingWidget,

View File

@ -11,33 +11,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { TableCategoryValue } from "@/types/tableCategoryValue"; import { TableCategoryValue } from "@/types/tableCategoryValue";
// 기본 색상 팔레트
const DEFAULT_COLORS = [
"#ef4444", // red
"#f97316", // orange
"#f59e0b", // amber
"#eab308", // yellow
"#84cc16", // lime
"#22c55e", // green
"#10b981", // emerald
"#14b8a6", // teal
"#06b6d4", // cyan
"#0ea5e9", // sky
"#3b82f6", // blue
"#6366f1", // indigo
"#8b5cf6", // violet
"#a855f7", // purple
"#d946ef", // fuchsia
"#ec4899", // pink
"#64748b", // slate
"#6b7280", // gray
];
interface CategoryValueAddDialogProps { interface CategoryValueAddDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -50,7 +26,6 @@ export const CategoryValueAddDialog: React.FC<
> = ({ open, onOpenChange, onAdd, columnLabel }) => { > = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(""); const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState("#3b82f6");
// 라벨에서 코드 자동 생성 // 라벨에서 코드 자동 생성
const generateCode = (label: string): string => { const generateCode = (label: string): string => {
@ -84,14 +59,13 @@ export const CategoryValueAddDialog: React.FC<
valueCode, valueCode,
valueLabel: valueLabel.trim(), valueLabel: valueLabel.trim(),
description: description.trim(), description: description.trim(),
color: color, color: "#3b82f6",
isDefault: false, isDefault: false,
}); });
// 초기화 // 초기화
setValueLabel(""); setValueLabel("");
setDescription(""); setDescription("");
setColor("#3b82f6");
}; };
return ( return (
@ -107,57 +81,24 @@ export const CategoryValueAddDialog: React.FC<
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
</Label>
<Input <Input
id="valueLabel" id="valueLabel"
placeholder="예: 개발, 긴급, 진행중" placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel} value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)} onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5" className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus autoFocus
/> />
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
</div>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
()
</Label>
<Textarea <Textarea
id="description" id="description"
placeholder="설명을 입력하세요" placeholder="설명 (선택사항)"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5" className="text-xs sm:text-sm"
rows={3} rows={3}
/> />
</div> </div>
</div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button

View File

@ -11,9 +11,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { TableCategoryValue } from "@/types/tableCategoryValue"; import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueEditDialogProps { interface CategoryValueEditDialogProps {
@ -24,39 +22,15 @@ interface CategoryValueEditDialogProps {
columnLabel: string; columnLabel: string;
} }
// 기본 색상 팔레트
const DEFAULT_COLORS = [
"#ef4444", // red
"#f97316", // orange
"#f59e0b", // amber
"#eab308", // yellow
"#84cc16", // lime
"#22c55e", // green
"#10b981", // emerald
"#14b8a6", // teal
"#06b6d4", // cyan
"#0ea5e9", // sky
"#3b82f6", // blue
"#6366f1", // indigo
"#8b5cf6", // violet
"#a855f7", // purple
"#d946ef", // fuchsia
"#ec4899", // pink
"#64748b", // slate
"#6b7280", // gray
];
export const CategoryValueEditDialog: React.FC< export const CategoryValueEditDialog: React.FC<
CategoryValueEditDialogProps CategoryValueEditDialogProps
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => { > = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.valueLabel); const [valueLabel, setValueLabel] = useState(value.valueLabel);
const [description, setDescription] = useState(value.description || ""); const [description, setDescription] = useState(value.description || "");
const [color, setColor] = useState(value.color || "#3b82f6");
useEffect(() => { useEffect(() => {
setValueLabel(value.valueLabel); setValueLabel(value.valueLabel);
setDescription(value.description || ""); setDescription(value.description || "");
setColor(value.color || "#3b82f6");
}, [value]); }, [value]);
const handleSubmit = () => { const handleSubmit = () => {
@ -67,7 +41,6 @@ export const CategoryValueEditDialog: React.FC<
onUpdate(value.valueId!, { onUpdate(value.valueId!, {
valueLabel: valueLabel.trim(), valueLabel: valueLabel.trim(),
description: description.trim(), description: description.trim(),
color: color,
}); });
}; };
@ -84,57 +57,24 @@ export const CategoryValueEditDialog: React.FC<
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
</Label>
<Input <Input
id="valueLabel" id="valueLabel"
placeholder="예: 개발, 긴급, 진행중" placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel} value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)} onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5" className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus autoFocus
/> />
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
</div>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
()
</Label>
<Textarea <Textarea
id="description" id="description"
placeholder="설명을 입력하세요" placeholder="설명 (선택사항)"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5" className="text-xs sm:text-sm"
rows={3} rows={3}
/> />
</div> </div>
</div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button

View File

@ -5,23 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// 🆕 Context를 사용하여 open 상태 공유 const ResizableDialog = DialogPrimitive.Root;
const ResizableDialogContext = React.createContext<{ open: boolean }>({ open: false });
// 🆕 ResizableDialog를 래핑하여 Context 제공
const ResizableDialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
children,
open = false,
...props
}) => {
return (
<ResizableDialogContext.Provider value={{ open }}>
<DialogPrimitive.Root open={open} {...props}>
{children}
</DialogPrimitive.Root>
</ResizableDialogContext.Provider>
);
};
const ResizableDialogTrigger = DialogPrimitive.Trigger; const ResizableDialogTrigger = DialogPrimitive.Trigger;
@ -54,7 +38,6 @@ interface ResizableDialogContentProps
defaultHeight?: number; defaultHeight?: number;
modalId?: string; // localStorage 저장용 고유 ID modalId?: string; // localStorage 저장용 고유 ID
userId?: string; // 사용자별 저장용 userId?: string; // 사용자별 저장용
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
} }
const ResizableDialogContent = React.forwardRef< const ResizableDialogContent = React.forwardRef<
@ -67,13 +50,12 @@ const ResizableDialogContent = React.forwardRef<
children, children,
minWidth = 400, minWidth = 400,
minHeight = 300, minHeight = 300,
maxWidth = 1600, maxWidth = 1400,
maxHeight = 1200, maxHeight = 900,
defaultWidth = 600, defaultWidth = 600,
defaultHeight = 500, defaultHeight = 500,
modalId, modalId,
userId = "guest", userId = "guest",
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
style: userStyle, style: userStyle,
...props ...props
}, },
@ -87,7 +69,6 @@ const ResizableDialogContent = React.forwardRef<
if (!stableIdRef.current) { if (!stableIdRef.current) {
if (modalId) { if (modalId) {
stableIdRef.current = modalId; stableIdRef.current = modalId;
// // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
} else { } else {
// className 기반 ID 생성 // className 기반 ID 생성
if (className) { if (className) {
@ -95,7 +76,6 @@ const ResizableDialogContent = React.forwardRef<
return ((acc << 5) - acc) + char.charCodeAt(0); return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0); }, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
// console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
} else if (userStyle) { } else if (userStyle) {
// userStyle 기반 ID 생성 // userStyle 기반 ID 생성
const styleStr = JSON.stringify(userStyle); const styleStr = JSON.stringify(userStyle);
@ -103,11 +83,9 @@ const ResizableDialogContent = React.forwardRef<
return ((acc << 5) - acc) + char.charCodeAt(0); return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0); }, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
// console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
} else { } else {
// 기본 ID // 기본 ID
stableIdRef.current = 'modal-default'; stableIdRef.current = 'modal-default';
// console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
} }
} }
} }
@ -154,133 +132,39 @@ const ResizableDialogContent = React.forwardRef<
const [isResizing, setIsResizing] = React.useState(false); const [isResizing, setIsResizing] = React.useState(false);
const [resizeDirection, setResizeDirection] = React.useState<string>(""); const [resizeDirection, setResizeDirection] = React.useState<string>("");
const [isInitialized, setIsInitialized] = React.useState(false); const [isInitialized, setIsInitialized] = React.useState(false);
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
// 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open)
const context = React.useContext(ResizableDialogContext);
const actualOpen = externalOpen !== undefined ? externalOpen : context.open;
// 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋
const [wasOpen, setWasOpen] = React.useState(false);
// 모달이 열릴 때 초기 크기 설정 (localStorage 우선, 없으면 화면관리 설정)
React.useEffect(() => { React.useEffect(() => {
// console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId });
if (actualOpen && !wasOpen) {
// 모달이 방금 열림
// console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
setIsInitialized(false);
setWasOpen(true);
} else if (!actualOpen && wasOpen) {
// 모달이 방금 닫힘
// console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
setWasOpen(false);
}
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
React.useEffect(() => {
if (effectiveModalId !== lastModalId) {
// console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized });
setIsInitialized(false);
setUserResized(false); // 사용자 리사이징 플래그도 리셋
setLastModalId(effectiveModalId);
}
}, [effectiveModalId, lastModalId, isInitialized]);
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
React.useEffect(() => {
// console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
if (!isInitialized) { if (!isInitialized) {
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
let attempts = 0;
const maxAttempts = 10;
const measureContent = () => {
attempts++;
// scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
let contentWidth = defaultWidth;
let contentHeight = defaultHeight;
if (contentRef.current) {
// scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
contentWidth = contentRef.current.scrollWidth || defaultWidth;
contentHeight = contentRef.current.scrollHeight || defaultHeight;
// console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
} else {
// console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
// contentRef가 아직 없으면 재시도
if (attempts < maxAttempts) {
setTimeout(measureContent, 100);
return;
}
}
// 패딩 추가 (p-6 * 2 = 48px)
const paddingAndMargin = 48;
const initialSize = getInitialSize(); const initialSize = getInitialSize();
// 내용 크기 기반 최소 크기 계산 // localStorage에서 저장된 크기가 있는지 확인
const contentBasedSize = {
width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
};
// console.log("📐 내용 기반 크기:", contentBasedSize);
// localStorage에서 저장된 크기 확인
let finalSize = contentBasedSize;
if (effectiveModalId && typeof window !== 'undefined') { if (effectiveModalId && typeof window !== 'undefined') {
try { try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`; const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const saved = localStorage.getItem(storageKey); const saved = localStorage.getItem(storageKey);
// console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
if (saved) { if (saved) {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
// 저장된 크기가 있으면 그것을 사용 (사용자가 이전에 리사이즈한 크기)
// userResized 플래그 확인 const restoredSize = {
if (parsed.userResized) { width: Math.max(minWidth, Math.min(maxWidth, parsed.width || initialSize.width)),
const savedSize = { height: Math.max(minHeight, Math.min(maxHeight, parsed.height || initialSize.height)),
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
}; };
setSize(restoredSize);
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); setIsInitialized(true);
return;
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
// (사용자가 의도적으로 작게 만든 것을 존중)
finalSize = savedSize;
setUserResized(true);
// console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
} else {
// console.log(" 자동 계산된 크기는 무시, 내용 크기 사용");
}
} else {
// console.log(" localStorage에 저장된 크기 없음, 내용 크기 사용");
} }
} catch (error) { } catch (error) {
// console.error("❌ 모달 크기 복원 실패:", error); console.error("모달 크기 복원 실패:", error);
} }
} }
setSize(finalSize); // 저장된 크기가 없으면 초기 크기 사용 (화면관리 설정 크기)
setSize(initialSize);
setIsInitialized(true); setIsInitialized(true);
};
// 첫 시도는 300ms 후에 시작
setTimeout(measureContent, 300);
} }
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]); }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight]);
const startResize = (direction: string) => (e: React.MouseEvent) => { const startResize = (direction: string) => (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -322,28 +206,14 @@ const ResizableDialogContent = React.forwardRef<
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
// 사용자가 리사이징했음을 표시 // localStorage에 크기 저장 (리사이즈 후 새로고침해도 유지)
setUserResized(true); if (effectiveModalId && typeof window !== 'undefined') {
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
try { try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`; const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const currentSize = { width: size.width, height: size.height };
// contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용
const modalElement = contentRef.current.parentElement;
const actualWidth = modalElement?.offsetWidth || size.width;
const actualHeight = modalElement?.offsetHeight || size.height;
const currentSize = {
width: actualWidth,
height: actualHeight,
userResized: true, // 사용자가 직접 리사이징했음을 표시
};
localStorage.setItem(storageKey, JSON.stringify(currentSize)); localStorage.setItem(storageKey, JSON.stringify(currentSize));
// console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
} catch (error) { } catch (error) {
// console.error("❌ 모달 크기 저장 실패:", error); console.error("모달 크기 저장 실패:", error);
} }
} }
}; };
@ -373,7 +243,7 @@ const ResizableDialogContent = React.forwardRef<
minHeight: `${minHeight}px`, minHeight: `${minHeight}px`,
}} }}
> >
<div ref={contentRef} className="flex flex-col h-full overflow-auto"> <div ref={contentRef} className="flex flex-col h-full overflow-hidden">
{children} {children}
</div> </div>

View File

@ -20,26 +20,17 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 모든 hooks를 먼저 호출 (조건부 return 이전에) // 모든 hooks를 먼저 호출 (조건부 return 이전에)
const { webTypes } = useWebTypes({ active: "Y" }); const { webTypes } = useWebTypes({ active: "Y" });
// 디버깅: 이미지 타입만 로깅 // 디버깅: 전달받은 웹타입과 props 정보 로깅
// if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") { if (webType === "button") {
// console.log(`🖼️ DynamicWebTypeRenderer 이미지 호출: webType="${webType}"`, { console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
// component: props.component, webType,
// readonly: props.readonly, component: props.component,
// value: props.value, position: props.component?.position,
// widgetType: props.component?.widgetType, size: props.component?.size,
// }); style: props.component?.style,
// } config,
});
// if (webType === "button") { }
// console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
// webType,
// component: props.component,
// position: props.component?.position,
// size: props.component?.size,
// style: props.component?.style,
// config,
// });
// }
const webTypeDefinition = useMemo(() => { const webTypeDefinition = useMemo(() => {
return WebTypeRegistry.getWebType(webType); return WebTypeRegistry.getWebType(webType);
@ -73,35 +64,23 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
if (webType === "file" || props.component?.type === "file") { if (webType === "file" || props.component?.type === "file") {
try { try {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`); console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} catch (error) { } catch (error) {
console.error("FileUploadComponent 로드 실패:", error); console.error("FileUploadComponent 로드 실패:", error);
} }
} }
// 이미지 컴포넌트 강제 처리
if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") {
try {
// console.log(`🎯 이미지 컴포넌트 감지! webType: ${webType}`, { props, finalProps });
const { ImageWidget } = require("@/components/screen/widgets/types/ImageWidget");
// console.log(`✅ ImageWidget 로드 성공`);
return <ImageWidget {...props} {...finalProps} />;
} catch (error) {
console.error("❌ ImageWidget 로드 실패:", error);
}
}
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선) // 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
if (dbWebType?.component_name) { if (dbWebType?.component_name) {
try { try {
// console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`); console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
// console.log("DB 웹타입 정보:", dbWebType); console.log("DB 웹타입 정보:", dbWebType);
// FileWidget의 경우 FileUploadComponent 직접 사용 // FileWidget의 경우 FileUploadComponent 직접 사용
if (dbWebType.component_name === "FileWidget" || webType === "file") { if (dbWebType.component_name === "FileWidget" || webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log("✅ FileWidget → FileUploadComponent 사용"); console.log("✅ FileWidget → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} }
@ -109,7 +88,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name); // const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName); // console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
// return <ComponentByName {...props} {...finalProps} />; // return <ComponentByName {...props} {...finalProps} />;
// console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`); console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
// 로딩 중 메시지 대신 레지스트리로 폴백 // 로딩 중 메시지 대신 레지스트리로 폴백
// return <div>컴포넌트 로딩 중...</div>; // return <div>컴포넌트 로딩 중...</div>;
@ -120,18 +99,18 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 2순위: 레지스트리에 등록된 웹타입 사용 // 2순위: 레지스트리에 등록된 웹타입 사용
if (webTypeDefinition) { if (webTypeDefinition) {
// console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`); console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 // 파일 웹타입의 경우 FileUploadComponent 직접 사용
if (webType === "file") { if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log("✅ 파일 웹타입 → FileUploadComponent 사용"); console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} }
// 웹타입이 비활성화된 경우 // 웹타입이 비활성화된 경우
if (!webTypeDefinition.isActive) { if (!webTypeDefinition.isActive) {
// console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`); console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
return ( return (
<div className="rounded-md border border-dashed border-yellow-500/30 bg-yellow-50 p-3 dark:bg-yellow-950/20"> <div className="rounded-md border border-dashed border-yellow-500/30 bg-yellow-50 p-3 dark:bg-yellow-950/20">
<div className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400"> <div className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
@ -159,28 +138,28 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백) // 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
if (webType === "file") { if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용"); console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} }
// 텍스트 입력 웹타입들 // 텍스트 입력 웹타입들
if (["text", "email", "password", "tel"].includes(webType)) { if (["text", "email", "password", "tel"].includes(webType)) {
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent"); const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
// console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`); console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
return <TextInputComponent {...props} {...finalProps} />; return <TextInputComponent {...props} {...finalProps} />;
} }
// 숫자 입력 웹타입들 // 숫자 입력 웹타입들
if (["number", "decimal"].includes(webType)) { if (["number", "decimal"].includes(webType)) {
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent"); const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
// console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`); console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
return <NumberInputComponent {...props} {...finalProps} />; return <NumberInputComponent {...props} {...finalProps} />;
} }
// 날짜 입력 웹타입들 // 날짜 입력 웹타입들
if (["date", "datetime", "time"].includes(webType)) { if (["date", "datetime", "time"].includes(webType)) {
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent"); const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
// console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`); console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
return <DateInputComponent {...props} {...finalProps} />; return <DateInputComponent {...props} {...finalProps} />;
} }
@ -194,7 +173,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 기본 폴백: Input 컴포넌트 사용 // 기본 폴백: Input 컴포넌트 사용
const { Input } = require("@/components/ui/input"); const { Input } = require("@/components/ui/input");
const { filterDOMProps } = require("@/lib/utils/domPropsFilter"); const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
// console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`); console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
const safeFallbackProps = filterDOMProps(props); const safeFallbackProps = filterDOMProps(props);
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />; return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
} catch (error) { } catch (error) {

View File

@ -35,35 +35,12 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
// 동적 웹타입 렌더링 사용 // 동적 웹타입 렌더링 사용
if (widgetType) { if (widgetType) {
try { try {
// 파일 위젯만 디자인 모드에서 인터랙션 허용 // 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거)
// 이미지 위젯은 실행 모드(모달)에서만 업로드 가능하도록 함
const isFileWidget = widgetType === "file"; const isFileWidget = widgetType === "file";
const isImageWidget = widgetType === "image" || widgetType === "img" || widgetType === "picture" || widgetType === "photo";
const allowInteraction = isFileWidget;
// 이미지 위젯은 래퍼 없이 직접 렌더링 (크기 문제 해결)
if (isImageWidget) {
return (
<div className="pointer-events-none h-full w-full">
<DynamicWebTypeRenderer
webType={widgetType}
props={{
...commonProps,
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
isDesignMode: true, // 디자인 모드임을 명시
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
}}
config={widget.webTypeConfig}
/>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className={allowInteraction ? "flex-1" : "pointer-events-none flex-1"}> <div className={isFileWidget ? "flex-1" : "pointer-events-none flex-1"}>
<DynamicWebTypeRenderer <DynamicWebTypeRenderer
webType={widgetType} webType={widgetType}
props={{ props={{

View File

@ -1,69 +0,0 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface ImageWidgetConfigPanelProps {
config: any;
onConfigChange: (config: any) => void;
}
/**
*
*/
export function ImageWidgetConfigPanel({ config, onConfigChange }: ImageWidgetConfigPanelProps) {
const handleChange = (key: string, value: any) => {
onConfigChange({
...config,
[key]: value,
});
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-xs"> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="maxSize" className="text-xs">
(MB)
</Label>
<Input
id="maxSize"
type="number"
min="1"
max="10"
value={(config.maxSize || 5 * 1024 * 1024) / (1024 * 1024)}
onChange={(e) => handleChange("maxSize", parseInt(e.target.value) * 1024 * 1024)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
</Label>
<Input
id="placeholder"
type="text"
value={config.placeholder || "이미지를 업로드하세요"}
onChange={(e) => handleChange("placeholder", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium"> :</p>
<p>JPG, PNG, GIF, WebP</p>
</div>
</CardContent>
</Card>
);
}
export default ImageWidgetConfigPanel;

View File

@ -1,57 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ImageWidgetDefinition } from "./index";
import { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
/**
* ImageWidget
*
*/
export class ImageWidgetRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ImageWidgetDefinition;
render(): React.ReactElement {
return <ImageWidget {...this.props} renderer={this} />;
}
/**
*
*/
// image 타입 특화 속성 처리
protected getImageWidgetProps() {
const baseProps = this.getWebTypeProps();
// image 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 image 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
ImageWidgetRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
ImageWidgetRenderer.enableHotReload();
}

View File

@ -1,40 +0,0 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
/**
* ImageWidget
* image-widget
*/
export const ImageWidgetDefinition = createComponentDefinition({
id: "image-widget",
name: "이미지 위젯",
nameEng: "Image Widget",
description: "이미지 표시 및 업로드",
category: ComponentCategory.INPUT,
webType: "image",
component: ImageWidget,
defaultConfig: {
type: "image-widget",
webType: "image",
maxSize: 5 * 1024 * 1024, // 5MB
acceptedFormats: ["image/jpeg", "image/png", "image/gif", "image/webp"],
},
defaultSize: { width: 200, height: 200 },
configPanel: ImageWidgetConfigPanel,
icon: "Image",
tags: ["image", "upload", "media", "picture", "photo"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/image-widget",
});
// 컴포넌트 내보내기
export { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
export { ImageWidgetRenderer } from "./ImageWidgetRenderer";

View File

@ -28,7 +28,6 @@ import "./date-input/DateInputRenderer";
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체 // import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
import "./text-display/TextDisplayRenderer"; import "./text-display/TextDisplayRenderer";
import "./file-upload/FileUploadRenderer"; import "./file-upload/FileUploadRenderer";
import "./image-widget/ImageWidgetRenderer";
import "./slider-basic/SliderBasicRenderer"; import "./slider-basic/SliderBasicRenderer";
import "./toggle-switch/ToggleSwitchRenderer"; import "./toggle-switch/ToggleSwitchRenderer";
import "./image-display/ImageDisplayRenderer"; import "./image-display/ImageDisplayRenderer";

View File

@ -58,14 +58,14 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
return ( return (
<div <div
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm" className="relative h-full overflow-x-auto overflow-y-auto bg-background shadow-sm backdrop-blur-sm"
style={{ style={{
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
maxHeight: "100%", // 최대 높이 제한으로 스크롤 활성화
boxSizing: "border-box", boxSizing: "border-box",
}} }}
> >
<div className="relative flex-1 overflow-x-auto overflow-y-auto">
<Table <Table
className="w-full" className="w-full"
style={{ style={{
@ -78,15 +78,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableHeader <TableHeader
className={ className={
tableConfig.stickyHeader tableConfig.stickyHeader
? "sticky top-0 border-b shadow-md" ? "sticky top-0 z-20 border-b bg-background backdrop-blur-sm"
: "border-b" : "border-b bg-background backdrop-blur-sm"
} }
style={{
position: "sticky",
top: 0,
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
> >
<TableRow className="border-b"> <TableRow className="border-b">
{actualColumns.map((column, colIndex) => { {actualColumns.map((column, colIndex) => {
@ -109,15 +103,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={column.columnName} key={column.columnName}
className={cn( className={cn(
column.columnName === "__checkbox__" column.columnName === "__checkbox__"
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background" ? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3"
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background", : "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`, `text-${column.align}`,
column.sortable && "hover:bg-primary/10", column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일 // 고정 컬럼 스타일
column.fixed === "left" && column.fixed === "left" &&
"sticky z-40 border-r border-border bg-background shadow-sm", "sticky z-10 border-r border-border bg-background shadow-sm",
column.fixed === "right" && column.fixed === "right" &&
"sticky z-40 border-l border-border bg-background shadow-sm", "sticky z-10 border-l border-border bg-background shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만) // 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40", isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)} )}
@ -129,7 +123,6 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정 // sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
@ -253,6 +246,5 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
); );
}; };

View File

@ -7,7 +7,6 @@ import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache"; import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
ChevronLeft, ChevronLeft,
@ -142,14 +141,7 @@ export interface TableListComponentProps {
onClose?: () => void; onClose?: () => void;
screenId?: string; screenId?: string;
userId?: string; // 사용자 ID (컬럼 순서 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용)
onSelectedRowsChange?: ( onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
selectedRows: any[],
selectedRowsData: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
tableDisplayData?: any[],
) => void;
onConfigChange?: (config: any) => void; onConfigChange?: (config: any) => void;
refreshKey?: number; refreshKey?: number;
} }
@ -194,9 +186,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 객체인 경우 tableName 속성 추출 시도 // 객체인 경우 tableName 속성 추출 시도
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
} }
tableConfig.selectedTable = finalSelectedTable; tableConfig.selectedTable = finalSelectedTable;
@ -252,13 +242,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({}); const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState< const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string; inputType?: string }>>({});
Record<string, { webType?: string; codeCategory?: string; inputType?: string }> const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
const [searchValues, setSearchValues] = useState<Record<string, any>>({}); const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({}); const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
@ -288,20 +273,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
useEffect(() => { useEffect(() => {
if (!tableConfig.selectedTable || !userId) return; if (!tableConfig.selectedTable || !userId) return;
const userKey = userId || "guest"; const userKey = userId || 'guest';
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`; const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
const savedOrder = localStorage.getItem(storageKey); const savedOrder = localStorage.getItem(storageKey);
if (savedOrder) { if (savedOrder) {
try { try {
const parsedOrder = JSON.parse(savedOrder); const parsedOrder = JSON.parse(savedOrder);
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
setColumnOrder(parsedOrder); setColumnOrder(parsedOrder);
// 부모 컴포넌트에 초기 컬럼 순서 전달 // 부모 컴포넌트에 초기 컬럼 순서 전달
if (onSelectedRowsChange && parsedOrder.length > 0) { if (onSelectedRowsChange && parsedOrder.length > 0) {
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
const initialData = data.map((row: any) => { const initialData = data.map((row: any) => {
const reordered: any = {}; const reordered: any = {};
@ -319,16 +301,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return reordered; return reordered;
}); });
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
// 전역 저장소에 데이터 저장 // 전역 저장소에 데이터 저장
if (tableConfig.selectedTable) { if (tableConfig.selectedTable) {
tableDisplayStore.setTableData( tableDisplayStore.setTableData(
tableConfig.selectedTable, tableConfig.selectedTable,
initialData, initialData,
parsedOrder.filter((col) => col !== "__checkbox__"), parsedOrder.filter(col => col !== '__checkbox__'),
sortColumn, sortColumn,
sortDirection, sortDirection
); );
} }
@ -358,22 +338,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cached = tableColumnCache.get(cacheKey); const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
}
cached.columns.forEach((col: any) => { cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName; labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = { meta[col.columnName] = {
webType: col.webType, webType: col.webType,
codeCategory: col.codeCategory, codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
}; };
}); });
@ -455,130 +426,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 카테고리 값 매핑 로드 // 카테고리 값 매핑 로드
// ======================================== // ========================================
// 카테고리 컬럼 목록 추출 (useMemo로 최적화) useEffect(() => {
const categoryColumns = useMemo(() => { const loadCategoryMappings = async () => {
const cols = Object.entries(columnMeta) if (!tableConfig.selectedTable || !columnMeta) return;
try {
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category") .filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName); .map(([columnName, _]) => columnName);
console.log("🔍 [TableList] 카테고리 컬럼 추출:", { if (categoryColumns.length === 0) return;
columnMeta,
categoryColumns: cols,
columnMetaKeys: Object.keys(columnMeta),
});
return cols; const mappings: Record<string, Record<string, string>> = {};
}, [columnMeta]);
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
useEffect(() => {
const loadCategoryMappings = async () => {
console.log("🔄 [TableList] loadCategoryMappings 트리거:", {
hasTable: !!tableConfig.selectedTable,
table: tableConfig.selectedTable,
categoryColumnsLength: categoryColumns.length,
categoryColumns,
columnMetaKeys: Object.keys(columnMeta),
});
if (!tableConfig.selectedTable) {
console.log("⏭️ [TableList] 테이블 선택 안됨, 카테고리 매핑 로드 스킵");
return;
}
if (categoryColumns.length === 0) {
console.log("⏭️ [TableList] 카테고리 컬럼 없음, 카테고리 매핑 로드 스킵");
setCategoryMappings({});
return;
}
console.log("🚀 [TableList] 카테고리 매핑 로드 시작:", {
table: tableConfig.selectedTable,
categoryColumns,
columnMetaKeys: Object.keys(columnMeta),
});
try {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const columnName of categoryColumns) { for (const columnName of categoryColumns) {
try { try {
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`,
});
const apiClient = (await import("@/lib/api/client")).apiClient; const apiClient = (await import("@/lib/api/client")).apiClient;
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); const response = await apiClient.get(
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
console.log(`📡 [TableList] API 응답 [${columnName}]:`, { );
success: response.data.success,
dataLength: response.data.data?.length,
rawData: response.data,
items: response.data.data,
});
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {};
if (response.data.success && response.data.data) {
const mapping: Record<string, string> = {};
response.data.data.forEach((item: any) => { response.data.data.forEach((item: any) => {
// valueCode를 문자열로 변환하여 키로 사용 mapping[item.valueCode] = item.valueLabel;
const key = String(item.valueCode);
mapping[key] = {
label: item.valueLabel,
color: item.color,
};
console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`);
}); });
if (Object.keys(mapping).length > 0) {
mappings[columnName] = mapping; mappings[columnName] = mapping;
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
columnName,
mappingCount: Object.keys(mapping).length,
mappingKeys: Object.keys(mapping),
mapping,
});
} else {
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
}
} else {
console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, {
success: response.data.success,
hasData: !!response.data.data,
isArray: Array.isArray(response.data.data),
response: response.data,
});
}
} catch (error: any) {
console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, {
error: error.message,
stack: error.stack,
response: error.response?.data,
status: error.response?.status,
});
}
}
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
mappingsCount: Object.keys(mappings).length,
mappingsKeys: Object.keys(mappings),
mappings,
});
if (Object.keys(mappings).length > 0) {
setCategoryMappings(mappings);
setCategoryMappingsKey((prev) => prev + 1);
console.log("✅ [TableList] setCategoryMappings 호출 완료");
} else {
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
} }
} catch (error) { } catch (error) {
console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error); // 카테고리 값 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
} catch (error) {
console.error("TableListComponent 카테고리 매핑 로드 실패:", error);
} }
}; };
loadCategoryMappings(); loadCategoryMappings();
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns)]); // 더 명확한 의존성 }, [tableConfig.selectedTable, columnMeta]);
// ======================================== // ========================================
// 데이터 가져오기 // 데이터 가져오기
@ -690,8 +577,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
const handleSort = (column: string) => { const handleSort = (column: string) => {
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
let newSortColumn = column; let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc"; let newSortDirection: "asc" | "desc" = "asc";
@ -705,9 +590,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
newSortDirection = "asc"; newSortDirection = "asc";
} }
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
if (onSelectedRowsChange) { if (onSelectedRowsChange) {
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
@ -736,7 +618,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const bStr = String(bVal).toLowerCase(); const bStr = String(bVal).toLowerCase();
// 자연스러운 정렬 (숫자 포함 문자열) // 자연스러운 정렬 (숫자 포함 문자열)
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" }); const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' });
return newSortDirection === "desc" ? -comparison : comparison; return newSortDirection === "desc" ? -comparison : comparison;
}); });
@ -757,40 +639,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return reordered; return reordered;
}); });
console.log("✅ 정렬 정보 전달:", {
selectedRowsCount: selectedRows.size,
selectedRowsDataCount: selectedRowsData.length,
sortBy: newSortColumn,
sortOrder: newSortDirection,
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
tableDisplayDataCount: reorderedData.length,
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
});
onSelectedRowsChange( onSelectedRowsChange(
Array.from(selectedRows), Array.from(selectedRows),
selectedRowsData, selectedRowsData,
newSortColumn, newSortColumn,
newSortDirection, newSortDirection,
columnOrder.length > 0 ? columnOrder : undefined, columnOrder.length > 0 ? columnOrder : undefined,
reorderedData, reorderedData
); );
// 전역 저장소에 정렬된 데이터 저장 // 전역 저장소에 정렬된 데이터 저장
if (tableConfig.selectedTable) { if (tableConfig.selectedTable) {
const cleanColumnOrder = ( const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : visibleColumns.map(c => c.columnName)).filter(col => col !== '__checkbox__');
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
).filter((col) => col !== "__checkbox__");
tableDisplayStore.setTableData( tableDisplayStore.setTableData(
tableConfig.selectedTable, tableConfig.selectedTable,
reorderedData, reorderedData,
cleanColumnOrder, cleanColumnOrder,
newSortColumn, newSortColumn,
newSortDirection, newSortDirection
); );
} }
} else {
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
} }
}; };
@ -803,16 +671,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
fetchTableDataDebounced(); fetchTableDataDebounced();
}; };
const handleClearAdvancedFilters = useCallback(() => { const handleClearAdvancedFilters = () => {
console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues });
// 상태를 초기화하고 useEffect로 데이터 새로고침
setSearchValues({}); setSearchValues({});
setCurrentPage(1); setCurrentPage(1);
fetchTableDataDebounced();
// 강제로 데이터 새로고침 트리거 };
setRefreshTrigger((prev) => prev + 1);
}, [searchValues]);
const handleRefresh = () => { const handleRefresh = () => {
fetchTableDataDebounced(); fetchTableDataDebounced();
@ -887,8 +750,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isCurrentlySelected = selectedRows.has(rowKey); const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected); handleRowSelection(rowKey, !isCurrentlySelected);
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
}; };
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
@ -927,17 +788,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// columnOrder 상태가 있으면 그 순서대로 정렬 // columnOrder 상태가 있으면 그 순서대로 정렬
if (columnOrder.length > 0) { if (columnOrder.length > 0) {
const orderedCols = columnOrder const orderedCols = columnOrder
.map((colName) => cols.find((c) => c.columnName === colName)) .map(colName => cols.find(c => c.columnName === colName))
.filter(Boolean) as ColumnConfig[]; .filter(Boolean) as ColumnConfig[];
// columnOrder에 없는 새로운 컬럼들 추가 // columnOrder에 없는 새로운 컬럼들 추가
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName)); const remainingCols = cols.filter(c => !columnOrder.includes(c.columnName));
console.log("🔄 columnOrder 기반 정렬:", {
columnOrder,
orderedColsCount: orderedCols.length,
remainingColsCount: remainingCols.length,
});
return [...orderedCols, ...remainingCols]; return [...orderedCols, ...remainingCols];
} }
@ -949,41 +804,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const lastColumnOrderRef = useRef<string>(""); const lastColumnOrderRef = useRef<string>("");
useEffect(() => { useEffect(() => {
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
hasCallback: !!onSelectedRowsChange,
visibleColumnsLength: visibleColumns.length,
visibleColumnsNames: visibleColumns.map((c) => c.columnName),
});
if (!onSelectedRowsChange) { if (!onSelectedRowsChange) {
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
return; return;
} }
if (visibleColumns.length === 0) { if (visibleColumns.length === 0) {
console.warn("⚠️ visibleColumns가 비어있습니다!");
return; return;
} }
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외 const currentColumnOrder = visibleColumns
.map(col => col.columnName)
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); .filter(name => name !== "__checkbox__"); // 체크박스 컬럼 제외
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지) // 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
const columnOrderString = currentColumnOrder.join(","); const columnOrderString = currentColumnOrder.join(",");
console.log("🔍 [컬럼 순서] 비교:", {
current: columnOrderString,
last: lastColumnOrderRef.current,
isDifferent: columnOrderString !== lastColumnOrderRef.current,
});
if (columnOrderString === lastColumnOrderRef.current) { if (columnOrderString === lastColumnOrderRef.current) {
console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
return; return;
} }
lastColumnOrderRef.current = columnOrderString; lastColumnOrderRef.current = columnOrderString;
console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
// 선택된 행 데이터 가져오기 // 선택된 행 데이터 가져오기
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
@ -1011,9 +851,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortColumn, sortColumn,
sortDirection, sortDirection,
currentColumnOrder, currentColumnOrder,
reorderedData, reorderedData
); );
}, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 }, [visibleColumns.length, visibleColumns.map(c => c.columnName).join(",")]); // 의존성 단순화
const getColumnWidth = (column: ColumnConfig) => { const getColumnWidth = (column: ColumnConfig) => {
if (column.columnName === "__checkbox__") return 50; if (column.columnName === "__checkbox__") return 50;
@ -1080,56 +920,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType; const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시 // 카테고리 타입: 코드값 → 라벨로 변환
if (inputType === "image" && value && typeof value === "string") {
const imageUrl = getFullImageUrl(value);
return (
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover"
onError={(e) => {
e.currentTarget.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E";
}}
/>
);
}
// 카테고리 타입: 배지로 표시
if (inputType === "category") { if (inputType === "category") {
if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)]; if (mapping && value) {
const label = mapping[String(value)];
console.log(`🎨 [카테고리 배지] ${column.columnName}:`, { if (label) {
value, return label;
stringValue: String(value), }
mapping, }
categoryData, return String(value);
hasMapping: !!mapping,
hasCategoryData: !!categoryData,
allCategoryMappings: categoryMappings, // 전체 매핑 확인
categoryMappingsKeys: Object.keys(categoryMappings),
});
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
const { Badge } = require("@/components/ui/badge");
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
} }
// 코드 타입: 코드 값 → 코드명 변환 // 코드 타입: 코드 값 → 코드명 변환
@ -1188,7 +988,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return String(value); return String(value);
} }
}, },
[columnMeta, optimizedConvertCode, categoryMappings], [columnMeta, categoryMappings, optimizedConvertCode],
); );
// ======================================== // ========================================
@ -1307,19 +1107,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, []); }, []);
// 사용자 옵션 저장 핸들러 // 사용자 옵션 저장 핸들러
const handleTableOptionsSave = useCallback( const handleTableOptionsSave = useCallback((config: {
(config: {
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>; columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
showGridLines: boolean; showGridLines: boolean;
viewMode: "table" | "card" | "grouped-card"; viewMode: "table" | "card" | "grouped-card";
}) => { }) => {
// 컬럼 순서 업데이트 // 컬럼 순서 업데이트
const newColumnOrder = config.columns.map((col) => col.columnName); const newColumnOrder = config.columns.map(col => col.columnName);
setColumnOrder(newColumnOrder); setColumnOrder(newColumnOrder);
// 컬럼 너비 업데이트 // 컬럼 너비 업데이트
const newWidths: Record<string, number> = {}; const newWidths: Record<string, number> = {};
config.columns.forEach((col) => { config.columns.forEach(col => {
if (col.width) { if (col.width) {
newWidths[col.columnName] = col.width; newWidths[col.columnName] = col.width;
} }
@ -1327,7 +1126,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setColumnWidths(newWidths); setColumnWidths(newWidths);
// 틀고정 컬럼 업데이트 // 틀고정 컬럼 업데이트
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); const newFrozenColumns = config.columns.filter(col => col.frozen).map(col => col.columnName);
setFrozenColumns(newFrozenColumns); setFrozenColumns(newFrozenColumns);
// 그리드선 표시 업데이트 // 그리드선 표시 업데이트
@ -1337,8 +1136,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setViewMode(config.viewMode); setViewMode(config.viewMode);
// 컬럼 표시/숨기기 업데이트 // 컬럼 표시/숨기기 업데이트
const newDisplayColumns = displayColumns.map((col) => { const newDisplayColumns = displayColumns.map(col => {
const configCol = config.columns.find((c) => c.columnName === col.columnName); const configCol = config.columns.find(c => c.columnName === col.columnName);
if (configCol) { if (configCol) {
return { ...col, visible: configCol.visible }; return { ...col, visible: configCol.visible };
} }
@ -1347,9 +1146,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setDisplayColumns(newDisplayColumns); setDisplayColumns(newDisplayColumns);
toast.success("테이블 옵션이 저장되었습니다"); toast.success("테이블 옵션이 저장되었습니다");
}, }, [displayColumns]);
[displayColumns],
);
// 그룹 펼치기/접기 토글 // 그룹 펼치기/접기 토글
const toggleGroupCollapse = useCallback((groupKey: string) => { const toggleGroupCollapse = useCallback((groupKey: string) => {
@ -1441,9 +1238,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortColumn, sortColumn,
sortDirection, sortDirection,
searchTerm, searchTerm,
searchValues, // 필터 값 변경 시에도 데이터 새로고침
refreshKey, refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode, isDesignMode,
fetchTableDataDebounced, fetchTableDataDebounced,
]); ]);
@ -1498,9 +1293,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableConfig.pagination?.enabled || isDesignMode) return null; if (!tableConfig.pagination?.enabled || isDesignMode) return null;
return ( return (
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6"> <div
className="w-full h-14 flex items-center justify-center relative border-t-2 border-border bg-background px-4 flex-shrink-0 sm:h-[60px] sm:px-6"
>
{/* 중앙 페이지네이션 컨트롤 */} {/* 중앙 페이지네이션 컨트롤 */}
<div className="flex items-center gap-2 sm:gap-4"> <div
className="flex items-center gap-2 sm:gap-4"
>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -1520,7 +1319,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" /> <ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm"> <span className="text-xs font-medium text-foreground min-w-[60px] text-center sm:text-sm sm:min-w-[80px]">
{currentPage} / {totalPages || 1} {currentPage} / {totalPages || 1}
</span> </span>
@ -1543,7 +1342,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" /> <ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
<span className="text-muted-foreground ml-2 text-[10px] sm:ml-4 sm:text-xs"> <span className="text-[10px] text-muted-foreground ml-2 sm:text-xs sm:ml-4">
{totalItems.toLocaleString()} {totalItems.toLocaleString()}
</span> </span>
</div> </div>
@ -1596,15 +1395,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return ( return (
<div {...domProps}> <div {...domProps}>
{tableConfig.filter?.enabled && ( {tableConfig.filter?.enabled && (
<div className="border-border border-b px-4 py-2 sm:px-6 sm:py-2"> <div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1"> <div className="flex-1">
<AdvancedSearchFilters <AdvancedSearchFilters
filters={activeFilters} filters={activeFilters}
searchValues={searchValues} searchValues={searchValues}
onSearchValueChange={handleSearchValueChange} onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch} onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters} onClear={handleClearAdvancedFilters}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -1612,7 +1411,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsTableOptionsOpen(true)} onClick={() => setIsTableOptionsOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto" className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
> >
<TableIcon className="mr-2 h-4 w-4" /> <TableIcon className="mr-2 h-4 w-4" />
@ -1621,7 +1420,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsFilterSettingOpen(true)} onClick={() => setIsFilterSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto" className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
> >
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@ -1630,7 +1429,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsGroupSettingOpen(true)} onClick={() => setIsGroupSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto" className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
> >
<Layers className="mr-2 h-4 w-4" /> <Layers className="mr-2 h-4 w-4" />
@ -1642,7 +1441,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 그룹 표시 배지 */} {/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && ( {groupByColumns.length > 0 && (
<div className="border-border bg-muted/30 border-b px-4 py-1.5 sm:px-6"> <div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
<div className="flex items-center gap-2 text-xs sm:text-sm"> <div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -1666,7 +1465,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div> </div>
)} )}
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}> <div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px`, flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky <SingleTableWithSticky
data={data} data={data}
columns={visibleColumns} columns={visibleColumns}
@ -1703,7 +1502,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div {...domProps}> <div {...domProps}>
{/* 필터 */} {/* 필터 */}
{tableConfig.filter?.enabled && ( {tableConfig.filter?.enabled && (
<div className="border-border flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4"> <div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1"> <div className="flex-1">
<AdvancedSearchFilters <AdvancedSearchFilters
@ -1711,7 +1510,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchValues={searchValues} searchValues={searchValues}
onSearchValueChange={handleSearchValueChange} onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch} onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters} onClear={handleClearAdvancedFilters}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -1719,7 +1518,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsTableOptionsOpen(true)} onClick={() => setIsTableOptionsOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto" className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
> >
<TableIcon className="mr-2 h-4 w-4" /> <TableIcon className="mr-2 h-4 w-4" />
@ -1728,7 +1527,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsFilterSettingOpen(true)} onClick={() => setIsFilterSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto" className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
> >
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@ -1737,7 +1536,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsGroupSettingOpen(true)} onClick={() => setIsGroupSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto" className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
> >
<Layers className="mr-2 h-4 w-4" /> <Layers className="mr-2 h-4 w-4" />
@ -1749,7 +1548,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 그룹 표시 배지 */} {/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && ( {groupByColumns.length > 0 && (
<div className="border-border bg-muted/30 border-b px-4 py-2 sm:px-6"> <div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
<div className="flex items-center gap-2 text-xs sm:text-sm"> <div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -1775,17 +1574,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 테이블 컨테이너 */} {/* 테이블 컨테이너 */}
<div <div
className="flex w-full max-w-full flex-1 flex-col overflow-hidden" className="flex-1 flex flex-col overflow-hidden w-full max-w-full"
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px` }} style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px` }}
> >
{/* 스크롤 영역 */} {/* 스크롤 영역 */}
<div <div
className="bg-background h-[400px] w-full max-w-full overflow-x-auto overflow-y-scroll sm:h-[500px]" className="w-full max-w-full flex-1 overflow-y-auto overflow-x-auto bg-background"
style={{ position: "relative" }}
> >
{/* 테이블 */} {/* 테이블 */}
<table <table
className={cn("table-mobile-fixed w-full max-w-full", !showGridLines && "hide-grid")} className={cn(
"w-full max-w-full table-mobile-fixed",
!showGridLines && "hide-grid"
)}
style={{ style={{
borderCollapse: "collapse", borderCollapse: "collapse",
width: "100%", width: "100%",
@ -1794,20 +1595,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
> >
{/* 헤더 (sticky) */} {/* 헤더 (sticky) */}
<thead <thead
className="sticky z-50" className="sticky top-0 z-10"
style={{
position: "sticky",
top: "-2px",
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
>
<tr
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
style={{
backgroundColor: "hsl(var(--muted))",
}}
> >
<tr className="h-10 border-b-2 border-primary/20 bg-muted sm:h-12">
{visibleColumns.map((column, columnIndex) => { {visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName]; const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName); const isFrozen = frozenColumns.includes(column.columnName);
@ -1828,26 +1618,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
key={column.columnName} key={column.columnName}
ref={(el) => (columnRefs.current[column.columnName] = el)} ref={(el) => (columnRefs.current[column.columnName] = el)}
className={cn( className={cn(
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", "relative h-8 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
column.sortable !== false && (column.sortable !== false && column.columnName !== "__checkbox__") && "cursor-pointer hover:bg-muted/70 transition-colors",
column.columnName !== "__checkbox__" && isFrozen && "sticky z-20 bg-muted/80 backdrop-blur-sm shadow-[2px_0_4px_rgba(0,0,0,0.1)]"
"hover:bg-muted/70 cursor-pointer transition-colors",
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
)} )}
style={{ style={{
textAlign: column.columnName === "__checkbox__" ? "center" : "center", textAlign: column.columnName === "__checkbox__" ? "center" : "center",
width: width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
column.columnName === "__checkbox__" minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
? "48px" maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
: columnWidth userSelect: 'none',
? `${columnWidth}px` ...(isFrozen && { left: `${leftPosition}px` })
: undefined,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
userSelect: "none",
backgroundColor: "hsl(var(--muted))",
...(isFrozen && { left: `${leftPosition}px` }),
}} }}
onClick={() => { onClick={() => {
if (isResizing.current) return; if (isResizing.current) return;
@ -1869,8 +1651,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 리사이즈 핸들 (체크박스 제외) */} {/* 리사이즈 핸들 (체크박스 제외) */}
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && ( {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
<div <div
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500" className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }} style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지 onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
@ -1885,8 +1667,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const startWidth = columnWidth || thElement.offsetWidth; const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지 // 드래그 중 텍스트 선택 방지
document.body.style.userSelect = "none"; document.body.style.userSelect = 'none';
document.body.style.cursor = "col-resize"; document.body.style.cursor = 'col-resize';
const handleMouseMove = (moveEvent: MouseEvent) => { const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault(); moveEvent.preventDefault();
@ -1904,24 +1686,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 최종 너비를 state에 저장 // 최종 너비를 state에 저장
if (thElement) { if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth); const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths((prev) => ({ ...prev, [column.columnName]: finalWidth })); setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth }));
} }
// 텍스트 선택 복원 // 텍스트 선택 복원
document.body.style.userSelect = ""; document.body.style.userSelect = '';
document.body.style.cursor = ""; document.body.style.cursor = '';
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
setTimeout(() => { setTimeout(() => {
isResizing.current = false; isResizing.current = false;
}, 100); }, 100);
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
document.addEventListener("mousemove", handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}} }}
/> />
)} )}
@ -1932,13 +1714,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</thead> </thead>
{/* 바디 (스크롤) */} {/* 바디 (스크롤) */}
<tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}> <tbody>
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={visibleColumns.length} className="p-12 text-center"> <td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" /> <RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="text-muted-foreground text-sm font-medium"> ...</div> <div className="text-sm font-medium text-muted-foreground"> ...</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -1946,8 +1728,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr> <tr>
<td colSpan={visibleColumns.length} className="p-12 text-center"> <td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="text-destructive text-sm font-medium"> </div> <div className="text-sm font-medium text-destructive"> </div>
<div className="text-muted-foreground text-xs">{error}</div> <div className="text-xs text-muted-foreground">{error}</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -1955,9 +1737,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr> <tr>
<td colSpan={visibleColumns.length} className="p-12 text-center"> <td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<TableIcon className="text-muted-foreground/50 h-12 w-12" /> <TableIcon className="h-12 w-12 text-muted-foreground/50" />
<div className="text-muted-foreground text-sm font-medium"> </div> <div className="text-sm font-medium text-muted-foreground"> </div>
<div className="text-muted-foreground text-xs"> <div className="text-xs text-muted-foreground">
</div> </div>
</div> </div>
@ -1973,11 +1755,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr> <tr>
<td <td
colSpan={visibleColumns.length} colSpan={visibleColumns.length}
className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b" className="bg-muted border-b border-border sticky top-[48px] z-[5]"
style={{ top: "48px" }} style={{ top: "48px" }}
> >
<div <div
className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3" className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/80"
onClick={() => toggleGroupCollapse(group.groupKey)} onClick={() => toggleGroupCollapse(group.groupKey)}
> >
{isCollapsed ? ( {isCollapsed ? (
@ -1985,7 +1767,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
) : ( ) : (
<ChevronDown className="h-4 w-4 flex-shrink-0" /> <ChevronDown className="h-4 w-4 flex-shrink-0" />
)} )}
<span className="flex-1 text-sm font-medium">{group.groupKey}</span> <span className="font-medium text-sm flex-1">{group.groupKey}</span>
<span className="text-muted-foreground text-xs">({group.count})</span> <span className="text-muted-foreground text-xs">({group.count})</span>
</div> </div>
</td> </td>
@ -1996,7 +1778,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr <tr
key={index} key={index}
className={cn( className={cn(
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12", "h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
)} )}
onClick={(e) => handleRowClick(row, index, e)} onClick={(e) => handleRowClick(row, index, e)}
> >
@ -2025,24 +1807,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td <td
key={column.columnName} key={column.columnName}
className={cn( className={cn(
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm", "h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
)} )}
style={{ style={{
textAlign: textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
column.columnName === "__checkbox__" width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
? "center" minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
: isNumeric maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
? "right" ...(isFrozen && { left: `${leftPosition}px` })
: column.align || "left",
width:
column.columnName === "__checkbox__"
? "48px"
: `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && { left: `${leftPosition}px` }),
}} }}
> >
{column.columnName === "__checkbox__" {column.columnName === "__checkbox__"
@ -2062,7 +1836,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr <tr
key={index} key={index}
className={cn( className={cn(
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12", "h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
)} )}
onClick={(e) => handleRowClick(row, index, e)} onClick={(e) => handleRowClick(row, index, e)}
> >
@ -2091,21 +1865,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td <td
key={column.columnName} key={column.columnName}
className={cn( className={cn(
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm", "h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
)} )}
style={{ style={{
textAlign: textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
column.columnName === "__checkbox__" width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
? "center" minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
: isNumeric maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
? "right" ...(isFrozen && { left: `${leftPosition}px` })
: column.align || "left",
width: column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && { left: `${leftPosition}px` }),
}} }}
> >
{column.columnName === "__checkbox__" {column.columnName === "__checkbox__"
@ -2272,7 +2041,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<TableOptionsModal <TableOptionsModal
isOpen={isTableOptionsOpen} isOpen={isTableOptionsOpen}
onClose={() => setIsTableOptionsOpen(false)} onClose={() => setIsTableOptionsOpen(false)}
columns={visibleColumns.map((col) => ({ columns={visibleColumns.map(col => ({
columnName: col.columnName, columnName: col.columnName,
label: columnLabels[col.columnName] || col.displayName || col.columnName, label: columnLabels[col.columnName] || col.displayName || col.columnName,
visible: col.visible !== false, visible: col.visible !== false,

View File

@ -718,13 +718,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: componentConfig.placeholder || defaultPlaceholder : componentConfig.placeholder || defaultPlaceholder
} }
pattern={validationPattern} pattern={validationPattern}
title={ title={webType === "tel" ? "전화번호 형식: 010-1234-5678" : undefined}
webType === "tel"
? "전화번호 형식: 010-1234-5678"
: component.label
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
: component.columnName || undefined
}
disabled={componentConfig.disabled || false} disabled={componentConfig.disabled || false}
required={componentConfig.required || false} required={componentConfig.required || false}
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")} readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}

View File

@ -1987,12 +1987,7 @@ export class ButtonActionExecutor {
*/ */
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> { private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try { try {
console.log("📤 엑셀 업로드 모달 열기:", { console.log("📤 엑셀 업로드 모달 열기:", { config, context });
config,
context,
userId: context.userId,
tableName: context.tableName,
});
// 동적 import로 모달 컴포넌트 로드 // 동적 import로 모달 컴포넌트 로드
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
@ -2009,28 +2004,11 @@ export class ButtonActionExecutor {
document.body.removeChild(modalContainer); document.body.removeChild(modalContainer);
}; };
// localStorage 디버깅
const modalId = `excel-upload-${context.tableName || ""}`;
const storageKey = `modal_size_${modalId}_${context.userId || "guest"}`;
console.log("🔍 엑셀 업로드 모달 localStorage 확인:", {
modalId,
userId: context.userId,
storageKey,
savedSize: localStorage.getItem(storageKey),
});
root.render( root.render(
React.createElement(ExcelUploadModal, { React.createElement(ExcelUploadModal, {
open: true, open: true,
onOpenChange: (open: boolean) => { onOpenChange: (open: boolean) => {
if (!open) { if (!open) closeModal();
// 모달 닫을 때 localStorage 확인
console.log("🔍 모달 닫을 때 localStorage:", {
storageKey,
savedSize: localStorage.getItem(storageKey),
});
closeModal();
}
}, },
tableName: context.tableName || "", tableName: context.tableName || "",
uploadMode: config.excelUploadMode || "insert", uploadMode: config.excelUploadMode || "insert",

View File

@ -60,16 +60,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
* *
*/ */
export const COLUMN_NAME_TO_WEB_TYPE: Record<string, WebType> = { export const COLUMN_NAME_TO_WEB_TYPE: Record<string, WebType> = {
// 이미지 관련
image: "image",
img: "image",
picture: "image",
photo: "image",
thumbnail: "image",
avatar: "image",
icon: "image",
logo: "image",
// 이메일 관련 // 이메일 관련
email: "email", email: "email",
mail: "email", mail: "email",

View File

@ -42,12 +42,6 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
// 파일 // 파일
file: "file-upload", file: "file-upload",
// 이미지
image: "image-widget",
img: "image-widget",
picture: "image-widget",
photo: "image-widget",
// 버튼 // 버튼
button: "button-primary", button: "button-primary",

View File

@ -8,7 +8,7 @@
import { WebType } from "./unified-core"; import { WebType } from "./unified-core";
/** /**
* 9 * 8
*/ */
export type BaseInputType = export type BaseInputType =
| "text" // 텍스트 | "text" // 텍스트
@ -18,8 +18,7 @@ export type BaseInputType =
| "entity" // 엔티티 | "entity" // 엔티티
| "select" // 선택박스 | "select" // 선택박스
| "checkbox" // 체크박스 | "checkbox" // 체크박스
| "radio" // 라디오버튼 | "radio"; // 라디오버튼
| "image"; // 이미지
/** /**
* *
@ -93,9 +92,6 @@ export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>
{ value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" }, { value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" },
{ value: "radio-vertical", label: "세로 라디오", description: "세로 배치" }, { value: "radio-vertical", label: "세로 라디오", description: "세로 배치" },
], ],
// 이미지 → image
image: [{ value: "image", label: "이미지", description: "이미지 URL 표시" }],
}; };
/** /**
@ -140,9 +136,6 @@ export function getBaseInputType(webType: WebType): BaseInputType {
// entity // entity
if (webType === "entity") return "entity"; if (webType === "entity") return "entity";
// image
if (webType === "image") return "image";
// 기본값: text // 기본값: text
return "text"; return "text";
} }
@ -174,7 +167,6 @@ export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: strin
{ value: "select", label: "선택박스", description: "드롭다운 선택" }, { value: "select", label: "선택박스", description: "드롭다운 선택" },
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치" }, { value: "checkbox", label: "체크박스", description: "체크박스/스위치" },
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" }, { value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" },
{ value: "image", label: "이미지", description: "이미지 표시" },
]; ];
/** /**

View File

@ -15,8 +15,7 @@ export type InputType =
| "category" // 카테고리 | "category" // 카테고리
| "select" // 선택박스 | "select" // 선택박스
| "checkbox" // 체크박스 | "checkbox" // 체크박스
| "radio" // 라디오버튼 | "radio"; // 라디오버튼
| "image"; // 이미지
// 입력 타입 옵션 정의 // 입력 타입 옵션 정의
export interface InputTypeOption { export interface InputTypeOption {
@ -98,13 +97,6 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
category: "selection", category: "selection",
icon: "Circle", icon: "Circle",
}, },
{
value: "image",
label: "이미지",
description: "이미지 표시",
category: "basic",
icon: "Image",
},
]; ];
// 카테고리별 입력 타입 그룹화 // 카테고리별 입력 타입 그룹화

View File

@ -36,7 +36,6 @@ export type WebType =
| "code" // 공통코드 참조 | "code" // 공통코드 참조
| "entity" // 엔티티 참조 | "entity" // 엔티티 참조
| "file" // 파일 업로드 | "file" // 파일 업로드
| "image" // 이미지 표시
| "button"; // 버튼 컴포넌트 | "button"; // 버튼 컴포넌트
/** /**