Compare commits

...

18 Commits

Author SHA1 Message Date
kjs c22e38da76 Merge pull request 'feature/screen-management' (#189) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/189
2025-11-06 14:46:33 +09:00
kjs 786576bb76 커밋 2025-11-06 14:46:15 +09:00
kjs 832e80cd7f 배지 표시 수정 2025-11-06 14:18:36 +09:00
kjs 2e674e13d0 fix: resizable-dialog 주석 처리된 객체 리터럴 파싱 에러 수정
- 여러 줄 객체 리터럴을 한 줄로 변경
- console.log 주석이 파싱 에러를 일으키는 문제 해결
- 빌드 에러 해결
2025-11-06 13:26:54 +09:00
kjs bc826e8e49 chore: resizable-dialog 디버깅 로그 모두 제거
- console.log 20개 주석 처리
- 콘솔 스팸 방지
- 불필요한 로그 제거로 성능 개선
2025-11-06 12:46:08 +09:00
kjs 4affe623a5 fix: 카테고리 매핑 로딩 타이밍 개선
- loading 의존성 제거 (불필요한 재로드 방지)
- columnMeta 길이 변화로 매핑 로드 트리거
- 매핑 로드 전후 상태 디버깅 로그 추가
- categoryMappings 빈 객체 문제 해결
2025-11-06 12:43:01 +09:00
kjs f53a818f2f fix: 카테고리 매핑 변경 시 강제 리렌더링 추가
- categoryMappingsKey 상태 추가로 매핑 변경 감지
- 매핑 업데이트 시 key 증가로 tbody 리렌더링 강제
- 간헐적으로 배지가 표시되지 않던 타이밍 이슈 해결
- 카테고리 배지 렌더링 디버깅 로그 추가
2025-11-06 12:39:56 +09:00
kjs b5a83bb0f3 docs: inputType 사용 가이드 추가
- webType은 레거시, inputType만 사용해야 함을 명시
- API 호출 및 캐시 처리 방법 설명
- 실제 적용 사례 및 마이그레이션 체크리스트 포함
- 디버깅 팁 및 주요 inputType 종류 문서화
2025-11-06 12:32:17 +09:00
kjs 85e1b532fa fix: 캐시에서 inputType 누락 문제 해결
- 캐시된 데이터 사용 시 inputType이 설정되지 않던 문제 수정
- cached.inputTypes를 올바르게 매핑하여 meta에 포함
- webType 체크 제거, inputType만 사용하도록 변경
- 화면 전환 후 캐시 사용 시에도 카테고리 타입 정상 인식
2025-11-06 12:28:39 +09:00
kjs 4cd08c3900 fix: webType도 체크하여 카테고리 컬럼 감지
- inputType과 webType 모두 'category'인 경우 처리
- columnMeta에 inputType이 없어도 webType으로 감지 가능
- material 컬럼 등 webType만 있는 경우도 정상 동작
2025-11-06 12:27:22 +09:00
kjs 70dc24f7a1 fix: columnMeta 로딩 완료 후 카테고리 매핑 로드
- columnMeta가 비어있을 때 로딩 대기 로그 출력
- columnMeta 준비 완료 후에만 카테고리 매핑 시도
- 카테고리 컬럼 없음 로그에 디버깅 정보 추가
- 화면 전환 시 columnMeta → 카테고리 매핑 순서 보장
2025-11-06 12:26:07 +09:00
kjs cd961a2162 fix: 화면 복귀 시 카테고리 매핑 갱신 보장
- loading 상태를 의존성으로 변경
- 데이터 로드 완료 시점(loading: false)에 매핑 갱신
- 화면 전환 후 복귀 시에도 최신 카테고리 데이터 반영
- 로딩 중에는 매핑 로드하지 않도록 가드 추가
2025-11-06 12:24:12 +09:00
kjs 95b341df79 fix: 데이터 변경 시 카테고리 매핑 자동 갱신
- useEffect 의존성을 refreshTrigger에서 data.length로 변경
- 데이터가 추가/삭제/변경될 때마다 자동으로 매핑 갱신
- 화면 전환 후 데이터 로드 완료 시점에 매핑도 함께 갱신
2025-11-06 12:22:24 +09:00
kjs 49935189b6 fix: 화면 전환 후 카테고리 매핑 갱신 문제 해결
- useEffect 의존성 배열에 refreshTrigger 추가
- 데이터 새로고침 시 카테고리 매핑도 자동 갱신
- 매핑 로드 시작/종료 로그 추가하여 디버깅 용이성 향상
2025-11-06 12:20:58 +09:00
kjs 939a8696c8 feat: TableListComponent에서 카테고리 값을 배지로 표시
- categoryMappings 타입을 색상 포함하도록 수정
- 카테고리 값 로드 시 color 필드 포함
- formatValue에서 카테고리를 Badge 컴포넌트로 렌더링
- 매핑 없을 시에도 기본 slate 색상의 배지로 표시
- 디버깅 로그 추가
2025-11-06 12:18:43 +09:00
kjs b526d8ea2c fix: 카테고리 배지 표시 개선 및 디버깅 로그 추가
- 매핑이 없어도 항상 배지로 표시
- 매핑 없을 시 코드값 그대로 + 기본 slate 색상 사용
- 카테고리 매핑 로드 과정 로그 추가
- 기존 데이터에 기본 색상 추가하는 마이그레이션 스크립트 생성
2025-11-06 12:15:47 +09:00
kjs 7581cd1582 feat: 테이블 리스트에서 카테고리 값을 배지로 표시
- 카테고리 타입 컬럼을 배지 형태로 렌더링
- 사용자가 설정한 색상 적용
- categoryMappings에 라벨과 색상 모두 저장
- 기본 색상: #3b82f6 (파란색)
- 텍스트 색상: 흰색으로 고정하여 가독성 확보
2025-11-06 12:12:19 +09:00
kjs 1d87b6c3ac feat: 카테고리 값에 배지 색상 설정 기능 추가
- 카테고리 값 추가/편집 다이얼로그에 색상 선택기 추가
- 18가지 기본 색상 팔레트 제공
- 선택한 색상의 실시간 배지 미리보기
- color 필드를 통해 DB에 저장
- 테이블 리스트에서 배지 형태로 표시할 준비 완료
2025-11-06 12:09:28 +09:00
12 changed files with 1100 additions and 436 deletions

View File

@ -0,0 +1,279 @@
# 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

@ -22,7 +22,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang"; import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -545,9 +545,9 @@ export const MenuManagement: React.FC = () => {
// uiTexts에서 번역 텍스트 찾기 // uiTexts에서 번역 텍스트 찾기
let text = uiTexts[key]; let text = uiTexts[key];
// uiTexts에 없으면 fallback 또는 키 사용 // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기
if (!text) { if (!text) {
text = fallback || key; text = getMenuTextSync(key, userLang) || fallback || key;
} }
// 파라미터 치환 // 파라미터 치환

View File

@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner"; import { toast } from "sonner";
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang"; import { MENU_MANAGEMENT_KEYS, getMenuTextSync } from "@/lib/utils/multilang";
interface MenuTableProps { interface MenuTableProps {
menus: MenuItem[]; menus: MenuItem[];
@ -39,7 +39,8 @@ export const MenuTable: React.FC<MenuTableProps> = ({
}) => { }) => {
// 다국어 텍스트 가져오기 함수 // 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => { const getText = (key: string, fallback?: string): string => {
return uiTexts[key] || fallback || key; // uiTexts에서 먼저 찾고, 없으면 기본 한글 텍스트를 가져옴
return uiTexts[key] || getMenuTextSync(key, "KR") || fallback || key;
}; };
// 다국어 텍스트 표시 함수 (기본값 처리) // 다국어 텍스트 표시 함수 (기본값 처리)

View File

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

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, string>>>({}); const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: 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, string>> = {}; const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const col of categoryColumns) { for (const col of categoryColumns) {
try { try {
@ -217,18 +217,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
); );
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
// valueCode -> valueLabel 매핑 생성 // valueCode -> {label, color} 매핑 생성
const mapping: Record<string, string> = {}; const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => { response.data.data.forEach((item: any) => {
mapping[item.valueCode] = item.valueLabel; mapping[item.valueCode] = {
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);
@ -1911,13 +1916,27 @@ 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];
if (mapping && value) { const categoryData = mapping?.[String(value)];
const label = mapping[String(value)];
return label || String(value); // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
} const displayLabel = categoryData?.label || String(value);
return String(value || ""); const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor
}}
className="text-white"
>
{displayLabel}
</Badge>
);
} }
case "date": case "date":

View File

@ -11,9 +11,33 @@ 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;
@ -26,6 +50,7 @@ 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 => {
@ -59,13 +84,14 @@ export const CategoryValueAddDialog: React.FC<
valueCode, valueCode,
valueLabel: valueLabel.trim(), valueLabel: valueLabel.trim(),
description: description.trim(), description: description.trim(),
color: "#3b82f6", color: color,
isDefault: false, isDefault: false,
}); });
// 초기화 // 초기화
setValueLabel(""); setValueLabel("");
setDescription(""); setDescription("");
setColor("#3b82f6");
}; };
return ( return (
@ -81,23 +107,56 @@ 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">
<Input <div>
id="valueLabel" <Label htmlFor="valueLabel" className="text-xs sm:text-sm">
placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel} </Label>
onChange={(e) => setValueLabel(e.target.value)} <Input
className="h-8 text-xs sm:h-10 sm:text-sm" id="valueLabel"
autoFocus placeholder="예: 개발, 긴급, 진행중"
/> value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
autoFocus
/>
</div>
<Textarea <div>
id="description" <Label className="text-xs sm:text-sm"> </Label>
placeholder="설명 (선택사항)" <div className="mt-1.5 flex items-center gap-3">
value={description} <div className="grid grid-cols-9 gap-2">
onChange={(e) => setDescription(e.target.value)} {DEFAULT_COLORS.map((c) => (
className="text-xs sm:text-sm" <button
rows={3} 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
id="description"
placeholder="설명을 입력하세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5"
rows={3}
/>
</div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">

View File

@ -11,7 +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";
interface CategoryValueEditDialogProps { interface CategoryValueEditDialogProps {
@ -22,15 +24,39 @@ 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 = () => {
@ -41,6 +67,7 @@ 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,
}); });
}; };
@ -57,23 +84,56 @@ 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">
<Input <div>
id="valueLabel" <Label htmlFor="valueLabel" className="text-xs sm:text-sm">
placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel} </Label>
onChange={(e) => setValueLabel(e.target.value)} <Input
className="h-8 text-xs sm:h-10 sm:text-sm" id="valueLabel"
autoFocus placeholder="예: 개발, 긴급, 진행중"
/> value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
autoFocus
/>
</div>
<Textarea <div>
id="description" <Label className="text-xs sm:text-sm"> </Label>
placeholder="설명 (선택사항)" <div className="mt-1.5 flex items-center gap-3">
value={description} <div className="grid grid-cols-9 gap-2">
onChange={(e) => setDescription(e.target.value)} {DEFAULT_COLORS.map((c) => (
className="text-xs sm:text-sm" <button
rows={3} 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
id="description"
placeholder="설명을 입력하세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5"
rows={3}
/>
</div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">

View File

@ -49,25 +49,33 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>( const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(
null null
); );
const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김)
// 카테고리 값 로드 // 카테고리 값 로드
useEffect(() => { useEffect(() => {
loadCategoryValues(); loadCategoryValues();
}, [tableName, columnName]); }, [tableName, columnName]);
// 검색 필터링 // 검색 필터링 + 비활성 필터링
useEffect(() => { useEffect(() => {
let filtered = values;
// 비활성 항목 필터링 (기본: 활성만 표시, 체크하면 비활성도 표시)
if (!showInactive) {
filtered = filtered.filter((v) => v.isActive !== false);
}
// 검색어 필터링
if (searchQuery) { if (searchQuery) {
const filtered = values.filter( filtered = filtered.filter(
(v) => (v) =>
v.valueCode.toLowerCase().includes(searchQuery.toLowerCase()) || v.valueCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.valueLabel.toLowerCase().includes(searchQuery.toLowerCase()) v.valueLabel.toLowerCase().includes(searchQuery.toLowerCase())
); );
setFilteredValues(filtered);
} else {
setFilteredValues(values);
} }
}, [searchQuery, values]);
setFilteredValues(filtered);
}, [searchQuery, values, showInactive]);
const loadCategoryValues = async () => { const loadCategoryValues = async () => {
setIsLoading(true); setIsLoading(true);
@ -264,10 +272,27 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
{filteredValues.length} {filteredValues.length}
</p> </p>
</div> </div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm"> <div className="flex items-center gap-3">
<Plus className="mr-2 h-4 w-4" /> {/* 비활성 항목 표시 옵션 */}
<div className="flex items-center gap-2">
</Button> <Checkbox
id="show-inactive"
checked={showInactive}
onCheckedChange={(checked) => setShowInactive(checked as boolean)}
/>
<label
htmlFor="show-inactive"
className="text-sm text-muted-foreground cursor-pointer whitespace-nowrap"
>
</label>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div> </div>
{/* 검색바 */} {/* 검색바 */}
@ -294,73 +319,90 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filteredValues.map((value) => ( {filteredValues.map((value) => {
<div const isInactive = value.isActive === false;
key={value.valueId}
className="flex items-center gap-3 rounded-md border bg-card p-3 transition-colors hover:bg-accent"
>
<Checkbox
checked={selectedValueIds.includes(value.valueId!)}
onCheckedChange={() => handleSelectValue(value.valueId!)}
/>
<div className="flex flex-1 items-center gap-2"> return (
<Badge variant="outline" className="text-xs"> <div
{value.valueCode} key={value.valueId}
</Badge> className={`flex items-center gap-3 rounded-md border bg-card p-3 transition-colors hover:bg-accent ${
<span className="text-sm font-medium"> isInactive ? "opacity-50" : ""
{value.valueLabel} }`}
</span> >
{value.description && ( <Checkbox
<span className="text-xs text-muted-foreground"> checked={selectedValueIds.includes(value.valueId!)}
- {value.description} onCheckedChange={() => handleSelectValue(value.valueId!)}
</span>
)}
{value.isDefault && (
<Badge variant="secondary" className="text-[10px]">
</Badge>
)}
{value.color && (
<div
className="h-4 w-4 rounded-full border"
style={{ backgroundColor: value.color }}
/>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={value.isActive !== false}
onCheckedChange={() =>
handleToggleActive(
value.valueId!,
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/> />
<Button <div className="flex flex-1 items-center gap-2">
variant="ghost" {/* 색상 표시 (앞쪽으로 이동) */}
size="icon" {value.color && (
onClick={() => setEditingValue(value)} <div
className="h-8 w-8" className="h-4 w-4 rounded-full border flex-shrink-0"
> style={{ backgroundColor: value.color }}
<Edit2 className="h-3 w-3" /> />
</Button> )}
<Button {/* 라벨 */}
variant="ghost" <span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}>
size="icon" {value.valueLabel}
onClick={() => handleDeleteValue(value.valueId!)} </span>
className="h-8 w-8 text-destructive"
> {/* 설명 */}
<Trash2 className="h-3 w-3" /> {value.description && (
</Button> <span className="text-xs text-muted-foreground">
- {value.description}
</span>
)}
{/* 기본값 배지 */}
{value.isDefault && (
<Badge variant="secondary" className="text-[10px]">
</Badge>
)}
{/* 비활성 배지 */}
{isInactive && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={value.isActive !== false}
onCheckedChange={() =>
handleToggleActive(
value.valueId!,
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingValue(value)}
className="h-8 w-8"
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteValue(value.valueId!)}
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
</div> </div>

View File

@ -87,7 +87,7 @@ 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); // // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
} else { } else {
// className 기반 ID 생성 // className 기반 ID 생성
if (className) { if (className) {
@ -95,10 +95,7 @@ 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 생성:", { // console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
className,
generatedId: stableIdRef.current,
});
} else if (userStyle) { } else if (userStyle) {
// userStyle 기반 ID 생성 // userStyle 기반 ID 생성
const styleStr = JSON.stringify(userStyle); const styleStr = JSON.stringify(userStyle);
@ -106,14 +103,11 @@ 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 생성:", { // console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
userStyle,
generatedId: stableIdRef.current,
});
} else { } else {
// 기본 ID // 기본 ID
stableIdRef.current = 'modal-default'; stableIdRef.current = 'modal-default';
console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)"); // console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
} }
} }
} }
@ -171,22 +165,16 @@ const ResizableDialogContent = React.forwardRef<
const [wasOpen, setWasOpen] = React.useState(false); const [wasOpen, setWasOpen] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
console.log("🔍 모달 상태 변화 감지:", { // console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId });
actualOpen,
wasOpen,
externalOpen,
contextOpen: context.open,
effectiveModalId
});
if (actualOpen && !wasOpen) { if (actualOpen && !wasOpen) {
// 모달이 방금 열림 // 모달이 방금 열림
console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId }); // console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
setIsInitialized(false); setIsInitialized(false);
setWasOpen(true); setWasOpen(true);
} else if (!actualOpen && wasOpen) { } else if (!actualOpen && wasOpen) {
// 모달이 방금 닫힘 // 모달이 방금 닫힘
console.log("🔒 모달 닫힘 감지:", { effectiveModalId }); // console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
setWasOpen(false); setWasOpen(false);
} }
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]); }, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
@ -194,11 +182,7 @@ const ResizableDialogContent = React.forwardRef<
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우) // modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
React.useEffect(() => { React.useEffect(() => {
if (effectiveModalId !== lastModalId) { if (effectiveModalId !== lastModalId) {
console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { // console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized });
이전: lastModalId,
현재: effectiveModalId,
isInitialized,
});
setIsInitialized(false); setIsInitialized(false);
setUserResized(false); // 사용자 리사이징 플래그도 리셋 setUserResized(false); // 사용자 리사이징 플래그도 리셋
setLastModalId(effectiveModalId); setLastModalId(effectiveModalId);
@ -207,11 +191,7 @@ const ResizableDialogContent = React.forwardRef<
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용) // 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
React.useEffect(() => { React.useEffect(() => {
console.log("🔍 초기 크기 설정 useEffect 실행:", { // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
isInitialized,
hasContentRef: !!contentRef.current,
effectiveModalId,
});
if (!isInitialized) { if (!isInitialized) {
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기) // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
@ -231,22 +211,9 @@ const ResizableDialogContent = React.forwardRef<
contentWidth = contentRef.current.scrollWidth || defaultWidth; contentWidth = contentRef.current.scrollWidth || defaultWidth;
contentHeight = contentRef.current.scrollHeight || defaultHeight; contentHeight = contentRef.current.scrollHeight || defaultHeight;
console.log("📏 모달 내용 크기 측정:", { // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
attempt: attempts,
scrollWidth: contentRef.current.scrollWidth,
scrollHeight: contentRef.current.scrollHeight,
clientWidth: contentRef.current.clientWidth,
clientHeight: contentRef.current.clientHeight,
contentWidth,
contentHeight,
});
} else { } else {
console.log("⚠️ contentRef 없음, 재시도:", { // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
attempt: attempts,
maxAttempts,
defaultWidth,
defaultHeight
});
// contentRef가 아직 없으면 재시도 // contentRef가 아직 없으면 재시도
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
@ -265,7 +232,7 @@ const ResizableDialogContent = React.forwardRef<
height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))), height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
}; };
console.log("📐 내용 기반 크기:", contentBasedSize); // console.log("📐 내용 기반 크기:", contentBasedSize);
// localStorage에서 저장된 크기 확인 // localStorage에서 저장된 크기 확인
let finalSize = contentBasedSize; let finalSize = contentBasedSize;
@ -275,12 +242,7 @@ const ResizableDialogContent = React.forwardRef<
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 확인:", { // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
effectiveModalId,
userId,
storageKey,
saved: saved ? "있음" : "없음",
});
if (saved) { if (saved) {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
@ -292,27 +254,22 @@ const ResizableDialogContent = React.forwardRef<
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
}; };
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용 // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
// (사용자가 의도적으로 작게 만든 것을 존중) // (사용자가 의도적으로 작게 만든 것을 존중)
finalSize = savedSize; finalSize = savedSize;
setUserResized(true); setUserResized(true);
console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
savedSize,
contentBasedSize,
finalSize,
note: "사용자가 리사이징한 크기를 그대로 사용합니다",
});
} else { } else {
console.log(" 자동 계산된 크기는 무시, 내용 크기 사용"); // console.log(" 자동 계산된 크기는 무시, 내용 크기 사용");
} }
} else { } else {
console.log(" localStorage에 저장된 크기 없음, 내용 크기 사용"); // console.log(" localStorage에 저장된 크기 없음, 내용 크기 사용");
} }
} catch (error) { } catch (error) {
console.error("❌ 모달 크기 복원 실패:", error); // console.error("❌ 모달 크기 복원 실패:", error);
} }
} }
@ -384,15 +341,9 @@ const ResizableDialogContent = React.forwardRef<
userResized: true, // 사용자가 직접 리사이징했음을 표시 userResized: true, // 사용자가 직접 리사이징했음을 표시
}; };
localStorage.setItem(storageKey, JSON.stringify(currentSize)); localStorage.setItem(storageKey, JSON.stringify(currentSize));
console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { // console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
effectiveModalId,
userId,
storageKey,
size: currentSize,
stateSize: { width: size.width, height: size.height },
});
} catch (error) { } catch (error) {
console.error("❌ 모달 크기 저장 실패:", error); // console.error("❌ 모달 크기 저장 실패:", error);
} }
} }
}; };

View File

@ -142,7 +142,14 @@ export interface TableListComponentProps {
onClose?: () => void; onClose?: () => void;
screenId?: string; screenId?: string;
userId?: string; // 사용자 ID (컬럼 순서 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용)
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; onSelectedRowsChange?: (
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;
} }
@ -245,7 +252,13 @@ 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<Record<string, { webType?: string; codeCategory?: string }>>({}); const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: 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>>({});
@ -275,7 +288,7 @@ 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);
@ -313,9 +326,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableDisplayStore.setTableData( tableDisplayStore.setTableData(
tableConfig.selectedTable, tableConfig.selectedTable,
initialData, initialData,
parsedOrder.filter(col => col !== '__checkbox__'), parsedOrder.filter((col) => col !== "__checkbox__"),
sortColumn, sortColumn,
sortDirection sortDirection,
); );
} }
@ -345,13 +358,22 @@ 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 }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: 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 사용!
}; };
}); });
@ -429,6 +451,135 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
}, [tableConfig.selectedTable]); }, [tableConfig.selectedTable]);
// ========================================
// 카테고리 값 매핑 로드
// ========================================
// 카테고리 컬럼 목록 추출 (useMemo로 최적화)
const categoryColumns = useMemo(() => {
const cols = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
console.log("🔍 [TableList] 카테고리 컬럼 추출:", {
columnMeta,
categoryColumns: cols,
columnMetaKeys: Object.keys(columnMeta),
});
return cols;
}, [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) {
try {
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`,
});
const apiClient = (await import("@/lib/api/client")).apiClient;
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 }> = {};
response.data.data.forEach((item: any) => {
// valueCode를 문자열로 변환하여 키로 사용
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;
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) {
console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error);
}
};
loadCategoryMappings();
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns)]); // 더 명확한 의존성
// ======================================== // ========================================
// 데이터 가져오기 // 데이터 가져오기
// ======================================== // ========================================
@ -585,7 +736,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;
}); });
@ -614,7 +765,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
columnOrder: columnOrder.length > 0 ? columnOrder : undefined, columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
tableDisplayDataCount: reorderedData.length, tableDisplayDataCount: reorderedData.length,
firstRowAfterSort: reorderedData[0]?.[newSortColumn], firstRowAfterSort: reorderedData[0]?.[newSortColumn],
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn] lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
}); });
onSelectedRowsChange( onSelectedRowsChange(
Array.from(selectedRows), Array.from(selectedRows),
@ -622,18 +773,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
newSortColumn, newSortColumn,
newSortDirection, newSortDirection,
columnOrder.length > 0 ? columnOrder : undefined, columnOrder.length > 0 ? columnOrder : undefined,
reorderedData reorderedData,
); );
// 전역 저장소에 정렬된 데이터 저장 // 전역 저장소에 정렬된 데이터 저장
if (tableConfig.selectedTable) { if (tableConfig.selectedTable) {
const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : visibleColumns.map(c => c.columnName)).filter(col => col !== '__checkbox__'); const cleanColumnOrder = (
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 { } else {
@ -774,16 +927,16 @@ 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 기반 정렬:", { console.log("🔄 columnOrder 기반 정렬:", {
columnOrder, columnOrder,
orderedColsCount: orderedCols.length, orderedColsCount: orderedCols.length,
remainingColsCount: remainingCols.length remainingColsCount: remainingCols.length,
}); });
return [...orderedCols, ...remainingCols]; return [...orderedCols, ...remainingCols];
@ -799,7 +952,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", { console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
hasCallback: !!onSelectedRowsChange, hasCallback: !!onSelectedRowsChange,
visibleColumnsLength: visibleColumns.length, visibleColumnsLength: visibleColumns.length,
visibleColumnsNames: visibleColumns.map(c => c.columnName), visibleColumnsNames: visibleColumns.map((c) => c.columnName),
}); });
if (!onSelectedRowsChange) { if (!onSelectedRowsChange) {
@ -812,9 +965,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return; return;
} }
const currentColumnOrder = visibleColumns const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
.map(col => col.columnName)
.filter(name => name !== "__checkbox__"); // 체크박스 컬럼 제외
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
@ -860,9 +1011,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;
@ -936,14 +1087,51 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<img <img
src={imageUrl} src={imageUrl}
alt="이미지" alt="이미지"
className="h-10 w-10 object-cover rounded" className="h-10 w-10 rounded object-cover"
onError={(e) => { 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"; 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 (!value) return "";
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
console.log(`🎨 [카테고리 배지] ${column.columnName}:`, {
value,
stringValue: String(value),
mapping,
categoryData,
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>
);
}
// 코드 타입: 코드 값 → 코드명 변환 // 코드 타입: 코드 값 → 코드명 변환
if (inputType === "code" && meta?.codeCategory && value) { if (inputType === "code" && meta?.codeCategory && value) {
try { try {
@ -1000,7 +1188,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return String(value); return String(value);
} }
}, },
[columnMeta, optimizedConvertCode], [columnMeta, optimizedConvertCode, categoryMappings],
); );
// ======================================== // ========================================
@ -1119,46 +1307,49 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, []); }, []);
// 사용자 옵션 저장 핸들러 // 사용자 옵션 저장 핸들러
const handleTableOptionsSave = useCallback((config: { const handleTableOptionsSave = useCallback(
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>; (config: {
showGridLines: boolean; columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
viewMode: "table" | "card" | "grouped-card"; showGridLines: boolean;
}) => { viewMode: "table" | "card" | "grouped-card";
// 컬럼 순서 업데이트 }) => {
const newColumnOrder = config.columns.map(col => col.columnName); // 컬럼 순서 업데이트
setColumnOrder(newColumnOrder); const newColumnOrder = config.columns.map((col) => col.columnName);
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;
} }
}); });
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);
// 그리드선 표시 업데이트 // 그리드선 표시 업데이트
setShowGridLines(config.showGridLines); setShowGridLines(config.showGridLines);
// 보기 모드 업데이트 // 보기 모드 업데이트
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 };
} }
return col; return col;
}); });
setDisplayColumns(newDisplayColumns); setDisplayColumns(newDisplayColumns);
toast.success("테이블 옵션이 저장되었습니다"); toast.success("테이블 옵션이 저장되었습니다");
}, [displayColumns]); },
[displayColumns],
);
// 그룹 펼치기/접기 토글 // 그룹 펼치기/접기 토글
const toggleGroupCollapse = useCallback((groupKey: string) => { const toggleGroupCollapse = useCallback((groupKey: string) => {
@ -1307,13 +1498,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableConfig.pagination?.enabled || isDesignMode) return null; if (!tableConfig.pagination?.enabled || isDesignMode) return null;
return ( return (
<div <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">
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 <div className="flex items-center gap-2 sm:gap-4">
className="flex items-center gap-2 sm:gap-4"
>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -1333,7 +1520,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-xs font-medium text-foreground min-w-[60px] text-center sm:text-sm sm:min-w-[80px]"> <span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
{currentPage} / {totalPages || 1} {currentPage} / {totalPages || 1}
</span> </span>
@ -1356,7 +1543,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-[10px] text-muted-foreground ml-2 sm:text-xs sm:ml-4"> <span className="text-muted-foreground ml-2 text-[10px] sm:ml-4 sm:text-xs">
{totalItems.toLocaleString()} {totalItems.toLocaleString()}
</span> </span>
</div> </div>
@ -1409,7 +1596,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return ( return (
<div {...domProps}> <div {...domProps}>
{tableConfig.filter?.enabled && ( {tableConfig.filter?.enabled && (
<div className="px-4 py-2 border-b border-border sm:px-6 sm:py-2"> <div className="border-border border-b px-4 py-2 sm:px-6 sm:py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="flex-1"> <div className="flex-1">
<AdvancedSearchFilters <AdvancedSearchFilters
@ -1425,7 +1612,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsTableOptionsOpen(true)} onClick={() => setIsTableOptionsOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1" className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
> >
<TableIcon className="mr-2 h-4 w-4" /> <TableIcon className="mr-2 h-4 w-4" />
@ -1434,7 +1621,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsFilterSettingOpen(true)} onClick={() => setIsFilterSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1" className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
> >
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@ -1443,7 +1630,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsGroupSettingOpen(true)} onClick={() => setIsGroupSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1" className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
> >
<Layers className="mr-2 h-4 w-4" /> <Layers className="mr-2 h-4 w-4" />
@ -1455,7 +1642,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 그룹 표시 배지 */} {/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && ( {groupByColumns.length > 0 && (
<div className="px-4 py-1.5 border-b border-border bg-muted/30 sm:px-6"> <div className="border-border bg-muted/30 border-b px-4 py-1.5 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">
@ -1516,7 +1703,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div {...domProps}> <div {...domProps}>
{/* 필터 */} {/* 필터 */}
{tableConfig.filter?.enabled && ( {tableConfig.filter?.enabled && (
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4"> <div className="border-border flex-shrink-0 border-b px-4 py-3 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
@ -1532,7 +1719,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsTableOptionsOpen(true)} onClick={() => setIsTableOptionsOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1" className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
> >
<TableIcon className="mr-2 h-4 w-4" /> <TableIcon className="mr-2 h-4 w-4" />
@ -1541,7 +1728,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsFilterSettingOpen(true)} onClick={() => setIsFilterSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1" className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
> >
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@ -1550,7 +1737,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsGroupSettingOpen(true)} onClick={() => setIsGroupSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1" className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
> >
<Layers className="mr-2 h-4 w-4" /> <Layers className="mr-2 h-4 w-4" />
@ -1562,7 +1749,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 그룹 표시 배지 */} {/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && ( {groupByColumns.length > 0 && (
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6"> <div className="border-border bg-muted/30 border-b px-4 py-2 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">
@ -1588,20 +1775,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 테이블 컨테이너 */} {/* 테이블 컨테이너 */}
<div <div
className="flex-1 flex flex-col overflow-hidden w-full max-w-full" className="flex w-full max-w-full flex-1 flex-col overflow-hidden"
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px` }} style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px` }}
> >
{/* 스크롤 영역 */} {/* 스크롤 영역 */}
<div <div
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]" className="bg-background h-[400px] w-full max-w-full overflow-x-auto overflow-y-scroll sm:h-[500px]"
style={{ position: "relative" }} style={{ position: "relative" }}
> >
{/* 테이블 */} {/* 테이블 */}
<table <table
className={cn( className={cn("table-mobile-fixed w-full max-w-full", !showGridLines && "hide-grid")}
"w-full max-w-full table-mobile-fixed",
!showGridLines && "hide-grid"
)}
style={{ style={{
borderCollapse: "collapse", borderCollapse: "collapse",
width: "100%", width: "100%",
@ -1619,7 +1803,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}} }}
> >
<tr <tr
className="h-10 border-b-2 border-primary/20 bg-muted sm:h-12" className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
style={{ style={{
backgroundColor: "hsl(var(--muted))", backgroundColor: "hsl(var(--muted))",
}} }}
@ -1644,19 +1828,26 @@ 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(
"relative h-8 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", "text-foreground/90 relative h-8 overflow-hidden text-xs font-bold 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.columnName !== "__checkbox__") && "cursor-pointer hover:bg-muted/70 transition-colors", column.sortable !== false &&
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]" column.columnName !== "__checkbox__" &&
"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: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined), width:
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, column.columnName === "__checkbox__"
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, ? "48px"
userSelect: 'none', : columnWidth
? `${columnWidth}px`
: undefined,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
userSelect: "none",
backgroundColor: "hsl(var(--muted))", backgroundColor: "hsl(var(--muted))",
...(isFrozen && { left: `${leftPosition}px` }) ...(isFrozen && { left: `${leftPosition}px` }),
}} }}
onClick={() => { onClick={() => {
if (isResizing.current) return; if (isResizing.current) return;
@ -1678,8 +1869,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 right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20" className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
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();
@ -1694,8 +1885,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();
@ -1713,24 +1904,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);
}} }}
/> />
)} )}
@ -1741,13 +1932,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</thead> </thead>
{/* 바디 (스크롤) */} {/* 바디 (스크롤) */}
<tbody style={{ position: "relative" }}> <tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}>
{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="h-8 w-8 animate-spin text-muted-foreground" /> <RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" />
<div className="text-sm font-medium text-muted-foreground"> ...</div> <div className="text-muted-foreground text-sm font-medium"> ...</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -1755,8 +1946,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-sm font-medium text-destructive"> </div> <div className="text-destructive text-sm font-medium"> </div>
<div className="text-xs text-muted-foreground">{error}</div> <div className="text-muted-foreground text-xs">{error}</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -1764,9 +1955,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="h-12 w-12 text-muted-foreground/50" /> <TableIcon className="text-muted-foreground/50 h-12 w-12" />
<div className="text-sm font-medium text-muted-foreground"> </div> <div className="text-muted-foreground text-sm font-medium"> </div>
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
</div> </div>
</div> </div>
@ -1782,11 +1973,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr> <tr>
<td <td
colSpan={visibleColumns.length} colSpan={visibleColumns.length}
className="bg-muted/50 border-b border-border sticky top-[48px] z-[5]" className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b"
style={{ top: "48px" }} style={{ top: "48px" }}
> >
<div <div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted" className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3"
onClick={() => toggleGroupCollapse(group.groupKey)} onClick={() => toggleGroupCollapse(group.groupKey)}
> >
{isCollapsed ? ( {isCollapsed ? (
@ -1794,7 +1985,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="font-medium text-sm flex-1">{group.groupKey}</span> <span className="flex-1 text-sm font-medium">{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>
@ -1802,57 +1993,65 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 그룹 데이터 */} {/* 그룹 데이터 */}
{!isCollapsed && {!isCollapsed &&
group.items.map((row, index) => ( group.items.map((row, index) => (
<tr <tr
key={index} key={index}
className={cn( className={cn(
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12" "bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
)} )}
onClick={(e) => handleRowClick(row, index, e)} onClick={(e) => handleRowClick(row, index, e)}
> >
{visibleColumns.map((column, colIndex) => { {visibleColumns.map((column, colIndex) => {
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName]; const cellValue = row[mappedColumnName];
const meta = columnMeta[column.columnName]; const meta = columnMeta[column.columnName];
const inputType = meta?.inputType || column.inputType; const inputType = meta?.inputType || column.inputType;
const isNumeric = inputType === "number" || inputType === "decimal"; const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName); const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName); const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 // 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0; let leftPosition = 0;
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = frozenColumns[i];
const frozenColWidth = columnWidths[frozenCol] || 150; const frozenColWidth = columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;
} }
} }
return ( return (
<td <td
key={column.columnName} key={column.columnName}
className={cn( className={cn(
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm", "text-foreground h-10 overflow-hidden text-xs 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 && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]" isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
)} )}
style={{ style={{
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")), textAlign:
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`, column.columnName === "__checkbox__"
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, ? "center"
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, : isNumeric
...(isFrozen && { left: `${leftPosition}px` }) ? "right"
}} : column.align || "left",
> width:
{column.columnName === "__checkbox__" column.columnName === "__checkbox__"
? renderCheckboxCell(row, index) ? "48px"
: formatCellValue(cellValue, column, row)} : `${100 / visibleColumns.length}%`,
</td> minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
); maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
})} ...(isFrozen && { left: `${leftPosition}px` }),
</tr> }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
);
})}
</tr>
))} ))}
</React.Fragment> </React.Fragment>
); );
@ -1863,7 +2062,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr <tr
key={index} key={index}
className={cn( className={cn(
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12" "bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
)} )}
onClick={(e) => handleRowClick(row, index, e)} onClick={(e) => handleRowClick(row, index, e)}
> >
@ -1892,16 +2091,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td <td
key={column.columnName} key={column.columnName}
className={cn( className={cn(
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm", "text-foreground h-10 overflow-hidden text-xs 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 && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]" isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
)} )}
style={{ style={{
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")), textAlign:
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`, column.columnName === "__checkbox__"
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, ? "center"
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, : isNumeric
...(isFrozen && { left: `${leftPosition}px` }) ? "right"
: 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__"
@ -2068,7 +2272,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

@ -288,9 +288,19 @@ function getDefaultText(key: string): string {
[MENU_MANAGEMENT_KEYS.USER_MENU]: "사용자 메뉴", [MENU_MANAGEMENT_KEYS.USER_MENU]: "사용자 메뉴",
[MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION]: "시스템 관리 및 설정 메뉴", [MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION]: "시스템 관리 및 설정 메뉴",
[MENU_MANAGEMENT_KEYS.USER_DESCRIPTION]: "일반 사용자 업무 메뉴", [MENU_MANAGEMENT_KEYS.USER_DESCRIPTION]: "일반 사용자 업무 메뉴",
[MENU_MANAGEMENT_KEYS.LIST_TITLE]: "메뉴 목록",
[MENU_MANAGEMENT_KEYS.LIST_TOTAL]: "전체",
[MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT]: "검색 결과",
[MENU_MANAGEMENT_KEYS.FILTER_COMPANY]: "회사 필터",
[MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL]: "전체",
[MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON]: "공통",
[MENU_MANAGEMENT_KEYS.FILTER_COMPANY_SEARCH]: "회사 검색...",
[MENU_MANAGEMENT_KEYS.FILTER_SEARCH]: "검색",
[MENU_MANAGEMENT_KEYS.FILTER_SEARCH_PLACEHOLDER]: "메뉴명 검색...",
[MENU_MANAGEMENT_KEYS.FILTER_RESET]: "초기화",
[MENU_MANAGEMENT_KEYS.BUTTON_ADD]: "추가", [MENU_MANAGEMENT_KEYS.BUTTON_ADD]: "추가",
[MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL]: "최상위 메뉴 추가", [MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL]: "최상위 메뉴 추가",
[MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB]: "하위 메뉴 추가", [MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB]: "하위",
[MENU_MANAGEMENT_KEYS.BUTTON_EDIT]: "수정", [MENU_MANAGEMENT_KEYS.BUTTON_EDIT]: "수정",
[MENU_MANAGEMENT_KEYS.BUTTON_DELETE]: "삭제", [MENU_MANAGEMENT_KEYS.BUTTON_DELETE]: "삭제",
[MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED]: "선택 삭제", [MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED]: "선택 삭제",
@ -340,9 +350,48 @@ function getDefaultText(key: string): string {
[MENU_MANAGEMENT_KEYS.STATUS_ACTIVE]: "활성화", [MENU_MANAGEMENT_KEYS.STATUS_ACTIVE]: "활성화",
[MENU_MANAGEMENT_KEYS.STATUS_INACTIVE]: "비활성화", [MENU_MANAGEMENT_KEYS.STATUS_INACTIVE]: "비활성화",
[MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED]: "미지정", [MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED]: "미지정",
[MENU_MANAGEMENT_KEYS.MESSAGE_LOADING]: "로딩 중...",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_PROCESSING]: "메뉴를 삭제하는 중...",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_SUCCESS]: "메뉴가 성공적으로 저장되었습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED]: "메뉴 저장에 실패했습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_SUCCESS]: "메뉴가 성공적으로 삭제되었습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_FAILED]: "메뉴 삭제에 실패했습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_SUCCESS]: "{count}개의 메뉴가 성공적으로 삭제되었습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_PARTIAL]: "{success}개 삭제됨, {failed}개 실패",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_SUCCESS]: "메뉴 상태가 변경되었습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_FAILED]: "메뉴 상태 변경에 실패했습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED]: "메뉴명을 입력하세요.",
[MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED]: "회사를 선택하세요.",
[MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_SELECT_MENU_DELETE]: "삭제할 메뉴를 선택하세요.",
[MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_LIST]: "메뉴 목록을 불러오는데 실패했습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO]: "메뉴 정보를 불러오는데 실패했습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST]: "회사 목록을 불러오는데 실패했습니다.",
[MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST]: "다국어 키 목록을 불러오는데 실패했습니다.",
[MENU_MANAGEMENT_KEYS.UI_EXPAND]: "펼치기",
[MENU_MANAGEMENT_KEYS.UI_COLLAPSE]: "접기",
[MENU_MANAGEMENT_KEYS.UI_MENU_COLLAPSE]: "메뉴 접기",
[MENU_MANAGEMENT_KEYS.UI_LANGUAGE]: "언어",
// 추가 매핑: key 문자열 자체도 한글로 매핑
"menu.type.title": "메뉴 타입",
"menu.management.admin": "관리자",
"menu.management.admin.description": "시스템 관리 및 설정 메뉴",
"menu.management.user": "사용자",
"menu.management.user.description": "일반 사용자 업무 메뉴",
"menu.list.title": "메뉴 목록",
"filter.company.all": "전체",
"filter.search.placeholder": "메뉴명 검색...",
"filter.reset": "초기화",
"button.add.top.level": "최상위 메뉴 추가",
"button.delete.selected": "선택 삭제",
"table.header.menu.name": "메뉴명",
"table.header.sequence": "순서",
"table.header.company": "회사",
"table.header.menu.url": "URL",
"table.header.status": "상태",
"table.header.actions": "작업",
}; };
return defaultTexts[key] || key; return defaultTexts[key] || "";
} }
/** /**