; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-10-22 17:07:52 +09:00
commit 5e1d2507da
64 changed files with 7541 additions and 4741 deletions

View File

@ -0,0 +1,749 @@
---
description: 관리자 페이지 표준 스타일 가이드 - shadcn/ui 기반 일관된 디자인 시스템
globs: **/app/(main)/admin/**/*.tsx,**/components/admin/**/*.tsx
---
# 관리자 페이지 표준 스타일 가이드
이 가이드는 관리자 페이지의 일관된 UI/UX를 위한 표준 스타일 규칙입니다.
모든 관리자 페이지는 이 가이드를 따라야 합니다.
## 1. 페이지 레이아웃 구조
### 기본 페이지 템플릿
```tsx
export default function AdminPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
<p className="text-sm text-muted-foreground">페이지 설명</p>
</div>
{/* 메인 컨텐츠 */}
<MainComponent />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}
```
**필수 적용 사항:**
- 최상위: `flex min-h-screen flex-col bg-background`
- 컨텐츠 영역: `space-y-6 p-6` (24px 좌우 여백, 24px 간격)
- 헤더 구분선: `border-b pb-4` (테두리 박스 사용 금지)
- Scroll to Top: 모든 관리자 페이지에 포함
## 2. Color System (색상 시스템)
### CSS Variables 사용 (하드코딩 금지)
```tsx
// ❌ 잘못된 예시
<div className="bg-gray-50 text-gray-900 border-gray-200">
// ✅ 올바른 예시
<div className="bg-background text-foreground border-border">
<div className="bg-card text-card-foreground">
<div className="bg-muted text-muted-foreground">
```
**표준 색상 토큰:**
- `bg-background` / `text-foreground`: 기본 배경/텍스트
- `bg-card` / `text-card-foreground`: 카드 배경/텍스트
- `bg-muted` / `text-muted-foreground`: 보조 배경/텍스트
- `bg-primary` / `text-primary`: 메인 액션
- `bg-destructive` / `text-destructive`: 삭제/에러
- `border-border`: 테두리
- `ring-ring`: 포커스 링
## 3. Typography (타이포그래피)
### 표준 텍스트 크기와 가중치
```tsx
// 페이지 제목
<h1 className="text-3xl font-bold tracking-tight">
// 섹션 제목
<h2 className="text-xl font-semibold">
<h3 className="text-lg font-semibold">
<h4 className="text-sm font-semibold">
// 본문 텍스트
<p className="text-sm">
// 보조 텍스트
<p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
// 라벨
<label className="text-sm font-medium">
```
## 4. Spacing System (간격)
### 일관된 간격 (4px 기준)
```tsx
// 페이지 레벨 간격
<div className="space-y-6"> // 24px
// 섹션 레벨 간격
<div className="space-y-4"> // 16px
// 필드 레벨 간격
<div className="space-y-2"> // 8px
// 패딩
<div className="p-6"> // 24px (카드)
<div className="p-4"> // 16px (내부 섹션)
// 갭
<div className="gap-4"> // 16px (flex/grid)
<div className="gap-2"> // 8px (버튼 그룹)
```
## 5. 검색 툴바 (Toolbar)
### 패턴 A: 통합 검색 영역 (권장)
```tsx
<div className="space-y-4">
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 통합 검색 */}
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="통합 검색..." className="h-10 pl-10 text-sm" />
</div>
</div>
{/* 고급 검색 토글 */}
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
고급 검색
</Button>
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
총{" "}
<span className="font-semibold text-foreground">
{count.toLocaleString()}
</span>{" "}
</div>
<Button className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
등록
</Button>
</div>
</div>
{/* 고급 검색 옵션 */}
{showAdvanced && (
<div className="space-y-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold">고급 검색 옵션</h4>
<p className="text-xs text-muted-foreground">설명</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Input placeholder="필드 검색" className="h-10 text-sm" />
</div>
</div>
)}
</div>
```
### 패턴 B: 제목 + 검색 + 버튼 한 줄 (공간 효율적)
```tsx
{
/* 상단 헤더: 제목 + 검색 + 버튼 */
}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 왼쪽: 제목 */}
<h2 className="text-xl font-semibold">페이지 제목</h2>
{/* 오른쪽: 검색 + 버튼 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* 필터 선택 */}
<div className="w-full sm:w-[160px]">
<Select>
<SelectTrigger className="h-10">
<SelectValue placeholder="필터" />
</SelectTrigger>
</Select>
</div>
{/* 검색 입력 */}
<div className="w-full sm:w-[240px]">
<Input placeholder="검색..." className="h-10 text-sm" />
</div>
{/* 초기화 버튼 */}
<Button variant="outline" className="h-10 text-sm font-medium">
초기화
</Button>
{/* 주요 액션 버튼 */}
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
등록
</Button>
{/* 조건부 버튼 (선택 시) */}
{selectedCount > 0 && (
<Button variant="destructive" className="h-10 gap-2 text-sm font-medium">
삭제 ({selectedCount})
</Button>
)}
</div>
</div>;
```
**필수 적용 사항:**
- ❌ 검색 영역에 박스/테두리 사용 금지
- ✅ 검색창 권장 너비: `w-full sm:w-[240px]` ~ `sm:w-[400px]`
- ✅ 필터/Select 권장 너비: `w-full sm:w-[160px]` ~ `sm:w-[200px]`
- ✅ 고급 검색 필드: placeholder만 사용 (라벨 제거)
- ✅ 검색 아이콘: `Search` (lucide-react)
- ✅ Input/Select 높이: `h-10` (40px)
- ✅ 상단 헤더에 `relative` 추가 (드롭다운 표시용)
## 6. Button (버튼)
### 표준 버튼 variants와 크기
```tsx
// Primary 액션
<Button variant="default" size="default" className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
등록
</Button>
// Secondary 액션
<Button variant="outline" size="default" className="h-10 gap-2 text-sm font-medium">
취소
</Button>
// Ghost 버튼 (아이콘 전용)
<Button variant="ghost" size="icon" className="h-8 w-8">
<Icon className="h-4 w-4" />
</Button>
// Destructive
<Button variant="destructive" size="default" className="h-10 gap-2 text-sm font-medium">
삭제
</Button>
```
**표준 크기:**
- `h-10`: 기본 버튼 (40px)
- `h-9`: 작은 버튼 (36px)
- `h-8`: 아이콘 버튼 (32px)
**아이콘 크기:**
- `h-4 w-4`: 버튼 내 아이콘 (16px)
## 7. Input (입력 필드)
### 표준 Input 스타일
```tsx
// 기본
<Input placeholder="입력..." className="h-10 text-sm" />
// 검색 (아이콘 포함)
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="검색..." className="h-10 pl-10 text-sm" />
</div>
// 로딩/액티브
<Input className="h-10 text-sm border-primary ring-2 ring-primary/20" />
// 비활성화
<Input disabled className="h-10 text-sm cursor-not-allowed bg-muted text-muted-foreground" />
```
**필수 적용 사항:**
- 높이: `h-10` (40px)
- 텍스트: `text-sm`
- 포커스: 자동 적용 (`ring-2 ring-ring`)
## 8. Table & Card (테이블과 카드)
### 반응형 테이블/카드 구조
```tsx
// 실제 데이터 렌더링
return (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold">컬럼</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 text-sm">데이터</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{items.map((item) => (
<div
key={item.id}
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{item.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{item.id}</p>
</div>
<Switch checked={item.active} />
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">필드</span>
<span className="font-medium">{item.value}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
>
<Icon className="h-4 w-4" />
액션
</Button>
</div>
</div>
))}
</div>
</>
);
```
**테이블 표준:**
- 헤더: `h-12` (48px), `bg-muted/50`, `font-semibold`
- 데이터 행: `h-16` (64px), `hover:bg-muted/50`
- 텍스트: `text-sm`
**카드 표준:**
- 컨테이너: `rounded-lg border bg-card p-4 shadow-sm`
- 헤더 제목: `text-base font-semibold`
- 부제목: `text-sm text-muted-foreground`
- 정보 라벨: `text-sm text-muted-foreground`
- 정보 값: `text-sm font-medium`
- 버튼: `h-9 flex-1 gap-2 text-sm`
## 9. Loading States (로딩 상태)
### Skeleton UI 패턴
```tsx
// 테이블 스켈레톤 (데스크톱)
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>...</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
// 카드 스켈레톤 (모바일/태블릿)
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</div>
<div className="h-6 w-11 animate-pulse rounded-full bg-muted"></div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
</div>
))}
</div>
</div>
))}
</div>
```
## 10. Empty States (빈 상태)
### 표준 Empty State
```tsx
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">데이터가 없습니다.</p>
</div>
</div>
```
## 11. Error States (에러 상태)
### 표준 에러 메시지
```tsx
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-destructive">
오류가 발생했습니다
</p>
<button
onClick={clearError}
className="text-destructive transition-colors hover:text-destructive/80"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="mt-1.5 text-sm text-destructive/80">{errorMessage}</p>
</div>
```
## 12. Responsive Design (반응형)
### Breakpoints
- `sm`: 640px (모바일 가로/태블릿)
- `md`: 768px (태블릿)
- `lg`: 1024px (노트북)
- `xl`: 1280px (데스크톱)
### 모바일 우선 패턴
```tsx
// 레이아웃
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
// 그리드
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
// 검색창
<div className="w-full sm:w-[400px]">
// 테이블/카드 전환
<div className="hidden lg:block"> {/* 데스크톱 테이블 */}
<div className="lg:hidden"> {/* 모바일 카드 */}
// 간격
<div className="p-4 sm:p-6">
<div className="gap-2 sm:gap-4">
```
## 13. 좌우 레이아웃 (Side-by-Side Layout)
### 사이드바 + 메인 영역 구조
```tsx
<div className="flex h-full gap-6">
{/* 좌측 사이드바 (20-30%) */}
<div className="w-[20%] border-r pr-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">사이드바 제목</h3>
{/* 사이드바 컨텐츠 */}
<div className="space-y-3">
<div className="cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md">
<h4 className="text-sm font-semibold">항목</h4>
<p className="mt-1 text-xs text-muted-foreground">설명</p>
</div>
</div>
</div>
</div>
{/* 우측 메인 영역 (70-80%) */}
<div className="w-[80%] pl-0">
<div className="flex h-full flex-col space-y-4">
<h2 className="text-xl font-semibold">메인 제목</h2>
{/* 메인 컨텐츠 */}
<div className="flex-1 overflow-hidden">{/* 컨텐츠 */}</div>
</div>
</div>
</div>
```
**필수 적용 사항:**
- ✅ 좌우 구분: `border-r` 사용 (세로 구분선)
- ✅ 간격: `gap-6` (24px)
- ✅ 사이드바 패딩: `pr-6` (오른쪽 24px)
- ✅ 메인 영역 패딩: `pl-0` (gap으로 간격 확보)
- ✅ 비율: 20:80 또는 30:70
- ❌ 과도한 구분선 사용 금지 (세로 구분선 1개만)
- ❌ 사이드바와 메인 영역 각각에 추가 border 금지
## 14. Custom Dropdown (커스텀 드롭다운)
### 커스텀 Select/Dropdown 구조
```tsx
{
/* 드롭다운 컨테이너 */
}
<div className="w-full sm:w-[160px]">
<div className="company-dropdown relative">
{/* 트리거 버튼 */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={!value ? "text-muted-foreground" : ""}>
{value || "선택하세요"}
</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* 드롭다운 메뉴 */}
{isOpen && (
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg">
{/* 검색 (선택사항) */}
<div className="border-b p-2">
<Input
placeholder="검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* 옵션 목록 */}
<div className="max-h-48 overflow-y-auto">
{options.map((option) => (
<div
key={option.value}
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setValue(option.value);
setIsOpen(false);
}}
>
{option.label}
</div>
))}
</div>
</div>
)}
</div>
</div>;
```
**필수 적용 사항:**
- ✅ z-index: `z-[100]` (다른 요소 위에 표시)
- ✅ 그림자: `shadow-lg` (명확한 레이어 구분)
- ✅ 최소 너비: `min-w-[200px]` (내용이 잘리지 않도록)
- ✅ 최대 높이: `max-h-48` (스크롤 가능)
- ✅ 애니메이션: 화살표 아이콘 회전 (`rotate-180`)
- ✅ 부모 요소: `relative` 클래스 필요
- ⚠️ 부모에 `overflow-hidden` 사용 시 드롭다운 잘림 주의
**드롭다운이 잘릴 때 해결방법:**
```tsx
// 부모 요소의 overflow 제거
<div className="w-[80%] pl-0"> // overflow-hidden 제거
// 또는 상단 헤더에 relative 추가
<div className="relative flex ..."> // 드롭다운 포지셔닝 기준점
```
## 15. Scroll to Top Button
### 모바일/태블릿 전용 버튼
```tsx
import { ScrollToTop } from "@/components/common/ScrollToTop";
// 페이지에 추가
<ScrollToTop />;
```
**특징:**
- 데스크톱에서 숨김 (`lg:hidden`)
- 스크롤 200px 이상 시 나타남
- 부드러운 페이드 인/아웃 애니메이션
- 오른쪽 하단 고정 위치
- 원형 디자인 (`rounded-full`)
## 14. Accessibility (접근성)
### 필수 적용 사항
```tsx
// Label과 Input 연결
<label htmlFor="field-id" className="text-sm font-medium">
라벨
</label>
<Input id="field-id" />
// 버튼에 aria-label
<Button aria-label="설명">
<Icon />
</Button>
// Switch에 aria-label
<Switch
checked={isActive}
onCheckedChange={handleChange}
aria-label="상태 토글"
/>
// 포커스 표시 (자동 적용)
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
```
## 15. Class 순서 (일관성)
### 표준 클래스 작성 순서
1. Layout: `flex`, `grid`, `block`
2. Position: `fixed`, `absolute`, `relative`
3. Sizing: `w-full`, `h-10`
4. Spacing: `p-4`, `m-2`, `gap-4`
5. Typography: `text-sm`, `font-medium`
6. Colors: `bg-primary`, `text-white`
7. Border: `border`, `rounded-md`
8. Effects: `shadow-sm`, `opacity-50`
9. States: `hover:`, `focus:`, `disabled:`
10. Responsive: `sm:`, `md:`, `lg:`
## 16. 금지 사항
### ❌ 절대 사용하지 말 것
1. 하드코딩된 색상 (`bg-gray-50`, `text-blue-500` 등)
2. 인라인 스타일로 색상 지정 (`style={{ color: '#3b82f6' }}`)
3. 포커스 스타일 제거 (`outline-none`만 단독 사용)
4. 중첩된 박스 (Card 안에 Card, Border 안에 Border)
5. 검색 영역에 불필요한 박스/테두리
6. 검색 필드에 라벨 (placeholder만 사용)
7. 반응형 무시 (데스크톱 전용 스타일)
8. **이모지 사용** (사용자가 명시적으로 요청하지 않는 한 절대 사용 금지)
9. 과도한 구분선 사용 (최소한으로 유지)
10. 드롭다운 부모에 `overflow-hidden` (잘림 발생)
## 17. 체크리스트
새로운 관리자 페이지 작성 시 다음을 확인하세요:
### 페이지 레벨
- [ ] `bg-background` 사용 (하드코딩 금지)
- [ ] `space-y-6 p-6` 구조
- [ ] 페이지 헤더에 `border-b pb-4`
- [ ] `ScrollToTop` 컴포넌트 포함
### 검색 툴바
- [ ] 박스/테두리 없음
- [ ] 검색창 최대 너비 `sm:w-[400px]`
- [ ] 고급 검색 필드에 라벨 없음 (placeholder만)
- [ ] 반응형 레이아웃 적용
### 테이블/카드
- [ ] 데스크톱: 테이블 (`hidden lg:block`)
- [ ] 모바일: 카드 (`lg:hidden`)
- [ ] 표준 높이와 간격 적용
- [ ] 로딩/Empty 상태 구현
### 버튼
- [ ] 표준 variants 사용
- [ ] 표준 높이: `h-10`, `h-9`, `h-8`
- [ ] 아이콘 크기: `h-4 w-4`
- [ ] `gap-2`로 아이콘과 텍스트 간격
### 반응형
- [ ] 모바일 우선 디자인
- [ ] Breakpoints 적용 (`sm:`, `lg:`)
- [ ] 테이블/카드 전환
- [ ] Scroll to Top 버튼
### 접근성
- [ ] Label `htmlFor` / Input `id` 연결
- [ ] 버튼 `aria-label`
- [ ] Switch `aria-label`
- [ ] 포커스 표시 유지
## 참고 파일
완성된 예시:
### 기본 패턴
- [사용자 관리 페이지](<mdc:frontend/app/(main)/admin/userMng/page.tsx>) - 기본 페이지 구조
- [검색 툴바](mdc:frontend/components/admin/UserToolbar.tsx) - 패턴 A (통합 검색)
- [테이블/카드](mdc:frontend/components/admin/UserTable.tsx) - 반응형 테이블/카드
- [Scroll to Top](mdc:frontend/components/common/ScrollToTop.tsx) - 스크롤 버튼
### 고급 패턴
- [메뉴 관리 페이지](<mdc:frontend/app/(main)/admin/menu/page.tsx>) - 좌우 레이아웃 + 패턴 B (제목+검색+버튼)
- [메뉴 관리 컴포넌트](mdc:frontend/components/admin/MenuManagement.tsx) - 커스텀 드롭다운 + 좌우 레이아웃

View File

@ -0,0 +1,435 @@
# 관리자 페이지 스타일 가이드 적용 예시
## 개요
사용자 관리 페이지를 예시로 shadcn/ui 스타일 가이드에 맞춰 재작성했습니다.
이 예시를 기준으로 다른 관리자 페이지들도 일관된 스타일로 통일할 수 있습니다.
## 적용된 주요 원칙
### 1. Color System (색상 시스템)
**CSS Variables 사용 (하드코딩된 색상 금지)**
```tsx
// ❌ 잘못된 예시
<div className="bg-gray-50 text-gray-900">
// ✅ 올바른 예시
<div className="bg-background text-foreground">
<div className="bg-card text-card-foreground">
<div className="bg-muted text-muted-foreground">
<div className="text-primary">
<div className="text-destructive">
```
**적용 사례:**
- 페이지 배경: `bg-background`
- 카드 배경: `bg-card`
- 보조 텍스트: `text-muted-foreground`
- 주요 액션: `text-primary`, `border-primary`
- 에러 메시지: `text-destructive`, `bg-destructive/10`
### 2. Typography (타이포그래피)
**일관된 폰트 크기와 가중치**
```tsx
// 페이지 제목
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
// 섹션 제목
<h4 className="text-sm font-semibold">고급 검색 옵션</h4>
// 본문 텍스트
<p className="text-sm text-muted-foreground">설명 텍스트</p>
// 라벨
<label className="text-sm font-medium">필드 라벨</label>
// 보조 텍스트
<p className="text-xs text-muted-foreground">도움말</p>
```
### 3. Spacing System (간격)
**일관된 간격 사용 (4px 기준)**
```tsx
// 컴포넌트 간 간격
<div className="space-y-6"> // 24px (페이지 레벨)
<div className="space-y-4"> // 16px (섹션 레벨)
<div className="space-y-2"> // 8px (필드 레벨)
// 패딩
<div className="p-6"> // 24px (카드)
<div className="p-4"> // 16px (내부 섹션)
// 갭
<div className="gap-4"> // 16px (flex/grid)
<div className="gap-2"> // 8px (버튼 그룹)
```
### 4. Border & Radius (테두리 및 둥근 모서리)
**표준 radius 사용**
```tsx
// 카드/패널
<div className="rounded-lg border bg-card">
// 입력 필드
<Input className="rounded-md">
// 버튼
<Button className="rounded-md">
```
### 5. Button Variants (버튼 스타일)
**표준 variants 사용**
```tsx
// Primary 액션
<Button variant="default" size="default" className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
사용자 등록
</Button>
// Secondary 액션
<Button variant="outline" size="default" className="h-10 gap-2 text-sm font-medium">
고급 검색
</Button>
// Ghost 버튼 (아이콘 전용)
<Button variant="ghost" size="icon" className="h-8 w-8">
<Key className="h-4 w-4" />
</Button>
```
**크기 표준:**
- `h-10`: 기본 버튼 (40px)
- `h-9`: 작은 버튼 (36px)
- `h-8`: 아이콘 버튼 (32px)
### 6. Input States (입력 필드 상태)
**표준 Input 스타일**
```tsx
// 기본
<Input className="h-10 text-sm" />
// 포커스 (자동 적용)
// focus:ring-2 focus:ring-ring
// 로딩/액티브
<Input className="h-10 text-sm border-primary ring-2 ring-primary/20" />
// 비활성화
<Input disabled className="cursor-not-allowed bg-muted text-muted-foreground" />
```
### 7. Form Structure (폼 구조)
**표준 필드 구조**
```tsx
<div className="space-y-2">
<label htmlFor="field-id" className="text-sm font-medium">
필드 라벨
</label>
<Input
id="field-id"
placeholder="힌트 텍스트"
className="h-10 text-sm"
/>
<p className="text-xs text-muted-foreground">
도움말 텍스트
</p>
</div>
```
### 8. Table Structure (테이블 구조)
**표준 테이블 스타일**
```tsx
<div className="rounded-lg border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold">
컬럼명
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 text-sm">
데이터
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
```
**높이 표준:**
- 헤더: `h-12` (48px)
- 데이터 행: `h-16` (64px)
### 9. Loading States (로딩 상태)
**Skeleton UI**
```tsx
<div className="h-4 animate-pulse rounded bg-muted"></div>
```
### 10. Empty States (빈 상태)
**표준 Empty State**
```tsx
<TableCell colSpan={columns} className="h-32 text-center">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<p className="text-sm">등록된 데이터가 없습니다.</p>
</div>
</TableCell>
```
### 11. Error States (에러 상태)
**표준 에러 메시지**
```tsx
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-destructive">오류가 발생했습니다</p>
<button
onClick={clearError}
className="text-destructive transition-colors hover:text-destructive/80"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="mt-1.5 text-sm text-destructive/80">{errorMessage}</p>
</div>
```
### 12. Responsive Design (반응형)
**모바일 우선 접근**
```tsx
// 레이아웃
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
// 그리드
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
// 텍스트
<h1 className="text-2xl sm:text-3xl font-bold">
// 간격
<div className="p-4 sm:p-6">
```
### 13. Accessibility (접근성)
**필수 적용 사항**
```tsx
// Label과 Input 연결
<label htmlFor="user-id" className="text-sm font-medium">
사용자 ID
</label>
<Input id="user-id" />
// 버튼에 aria-label
<Button aria-label="에러 메시지 닫기">
</Button>
// Switch에 aria-label
<Switch
checked={isActive}
onCheckedChange={handleChange}
aria-label="사용자 상태 토글"
/>
```
## 페이지 구조 템플릿
### Page Component
```tsx
export default function AdminPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
<p className="text-sm text-muted-foreground">페이지 설명</p>
</div>
{/* 메인 컨텐츠 */}
<MainComponent />
</div>
</div>
);
}
```
### Toolbar Component
```tsx
export function Toolbar() {
return (
<div className="space-y-4">
{/* 검색 영역 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 검색 입력 */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="검색..." className="h-10 pl-10 text-sm" />
</div>
</div>
{/* 버튼 */}
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
고급 검색
</Button>
</div>
</div>
{/* 액션 버튼 영역 */}
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{count.toLocaleString()}</span>
</div>
<Button className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
등록
</Button>
</div>
</div>
);
}
```
## 적용해야 할 다른 관리자 페이지
### 우선순위 1 (핵심 페이지)
- [ ] 메뉴 관리 (`/admin/menu`)
- [ ] 공통코드 관리 (`/admin/commonCode`)
- [ ] 회사 관리 (`/admin/company`)
- [ ] 테이블 관리 (`/admin/tableMng`)
### 우선순위 2 (자주 사용하는 페이지)
- [ ] 외부 연결 관리 (`/admin/external-connections`)
- [ ] 외부 호출 설정 (`/admin/external-call-configs`)
- [ ] 배치 관리 (`/admin/batch-management`)
- [ ] 레이아웃 관리 (`/admin/layouts`)
### 우선순위 3 (기타 관리 페이지)
- [ ] 템플릿 관리 (`/admin/templates`)
- [ ] 표준 관리 (`/admin/standards`)
- [ ] 다국어 관리 (`/admin/i18n`)
- [ ] 수집 관리 (`/admin/collection-management`)
## 체크리스트
각 페이지 작업 시 다음을 확인하세요:
### 레이아웃
- [ ] `bg-background` 사용 (하드코딩된 색상 없음)
- [ ] `container mx-auto space-y-6 p-6` 구조
- [ ] 페이지 헤더에 `border-b pb-4`
### 색상
- [ ] CSS Variables만 사용 (`bg-card`, `text-muted-foreground` 등)
- [ ] `bg-gray-*`, `text-gray-*` 등 하드코딩 제거
### 타이포그래피
- [ ] 페이지 제목: `text-3xl font-bold tracking-tight`
- [ ] 섹션 제목: `text-sm font-semibold`
- [ ] 본문: `text-sm`
- [ ] 보조 텍스트: `text-xs text-muted-foreground`
### 간격
- [ ] 페이지 레벨: `space-y-6`
- [ ] 섹션 레벨: `space-y-4`
- [ ] 필드 레벨: `space-y-2`
- [ ] 카드 패딩: `p-4` 또는 `p-6`
### 버튼
- [ ] 표준 variants 사용 (`default`, `outline`, `ghost`)
- [ ] 표준 크기: `h-10` (기본), `h-9` (작음), `h-8` (아이콘)
- [ ] 텍스트: `text-sm font-medium`
- [ ] 아이콘 + 텍스트: `gap-2`
### 입력 필드
- [ ] 높이: `h-10`
- [ ] 텍스트: `text-sm`
- [ ] Label과 Input `htmlFor`/`id` 연결
- [ ] `space-y-2` 구조
### 테이블
- [ ] `rounded-lg border bg-card shadow-sm`
- [ ] 헤더: `h-12 text-sm font-semibold bg-muted/50`
- [ ] 데이터 행: `h-16 text-sm`
- [ ] Hover: `hover:bg-muted/50`
### 반응형
- [ ] 모바일 우선 디자인
- [ ] `sm:`, `md:`, `lg:` 브레이크포인트 사용
- [ ] `flex-col sm:flex-row` 패턴
### 접근성
- [ ] Label `htmlFor` 속성
- [ ] Input `id` 속성
- [ ] 버튼 `aria-label`
- [ ] Switch `aria-label`
## 마이그레이션 절차
1. **페이지 컴포넌트 수정** (`page.tsx`)
- 레이아웃 구조 변경
- 색상 CSS Variables로 변경
- 페이지 헤더 표준화
2. **Toolbar 컴포넌트 수정**
- 검색 영역 스타일 통일
- 버튼 스타일 표준화
- 반응형 레이아웃 적용
3. **Table 컴포넌트 수정**
- 테이블 컨테이너 스타일 통일
- 헤더/데이터 행 높이 표준화
- 로딩/Empty State 표준화
4. **Form 컴포넌트 수정** (있는 경우)
- 필드 구조 표준화
- 라벨과 입력 필드 연결
- 에러 메시지 스타일 통일
5. **Modal 컴포넌트 수정** (있는 경우)
- Dialog 표준 패턴 적용
- 반응형 크기 (`max-w-[95vw] sm:max-w-[500px]`)
- 버튼 스타일 표준화
6. **린트 에러 확인**
```bash
# 수정한 파일들 확인
npm run lint
```
7. **테스트**
- 기능 동작 확인
- 반응형 확인 (모바일/태블릿/데스크톱)
- 다크모드 확인 (있는 경우)
## 참고 파일
### 완성된 예시
- `frontend/app/(main)/admin/userMng/page.tsx`
- `frontend/components/admin/UserToolbar.tsx`
- `frontend/components/admin/UserTable.tsx`
- `frontend/components/admin/UserManagement.tsx`
### 스타일 가이드
- `.cursorrules` - 전체 스타일 규칙
- Section 1-21: 각 스타일 요소별 상세 가이드

View File

@ -1,17 +1,8 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
Plus,
Search,
@ -26,6 +17,7 @@ import {
BatchMapping,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function BatchManagementPage() {
const router = useRouter();
@ -178,187 +170,198 @@ export default function BatchManagementPage() {
};
return (
<div className="container mx-auto p-4 space-y-2">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground"> .</p>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<Button
onClick={handleCreateBatch}
className="flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span> </span>
</Button>
</div>
{/* 검색 및 필터 */}
<Card>
<CardContent className="py-2">
<div className="flex items-center space-x-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button
variant="outline"
onClick={loadBatchConfigs}
disabled={loading}
className="flex items-center space-x-2"
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<span></span>
</Button>
</div>
</CardContent>
</Card>
{/* 배치 목록 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({batchConfigs.length})</span>
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
</CardTitle>
</CardHeader>
<CardContent>
{batchConfigs.length === 0 ? (
<div className="text-center py-12">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-muted-foreground mb-4">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{" "}
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div>
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 배치 목록 */}
{batchConfigs.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<Database className="h-12 w-12 text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
</div>
{!searchTerm && (
<Button
onClick={handleCreateBatch}
className="flex items-center space-x-2"
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
<span> </span>
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
{batchConfigs.map((batch) => (
<BatchCard
key={batch.id}
batch={batch}
executingBatch={executingBatch}
onExecute={executeBatch}
onToggleStatus={(batchId, currentStatus) => {
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus });
toggleBatchStatus(batchId, currentStatus);
}}
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
onDelete={deleteBatch}
getMappingSummary={getMappingSummary}
/>
))}
</div>
)}
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex justify-center space-x-2">
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{batchConfigs.map((batch) => (
<BatchCard
key={batch.id}
batch={batch}
executingBatch={executingBatch}
onExecute={executeBatch}
onToggleStatus={(batchId, currentStatus) => {
toggleBatchStatus(batchId, currentStatus);
}}
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
onDelete={deleteBatch}
getMappingSummary={getMappingSummary}
/>
))}
</div>
)}
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="w-full max-w-2xl mx-4">
<CardHeader>
<CardTitle className="text-center"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* DB → DB */}
<div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
onClick={() => handleBatchTypeSelect('db-to-db')}
>
<div className="flex items-center justify-center mb-4">
<Database className="w-8 h-8 text-blue-600 mr-2" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
<Database className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center">
<div className="font-medium text-lg mb-2">DB DB</div>
<div className="text-sm text-gray-500"> </div>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-10 text-sm font-medium"
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
onClick={() => setCurrentPage(pageNum)}
className="h-10 min-w-[40px] text-sm"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-10 text-sm font-medium"
>
</Button>
</div>
)}
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
<div className="space-y-6">
<h2 className="text-xl font-semibold text-center"> </h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */}
<button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('db-to-db')}
>
<div className="flex items-center gap-2">
<Database className="h-8 w-8 text-primary" />
<span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">DB DB</div>
<div className="text-sm text-muted-foreground"> </div>
</div>
</button>
{/* REST API → DB */}
<button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center gap-2">
<span className="text-2xl">🌐</span>
<span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">REST API DB</div>
<div className="text-sm text-muted-foreground">REST API에서 </div>
</div>
</button>
</div>
{/* REST API → DB */}
<div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center justify-center mb-4">
<Globe className="w-8 h-8 text-green-600 mr-2" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
<Database className="w-8 h-8 text-green-600" />
</div>
<div className="text-center">
<div className="font-medium text-lg mb-2">REST API DB</div>
<div className="text-sm text-gray-500">REST API에서 </div>
</div>
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
className="h-10 text-sm font-medium"
>
</Button>
</div>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
>
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,59 +1,49 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function CommonCodeManagementPage() {
// const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-6">
{/* 좌측: 카테고리 패널 */}
<div className="w-full lg:w-80 lg:border-r lg:pr-6">
<div className="space-y-4">
<h2 className="text-lg font-semibold"> </h2>
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
</div>
</div>
{/* 우측: 코드 상세 패널 */}
<div className="min-w-0 flex-1 lg:pl-0">
<div className="space-y-4">
<h2 className="text-lg font-semibold">
{selectedCategoryCode && (
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
)}
</h2>
<CodeDetailPanel categoryCode={selectedCategoryCode} />
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
{/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
<div className="w-full lg:w-80 lg:flex-shrink-0">
<Card className="h-full shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">📂 </CardTitle>
</CardHeader>
<CardContent className="p-0">
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
</CardContent>
</Card>
</div>
{/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */}
<div className="min-w-0 flex-1">
<Card className="h-fit shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
📋
{selectedCategoryCode && (
<span className="text-muted-foreground text-sm font-normal">({selectedCategoryCode})</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<CodeDetailPanel categoryCode={selectedCategoryCode} />
</CardContent>
</Card>
</div>
</div>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,21 +1,25 @@
import { CompanyManagement } from "@/components/admin/CompanyManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
*/
export default function CompanyPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<CompanyManagement />
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -5,10 +5,14 @@ import { useRouter } from "next/navigation";
import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
@ -21,7 +25,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreHorizontal } from "lucide-react";
import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react";
/**
*
@ -161,123 +165,108 @@ export default function DashboardListPage() {
});
};
if (loading) {
return (
<div className="bg-card flex h-full items-center justify-center rounded-lg border shadow-sm">
<div className="text-center">
<div className="text-sm font-medium"> ...</div>
<div className="text-muted-foreground mt-2 text-xs"> </div>
</div>
</div>
);
}
return (
<div className="min-h-[calc(100vh-4rem)] bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 검색 및 필터 */}
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 pl-10"
/>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 검색 및 액션 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 대시보드 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-gray-500"> ...</div>
{dashboards.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : dashboards.length === 0 ? (
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="py-8 text-center text-gray-500">
<LayoutDashboard className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-sm text-gray-400"> .</p>
<Button onClick={() => router.push("/admin/dashboard/new")}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<CardContent className="p-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.updatedAt)}
</TableCell>
<TableCell className="h-16 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="text-destructive focus:text-destructive gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-gray-50">
<TableCell>
<div className="font-medium">{dashboard.title}</div>
</TableCell>
<TableCell className="max-w-md truncate text-sm text-gray-500">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-sm">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="text-right">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="h-8 w-full justify-start gap-2 px-2 text-xs"
>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(dashboard)}
className="h-8 w-full justify-start gap-2 px-2 text-xs"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="h-8 w-full justify-start gap-2 px-2 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</PopoverContent>
</Popover>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</TableBody>
</Table>
</div>
)}
{/* 페이지네이션 */}
@ -294,20 +283,19 @@ export default function DashboardListPage() {
{/* 삭제 확인 모달 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
&quot;{deleteTarget?.title}&quot; ?
<br />
<span className="font-medium text-red-600"> .</span>
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>

View File

@ -7,6 +7,7 @@ import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ArrowLeft } from "lucide-react";
type Step = "list" | "editor";
@ -50,17 +51,17 @@ export default function DataFlowPage() {
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
return (
<div className="fixed inset-0 z-50 bg-white">
<div className="fixed inset-0 z-50 bg-background">
<div className="flex h-full flex-col">
{/* 에디터 헤더 */}
<div className="flex items-center gap-4 border-b bg-white p-4">
<div className="flex items-center gap-4 border-b bg-background p-4">
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-600">
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
@ -76,19 +77,20 @@ export default function DataFlowPage() {
}
return (
<div className="min-h-screen bg-gray-50">
<div className="mx-auto space-y-4 px-5 py-4">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-4 sm:p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 플로우 목록 */}
<DataFlowList onLoadFlow={handleLoadFlow} />
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -161,205 +161,201 @@ export default function ExternalCallConfigsPage() {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground mt-1">Discord, Slack, .</p>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">Discord, Slack, .</p>
</div>
<Button onClick={handleAddConfig} className="flex items-center gap-2">
<Plus size={16} />
</Button>
</div>
{/* 검색 및 필터 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter size={18} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 */}
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="설정 이름 또는 설명으로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
{/* 검색 및 필터 영역 */}
<div className="space-y-4">
{/* 첫 번째 줄: 검색 + 추가 버튼 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-full sm:w-[320px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="설정 이름 또는 설명으로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleSearchKeyPress}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Search className="h-4 w-4" />
</Button>
</div>
<Button onClick={handleSearch} variant="outline">
<Search size={16} />
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 필터 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium"> </label>
<Select
value={filter.call_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
call_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 두 번째 줄: 필터 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Select
value={filter.call_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
call_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="호출 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<label className="mb-1 block text-sm font-medium">API </label>
<Select
value={filter.api_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
api_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select
value={filter.api_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
api_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="API 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Select
value={filter.is_active || "Y"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
is_active: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select
value={filter.is_active || "Y"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
is_active: value,
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
{/* 설정 목록 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{/* 설정 목록 */}
<div className="rounded-lg border bg-card shadow-sm">
{loading ? (
// 로딩 상태
<div className="py-8 text-center">
<div className="text-muted-foreground"> ...</div>
<div className="flex h-64 items-center justify-center">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
) : configs.length === 0 ? (
// 빈 상태
<div className="py-12 text-center">
<div className="text-muted-foreground">
<Plus size={48} className="mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium"> .</p>
<p className="text-sm"> .</p>
<div className="flex h-64 flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> .</p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
</div>
) : (
// 설정 테이블 목록
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead>API </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold">API </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configs.map((config) => (
<TableRow key={config.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{config.config_name}</TableCell>
<TableCell>
<TableRow key={config.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
</TableCell>
<TableCell>
<TableCell className="h-16 text-sm">
{config.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
) : (
<span className="text-muted-foreground text-sm">-</span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<TableCell className="h-16 text-sm">
<div className="max-w-xs">
{config.description ? (
<span className="text-muted-foreground block truncate text-sm" title={config.description}>
<span className="block truncate text-muted-foreground" title={config.description}>
{config.description}
</span>
) : (
<span className="text-muted-foreground text-sm">-</span>
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell>
<TableCell className="h-16 text-sm">
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
{config.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className="h-16 text-sm text-muted-foreground">
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<TableCell className="h-16 text-sm">
<div className="flex justify-center gap-1">
<Button size="sm" variant="outline" onClick={() => handleTestConfig(config)} title="테스트">
<TestTube size={14} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleTestConfig(config)}
title="테스트"
>
<TestTube className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleEditConfig(config)} title="편집">
<Edit size={14} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditConfig(config)}
title="편집"
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDeleteConfig(config)}
className="text-destructive hover:text-destructive"
title="삭제"
>
<Trash2 size={14} />
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
@ -368,8 +364,7 @@ export default function ExternalCallConfigsPage() {
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* 외부 호출 설정 모달 */}
<ExternalCallConfigModal
@ -381,17 +376,22 @@ export default function ExternalCallConfigsPage() {
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{configToDelete?.config_name}" ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteConfig} className="bg-destructive hover:bg-destructive/90">
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConfig}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>

View File

@ -227,14 +227,12 @@ export default function ExternalConnectionsPage() {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> REST API </p>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p>
</div>
{/* 탭 */}
@ -253,166 +251,152 @@ export default function ExternalConnectionsPage() {
{/* 데이터베이스 연결 탭 */}
<TabsContent value="database" className="space-y-6">
{/* 검색 및 필터 */}
<Card className="mb-6 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 pl-10"
/>
</div>
{/* DB 타입 필터 */}
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="DB 타입" />
</SelectTrigger>
<SelectContent>
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="w-32">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
</Button>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 검색 */}
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</CardContent>
</Card>
{/* DB 타입 필터 */}
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]">
<SelectValue placeholder="DB 타입" />
</SelectTrigger>
<SelectContent>
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 연결 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-gray-500"> ...</div>
<div className="flex h-64 items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
) : connections.length === 0 ? (
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="py-8 text-center text-gray-500">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-sm text-gray-400"> .</p>
<Button onClick={handleAddConnection}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
) : (
<Card className="shadow-sm">
<CardContent className="p-0">
<Table>
<div className="rounded-lg border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[120px]">DB </TableHead>
<TableHead className="w-[200px]">호스트:포트</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold">DB </TableHead>
<TableHead className="h-12 text-sm font-semibold">호스트:포트</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((connection) => (
<TableRow key={connection.id} className="hover:bg-gray-50">
<TableCell>
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 text-sm">
<div className="font-medium">{connection.connection_name}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
<TableCell className="h-16 text-sm">
<Badge variant="outline">
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
<TableCell className="h-16 font-mono text-sm">
{connection.host}:{connection.port}
</TableCell>
<TableCell className="font-mono text-sm">{connection.database_name}</TableCell>
<TableCell className="font-mono text-sm">{connection.username}</TableCell>
<TableCell>
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-xs">
<TableCell className="h-16 font-mono text-sm">{connection.database_name}</TableCell>
<TableCell className="h-16 font-mono text-sm">{connection.username}</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-sm">
<TableCell className="h-16 text-sm">
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
</TableCell>
<TableCell>
<TableCell className="h-16 text-sm">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)}
className="h-7 px-2 text-xs"
className="h-9 text-sm"
>
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(connection.id!) && (
<Badge
variant={testResults.get(connection.id!) ? "default" : "destructive"}
className="text-xs text-white"
>
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
{testResults.get(connection.id!) ? "성공" : "실패"}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<TableCell className="h-16 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => {
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
setSelectedConnection(connection);
setSqlModalOpen(true);
}}
className="h-8 w-8 p-0"
className="h-8 w-8"
title="SQL 쿼리 실행"
>
<Terminal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => handleEditConnection(connection)}
className="h-8 w-8 p-0"
className="h-8 w-8"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => handleDeleteConnection(connection)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
@ -422,8 +406,7 @@ export default function ExternalConnectionsPage() {
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)}
{/* 연결 설정 모달 */}
@ -439,20 +422,25 @@ export default function ExternalConnectionsPage() {
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{connectionToDelete?.connection_name}" ?
<br />
<span className="font-medium text-red-600"> .</span>
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelDeleteConnection}></AlertDialogCancel>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
onClick={cancelDeleteConnection}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConnection}
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>

View File

@ -9,9 +9,8 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
@ -32,6 +31,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function FlowManagementPage() {
const router = useRouter();
@ -45,11 +45,15 @@ export default function FlowManagementPage() {
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
// 테이블 목록 관련 상태
const [tableList, setTableList] = useState<any[]>([]); // 내부 DB 테이블
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string; description?: string }>>(
[],
);
const [loadingTables, setLoadingTables] = useState(false);
const [openTableCombobox, setOpenTableCombobox] = useState(false);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
const [externalConnections, setExternalConnections] = useState<any[]>([]);
const [externalConnections, setExternalConnections] = useState<
Array<{ id: number; connection_name: string; db_type: string }>
>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
@ -74,10 +78,10 @@ export default function FlowManagementPage() {
variant: "destructive",
});
}
} catch (error: any) {
} catch (error) {
toast({
title: "오류 발생",
description: error.message,
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
@ -87,6 +91,7 @@ export default function FlowManagementPage() {
useEffect(() => {
loadFlows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 테이블 목록 로드 (내부 DB)
@ -128,7 +133,8 @@ export default function FlowManagementPage() {
if (data.success && data.data) {
// 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
const filtered = data.data.filter(
(conn: any) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
(conn: { connection_name: string }) =>
!conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
);
setExternalConnections(filtered);
}
@ -164,7 +170,9 @@ export default function FlowManagementPage() {
if (data.success && data.data) {
const tables = Array.isArray(data.data) ? data.data : [];
const tableNames = tables
.map((t: any) => (typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name))
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
)
.filter(Boolean);
setExternalTableList(tableNames);
} else {
@ -224,10 +232,10 @@ export default function FlowManagementPage() {
variant: "destructive",
});
}
} catch (error: any) {
} catch (error) {
toast({
title: "오류 발생",
description: error.message,
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
variant: "destructive",
});
}
@ -254,10 +262,10 @@ export default function FlowManagementPage() {
variant: "destructive",
});
}
} catch (error: any) {
} catch (error) {
toast({
title: "오류 발생",
description: error.message,
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
variant: "destructive",
});
}
@ -269,317 +277,342 @@ export default function FlowManagementPage() {
};
return (
<div className="container mx-auto space-y-4 p-3 sm:space-y-6 sm:p-4 lg:p-6">
{/* 헤더 */}
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex-1">
<h1 className="flex items-center gap-2 text-xl font-bold sm:text-2xl lg:text-3xl">
<Workflow className="h-6 w-6 sm:h-7 sm:w-7 lg:h-8 lg:w-8" />
</h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"> </p>
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-4 sm:p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</Button>
</div>
{/* 플로우 카드 목록 */}
{loading ? (
<div className="py-8 text-center sm:py-12">
<p className="text-muted-foreground text-sm sm:text-base"> ...</p>
{/* 액션 버튼 영역 */}
<div className="flex items-center justify-end">
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
) : flows.length === 0 ? (
<Card>
<CardContent className="py-8 text-center sm:py-12">
<Workflow className="text-muted-foreground mx-auto mb-3 h-10 w-10 sm:mb-4 sm:h-12 sm:w-12" />
<p className="text-muted-foreground mb-3 text-sm sm:mb-4 sm:text-base"> </p>
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 lg:gap-6 xl:grid-cols-3">
{flows.map((flow) => (
<Card
key={flow.id}
className="cursor-pointer transition-shadow hover:shadow-lg"
onClick={() => handleEdit(flow.id)}
>
<CardHeader className="p-4 sm:p-6">
<div className="flex items-start justify-between">
{/* 플로우 카드 목록 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
<div className="mb-4 space-y-2">
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
</div>
))}
</div>
<div className="mt-4 flex gap-2 border-t pt-4">
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
</div>
</div>
))}
</div>
) : flows.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
<Workflow className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground max-w-sm text-sm">
.
</p>
<Button onClick={() => setIsCreateDialogOpen(true)} className="mt-4 h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{flows.map((flow) => (
<div
key={flow.id}
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors"
onClick={() => handleEdit(flow.id)}
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="flex flex-col gap-1 text-base sm:flex-row sm:items-center sm:gap-2 sm:text-lg">
<span className="truncate">{flow.name}</span>
<div className="flex items-center gap-2">
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
{flow.isActive && (
<Badge variant="success" className="self-start">
</Badge>
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600"></Badge>
)}
</CardTitle>
<CardDescription className="mt-1 line-clamp-2 text-xs sm:mt-2 sm:text-sm">
{flow.description || "설명 없음"}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 sm:p-6">
<div className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<Table className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">{flow.tableName}</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<User className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">: {flow.createdBy}</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<Calendar className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
</div>
</div>
<div className="mt-3 flex gap-2 sm:mt-4">
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex items-center gap-2 text-sm">
<Table className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="text-muted-foreground truncate">{flow.tableName}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<User className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="text-muted-foreground truncate">: {flow.createdBy}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="text-muted-foreground">
{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleEdit(flow.id);
}}
>
<Edit2 className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs sm:h-9 sm:px-3 sm:text-sm"
className="h-9 w-9 p-0"
onClick={(e) => {
e.stopPropagation();
setSelectedFlow(flow);
setIsDeleteDialogOpen(true);
}}
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 생성 다이얼로그 */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 제품 수명주기 관리"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* DB 소스 선택 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn: any) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 테이블 선택 */}
<div>
<Label htmlFor="tableName" className="text-xs sm:text-sm">
*
</Label>
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombobox}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
>
{formData.tableName
? selectedDbSource === "internal"
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
formData.tableName
: formData.tableName
: loadingTables || loadingExternalTables
? "로딩 중..."
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{selectedDbSource === "internal"
? // 내부 DB 테이블 목록
tableList.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
console.log("📝 Internal table selected:", {
tableName: table.tableName,
currentValue,
});
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.description && (
<span className="text-[10px] text-gray-500">{table.description}</span>
)}
</div>
</CommandItem>
))
: // 외부 DB 테이블 목록
externalTableList.map((tableName, index) => (
<CommandItem
key={`external-${selectedDbSource}-${tableName}-${index}`}
value={tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
<div>{tableName}</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
( )
</p>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="플로우에 대한 설명을 입력하세요"
rows={3}
className="text-xs sm:text-sm"
/>
</div>
</div>
))}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleCreate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 생성 다이얼로그 */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{/* 삭제 확인 다이얼로그 */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{selectedFlow?.name}" ?
<br /> .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 제품 수명주기 관리"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedFlow(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* DB 소스 선택 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 테이블 선택 */}
<div>
<Label htmlFor="tableName" className="text-xs sm:text-sm">
*
</Label>
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombobox}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
>
{formData.tableName
? selectedDbSource === "internal"
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
formData.tableName
: formData.tableName
: loadingTables || loadingExternalTables
? "로딩 중..."
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{selectedDbSource === "internal"
? // 내부 DB 테이블 목록
tableList.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
console.log("📝 Internal table selected:", {
tableName: table.tableName,
currentValue,
});
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.description && (
<span className="text-[10px] text-gray-500">{table.description}</span>
)}
</div>
</CommandItem>
))
: // 외부 DB 테이블 목록
externalTableList.map((tableName, index) => (
<CommandItem
key={`external-${selectedDbSource}-${tableName}-${index}`}
value={tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
<div>{tableName}</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
( )
</p>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="플로우에 대한 설명을 입력하세요"
rows={3}
className="text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleCreate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
&ldquo;{selectedFlow?.name}&rdquo; ?
<br /> .
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedFlow(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,20 +1,24 @@
"use client";
import { MenuManagement } from "@/components/admin/MenuManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function MenuPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<MenuManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -2,11 +2,11 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
// 단계별 진행을 위한 타입 정의
@ -25,17 +25,14 @@ export default function ScreenManagementPage() {
list: {
title: "화면 목록 관리",
description: "생성된 화면들을 확인하고 관리하세요",
icon: "📋",
},
design: {
title: "화면 설계",
description: "드래그앤드롭으로 화면을 설계하세요",
icon: "🎨",
},
template: {
title: "템플릿 관리",
description: "화면 템플릿을 관리하고 재사용하세요",
icon: "📝",
},
};
@ -65,62 +62,56 @@ export default function ScreenManagementPage() {
}
};
// 현재 단계가 마지막 단계인지 확인
const isLastStep = currentStep === "template";
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
if (isDesignMode) {
return (
<div className="fixed inset-0 z-50 bg-white">
<div className="fixed inset-0 z-50 bg-background">
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-6 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> 릿 </p>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> 릿 </p>
</div>
{/* 단계별 내용 */}
<div className="flex-1 overflow-hidden">
<div className="flex-1">
{/* 화면 목록 단계 */}
{currentStep === "list" && (
<div className="space-y-8">
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
<Button variant="default" className="shadow-sm" onClick={() => goToNextStep("design")}>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<ScreenList
onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
onDesignScreen={(screen) => {
setSelectedScreen(screen);
goToNextStep("design");
}}
/>
</div>
<ScreenList
onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
onDesignScreen={(screen) => {
setSelectedScreen(screen);
goToNextStep("design");
}}
/>
)}
{/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="space-y-8">
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.template.title}</h2>
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button variant="outline" className="shadow-sm" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
<Button
variant="outline"
onClick={goToPreviousStep}
className="h-10 gap-2 text-sm font-medium"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button variant="default" className="shadow-sm" onClick={() => goToStep("list")}>
<Button
onClick={() => goToStep("list")}
className="h-10 gap-2 text-sm font-medium"
>
</Button>
</div>
@ -130,6 +121,9 @@ export default function ScreenManagementPage() {
)}
</div>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,13 +1,11 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react";
import { Search, Database, RefreshCw, Settings, Plus, Activity } from "lucide-react";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang";
@ -21,7 +19,7 @@ import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
import { TableLogViewer } from "@/components/admin/TableLogViewer";
// 가상화 스크롤링을 위한 간단한 구현
import { ScrollToTop } from "@/components/common/ScrollToTop";
interface TableInfo {
tableName: string;
@ -546,457 +544,468 @@ export default function TableManagementPage() {
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
return (
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<p className="mt-2 text-gray-600">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
</p>
{isSuperAdmin && (
<p className="mt-1 text-sm font-medium text-blue-600">
🔧
</p>
)}
</div>
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{getTextFromUI(
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
)}
</p>
{isSuperAdmin && (
<p className="text-primary mt-1 text-sm font-medium">
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* DDL 기능 버튼들 (최고 관리자만) */}
{isSuperAdmin && (
<>
<Button
onClick={() => setCreateTableModalOpen(true)}
className="bg-green-600 text-white hover:bg-green-700"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* DDL 기능 버튼들 (최고 관리자만) */}
{isSuperAdmin && (
<>
<Button
onClick={() => setCreateTableModalOpen(true)}
className="h-10 gap-2 text-sm font-medium"
size="default"
>
<Plus className="h-4 w-4" />
</Button>
{selectedTable && (
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
{selectedTable && (
<Button
onClick={() => setAddColumnModalOpen(true)}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
)}
<Button
onClick={() => setDdlLogViewerOpen(true)}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<Activity className="h-4 w-4" />
DDL
</Button>
</>
)}
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" size="sm">
<Activity className="mr-2 h-4 w-4" />
DDL
<Button
onClick={loadTables}
disabled={loading}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button>
</>
)}
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 테이블 목록 */}
<Card className="shadow-sm lg:col-span-1">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-gray-600" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
</CardTitle>
</CardHeader>
<CardContent>
{/* 검색 */}
<div className="mb-4">
<div className="flex h-full gap-6">
{/* 좌측 사이드바: 테이블 목록 (20%) */}
<div className="w-[20%] border-r pr-6">
<div className="space-y-4">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Database className="text-muted-foreground h-5 w-5" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
</h2>
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
className="h-10 pl-10 text-sm"
/>
</div>
</div>
{/* 테이블 목록 */}
<div className="max-h-96 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2 text-sm text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span>
</div>
) : tables.length === 0 ? (
<div className="py-8 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div>
) : (
tables
.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
)
.map((table) => (
<div
key={table.tableName}
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
selectedTable === table.tableName
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => handleTableSelect(table.tableName)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
<p className="text-sm text-gray-500">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
{/* 테이블 목록 */}
<div className="space-y-3">
{loading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span>
</div>
) : tables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div>
) : (
tables
.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
)
.map((table) => (
<div
key={table.tableName}
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md"
}`}
onClick={() => handleTableSelect(table.tableName)}
>
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
<p className="text-muted-foreground mt-1 text-xs">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
<div className="mt-2 flex items-center justify-between border-t pt-2">
<span className="text-muted-foreground text-xs"></span>
<Badge variant="secondary" className="text-xs">
{table.columnCount}
</Badge>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
<div className="w-[80%] pl-0">
<div className="flex h-full flex-col space-y-4">
<h2 className="flex items-center gap-2 text-xl font-semibold">
<Settings className="text-muted-foreground h-5 w-5" />
{selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"}
</h2>
<div className="flex-1 overflow-hidden">
{!selectedTable ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p>
</div>
</div>
) : (
<>
{/* 테이블 라벨 설정 */}
<div className="mb-4 flex items-center gap-4">
<div className="flex-1">
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명"
className="h-10 text-sm"
/>
</div>
<div className="flex-1">
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명"
className="h-10 text-sm"
/>
</div>
</div>
{columnsLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span>
</div>
) : columns.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<div className="space-y-4">
{/* 컬럼 헤더 */}
<div className="text-foreground flex items-center border-b pb-2 text-sm font-semibold">
<div className="w-40 px-4"></div>
<div className="w-48 px-4"></div>
<div className="w-48 px-4"> </div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
</div>
<div className="w-80 px-4"></div>
</div>
{/* 컬럼 리스트 */}
<div
className="max-h-96 overflow-y-auto rounded-lg border"
onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
if (scrollHeight - scrollTop <= clientHeight + 100) {
loadMoreColumns();
}
}}
>
{columns.map((column, index) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex items-center border-b py-2 transition-colors"
>
<div className="w-40 px-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="w-48 px-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder={column.columnName}
className="h-8 text-xs"
/>
</div>
<div className="w-48 px-4">
<Select
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<Select
value={column.codeCategory || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "code", value)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="공통코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-1">
{/* Entity 타입 설정 - 가로 배치 */}
<div className="border-primary/20 bg-primary/5 rounded-lg border p-2">
<div className="mb-2 flex items-center gap-2">
<span className="text-primary text-xs font-medium">Entity </span>
</div>
<div className="grid grid-cols-3 gap-2">
{/* 참조 테이블 */}
<div>
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<Select
value={column.referenceTable || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="bg-background h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{referenceTableOptions.map((option, index) => (
<SelectItem
key={`entity-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-muted-foreground text-xs">
{option.value}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div>
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<Select
value={column.referenceColumn || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
value,
)
}
>
<SelectTrigger className="bg-background h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">-- --</SelectItem>
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
<SelectItem
key={`ref-col-${refCol.columnName}-${index}`}
value={refCol.columnName}
>
<span className="font-medium">{refCol.columnName}</span>
</SelectItem>
))}
{(!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
</div>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
</div>
{/* 설정 완료 표시 - 간소화 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-1 text-xs">
<span></span>
<span className="truncate">
{column.columnName} {column.referenceTable}.{column.displayColumn}
</span>
</div>
)}
</div>
</div>
)}
{/* 다른 웹 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "entity" && (
<div className="text-muted-foreground flex h-8 items-center text-xs">-</div>
)}
</div>
<div className="w-80 px-4">
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="h-8 text-xs"
/>
</div>
</div>
))}
</div>
{/* 로딩 표시 */}
{columnsLoading && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
)}
{/* 페이지 정보 */}
<div className="text-muted-foreground text-center text-sm">
{columns.length} / {totalColumns}
</div>
{/* 전체 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setLogViewerTableName(table.tableName);
setLogViewerOpen(true);
}}
title="변경 이력 조회"
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0}
className="h-10 gap-2 text-sm font-medium"
>
<Activity className="h-4 w-4" />
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* 컬럼 타입 관리 */}
<Card className="shadow-sm lg:col-span-4">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-600" />
{selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"}
</CardTitle>
</CardHeader>
<CardContent>
{!selectedTable ? (
<div className="py-12 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</div>
) : (
<>
{/* 테이블 라벨 설정 */}
<div className="mb-6 space-y-4 rounded-lg border border-gray-200 p-4">
<h3 className="text-lg font-medium text-gray-900"> </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700"> ( )</label>
<Input value={selectedTable} disabled className="bg-gray-50" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700"></label>
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명을 입력하세요"
/>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700"></label>
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명을 입력하세요"
/>
</div>
</div>
</div>
{columnsLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2 text-sm text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span>
</div>
) : columns.length === 0 ? (
<div className="py-8 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<div className="space-y-4">
{/* 컬럼 헤더 */}
<div className="flex items-center border-b border-gray-200 pb-2 text-sm font-medium text-gray-700">
<div className="w-40 px-4"></div>
<div className="w-48 px-4"></div>
<div className="w-48 px-4"> </div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
</div>
<div className="w-80 px-4"></div>
</div>
{/* 컬럼 리스트 */}
<div
className="max-h-96 overflow-y-auto rounded-lg border border-gray-200"
onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
if (scrollHeight - scrollTop <= clientHeight + 100) {
loadMoreColumns();
}
}}
>
{columns.map((column, index) => (
<div
key={column.columnName}
className="flex items-center border-b border-gray-200 py-2 hover:bg-gray-50"
>
<div className="w-40 px-4">
<div className="font-mono text-sm text-gray-700">{column.columnName}</div>
</div>
<div className="w-48 px-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder={column.columnName}
className="h-7 text-xs"
/>
</div>
<div className="w-48 px-4">
<Select
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<Select
value={column.codeCategory || "none"}
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="공통코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-1">
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-blue-800">Entity </span>
</div>
<div className="grid grid-cols-3 gap-2">
{/* 참조 테이블 */}
<div>
<label className="mb-1 block text-xs text-gray-600"> </label>
<Select
value={column.referenceTable || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="h-7 bg-white text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{referenceTableOptions.map((option, index) => (
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-xs text-gray-500">{option.value}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div>
<label className="mb-1 block text-xs text-gray-600"> </label>
<Select
value={column.referenceColumn || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
value,
)
}
>
<SelectTrigger className="h-7 bg-white text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">-- --</SelectItem>
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
<SelectItem
key={`ref-col-${refCol.columnName}-${index}`}
value={refCol.columnName}
>
<span className="font-medium">{refCol.columnName}</span>
</SelectItem>
))}
{(!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<div className="h-3 w-3 animate-spin rounded-full border border-blue-500 border-t-transparent"></div>
</div>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
</div>
{/* 설정 완료 표시 - 간소화 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="mt-1 flex items-center gap-1 rounded bg-green-100 px-2 py-1 text-xs text-green-700">
<span className="text-green-600"></span>
<span className="truncate">
{column.columnName} {column.referenceTable}.{column.displayColumn}
</span>
</div>
)}
</div>
</div>
)}
{/* 다른 웹 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "entity" && (
<div className="flex h-7 items-center text-xs text-gray-400">-</div>
)}
</div>
<div className="w-80 px-4">
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="h-7 text-xs"
/>
</div>
</div>
))}
</div>
{/* 로딩 표시 */}
{columnsLoading && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner />
<span className="ml-2 text-sm text-gray-500"> ...</span>
</div>
)}
{/* 페이지 정보 */}
<div className="text-center text-sm text-gray-500">
{columns.length} / {totalColumns}
</div>
{/* 전체 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0}
className="flex items-center gap-2"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && (
<>
<CreateTableModal
isOpen={createTableModalOpen}
onClose={() => setCreateTableModalOpen(false)}
onSuccess={async (result) => {
toast.success("테이블이 성공적으로 생성되었습니다!");
// 테이블 목록 새로고침
await loadTables();
// 새로 생성된 테이블 자동 선택 및 컬럼 로드
if (result.data?.tableName) {
setSelectedTable(result.data.tableName);
setCurrentPage(1);
setColumns([]);
await loadColumnTypes(result.data.tableName, 1, pageSize);
}
}}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)
await loadTables();
// 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋
if (selectedTable) {
setCurrentPage(1);
setColumns([]); // 기존 컬럼 목록 초기화
await loadColumnTypes(selectedTable, 1, pageSize);
}
}}
/>
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
{/* 테이블 로그 뷰어 */}
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
</>
)}
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
{/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && (
<>
<CreateTableModal
isOpen={createTableModalOpen}
onClose={() => setCreateTableModalOpen(false)}
onSuccess={async (result) => {
toast.success("테이블이 성공적으로 생성되었습니다!");
// 테이블 목록 새로고침
await loadTables();
// 새로 생성된 테이블 자동 선택 및 컬럼 로드
if (result.data?.tableName) {
setSelectedTable(result.data.tableName);
setCurrentPage(1);
setColumns([]);
await loadColumnTypes(result.data.tableName, 1, pageSize);
}
}}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)
await loadTables();
// 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋
if (selectedTable) {
setCurrentPage(1);
setColumns([]); // 기존 컬럼 목록 초기화
await loadColumnTypes(selectedTable, 1, pageSize);
}
}}
/>
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
{/* 테이블 로그 뷰어 */}
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
</>
)}
</div>
);
}

View File

@ -1,24 +1,30 @@
"use client";
import { UserManagement } from "@/components/admin/UserManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/userMng
*
* shadcn/ui
*/
export default function UserMngPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<UserManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -12,8 +12,6 @@ import {
RefreshCw,
Clock,
Database,
ArrowRight,
Globe,
Calendar,
Activity,
Settings
@ -39,90 +37,97 @@ export default function BatchCard({
onDelete,
getMappingSummary
}: BatchCardProps) {
// 상태에 따른 색상 및 스타일 결정
const getStatusColor = () => {
if (executingBatch === batch.id) return "bg-blue-50 border-blue-200";
if (batch.is_active === 'Y') return "bg-green-50 border-green-200";
return "bg-gray-50 border-gray-200";
};
const getStatusBadge = () => {
if (executingBatch === batch.id) {
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs px-1.5 py-0.5 h-5"> </Badge>;
}
return (
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'} className="text-xs px-1.5 py-0.5 h-5">
{batch.is_active === 'Y' ? '활성' : '비활성'}
</Badge>
);
};
// 상태에 따른 스타일 결정
const isExecuting = executingBatch === batch.id;
const isActive = batch.is_active === 'Y';
return (
<Card className={`transition-all duration-200 hover:shadow-md ${getStatusColor()} h-fit`}>
<CardContent className="p-3">
{/* 헤더 섹션 */}
<div className="mb-1.5">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-1 min-w-0 flex-1">
<Settings className="h-2.5 w-2.5 text-gray-600 flex-shrink-0" />
<h3 className="text-xs font-medium text-gray-900 truncate">{batch.batch_name}</h3>
<Card className="rounded-lg border bg-card shadow-sm transition-colors hover:bg-muted/50">
<CardContent className="p-4">
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Settings className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h3 className="text-base font-semibold truncate">{batch.batch_name}</h3>
</div>
{getStatusBadge()}
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{batch.description || '설명 없음'}
</p>
</div>
<p className="text-xs text-gray-500 line-clamp-1 leading-tight h-3 flex items-start">
{batch.description || '\u00A0'}
</p>
<Badge variant={isActive ? 'default' : 'secondary'} className="ml-2 flex-shrink-0">
{isExecuting ? '실행 중' : isActive ? '활성' : '비활성'}
</Badge>
</div>
{/* 정보 섹션 */}
<div className="space-y-1 mb-2">
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
{/* 스케줄 정보 */}
<div className="flex items-center space-x-1 text-xs">
<Clock className="h-2.5 w-2.5 text-blue-600" />
<span className="text-gray-600 truncate text-xs">{batch.cron_schedule}</span>
<div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" />
</span>
<span className="font-medium truncate ml-2">{batch.cron_schedule}</span>
</div>
{/* 생성일 정보 */}
<div className="flex items-center space-x-1 text-xs">
<Calendar className="h-2.5 w-2.5 text-green-600" />
<span className="text-gray-600 text-xs">
<div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
</span>
<span className="font-medium">
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
</span>
</div>
</div>
{/* 매핑 정보 섹션 */}
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
<div className="mb-2 p-1.5 bg-white rounded border border-gray-100">
<div className="flex items-center space-x-1 mb-1">
<Database className="h-2.5 w-2.5 text-purple-600" />
<span className="text-xs font-medium text-gray-700">
({batch.batch_mappings.length})
{/* 매핑 정보 */}
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
<div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Database className="h-4 w-4" />
</span>
<span className="font-medium">
{batch.batch_mappings.length}
</span>
</div>
<div className="text-xs text-gray-600 line-clamp-1">
{getMappingSummary(batch.batch_mappings)}
)}
</div>
{/* 실행 중 프로그레스 */}
{isExecuting && (
<div className="mt-4 space-y-2 border-t pt-4">
<div className="flex items-center gap-2 text-sm text-primary">
<Activity className="h-4 w-4 animate-pulse" />
<span> ...</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full animate-pulse rounded-full bg-primary"
style={{ width: '45%' }}
/>
</div>
</div>
)}
{/* 액션 버튼 섹션 */}
<div className="grid grid-cols-2 gap-1 pt-2 border-t border-gray-100">
{/* 액션 버튼 */}
<div className="mt-4 grid grid-cols-2 gap-2 border-t pt-4">
{/* 실행 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onExecute(batch.id)}
disabled={executingBatch === batch.id}
className="flex items-center justify-center space-x-1 bg-blue-50 hover:bg-blue-100 text-blue-700 border-blue-200 text-xs h-6"
disabled={isExecuting}
className="h-9 flex-1 gap-2 text-sm"
>
{executingBatch === batch.id ? (
<RefreshCw className="h-2.5 w-2.5 animate-spin" />
{isExecuting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Play className="h-2.5 w-2.5" />
<Play className="h-4 w-4" />
)}
<span></span>
</Button>
{/* 활성화/비활성화 버튼 */}
@ -130,18 +135,14 @@ export default function BatchCard({
variant="outline"
size="sm"
onClick={() => onToggleStatus(batch.id, batch.is_active)}
className={`flex items-center justify-center space-x-1 text-xs h-6 ${
batch.is_active === 'Y'
? 'bg-orange-50 hover:bg-orange-100 text-orange-700 border-orange-200'
: 'bg-green-50 hover:bg-green-100 text-green-700 border-green-200'
}`}
className="h-9 flex-1 gap-2 text-sm"
>
{batch.is_active === 'Y' ? (
<Pause className="h-2.5 w-2.5" />
{isActive ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-2.5 w-2.5" />
<Play className="h-4 w-4" />
)}
<span>{batch.is_active === 'Y' ? '비활성' : '활성'}</span>
{isActive ? '비활성' : '활성'}
</Button>
{/* 수정 버튼 */}
@ -149,36 +150,23 @@ export default function BatchCard({
variant="outline"
size="sm"
onClick={() => onEdit(batch.id)}
className="flex items-center justify-center space-x-1 bg-gray-50 hover:bg-gray-100 text-gray-700 border-gray-200 text-xs h-6"
className="h-9 flex-1 gap-2 text-sm"
>
<Edit className="h-2.5 w-2.5" />
<span></span>
<Edit className="h-4 w-4" />
</Button>
{/* 삭제 버튼 */}
<Button
variant="outline"
variant="destructive"
size="sm"
onClick={() => onDelete(batch.id, batch.batch_name)}
className="flex items-center justify-center space-x-1 bg-red-50 hover:bg-red-100 text-red-700 border-red-200 text-xs h-6"
className="h-9 flex-1 gap-2 text-sm"
>
<Trash2 className="h-2.5 w-2.5" />
<span></span>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 실행 중일 때 프로그레스 표시 */}
{executingBatch === batch.id && (
<div className="mt-2 pt-2 border-t border-blue-100">
<div className="flex items-center space-x-1 text-xs text-blue-600">
<Activity className="h-3 w-3 animate-pulse" />
<span> ...</span>
</div>
<div className="mt-1 w-full bg-blue-100 rounded-full h-1">
<div className="bg-blue-600 h-1 rounded-full animate-pulse" style={{ width: '45%' }}></div>
</div>
</div>
)}
</CardContent>
</Card>
);

View File

@ -166,41 +166,25 @@ export default function BatchJobModal({
}));
};
const getJobTypeIcon = (type: string) => {
switch (type) {
case 'collection': return '📥';
case 'sync': return '🔄';
case 'cleanup': return '🧹';
case 'custom': return '⚙️';
default: return '📋';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'Y': return 'bg-green-100 text-green-800';
case 'N': return 'bg-destructive/20 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
// 상태 제거 - 필요없음
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
<DialogTitle className="text-base sm:text-lg">
{job ? "배치 작업 수정" : "새 배치 작업"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 기본 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="job_name"> *</Label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<Label htmlFor="job_name" className="text-xs sm:text-sm"> *</Label>
<Input
id="job_name"
value={formData.job_name || ""}
@ -208,26 +192,24 @@ export default function BatchJobModal({
setFormData(prev => ({ ...prev, job_name: e.target.value }))
}
placeholder="배치 작업명을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="job_type"> *</Label>
<div>
<Label htmlFor="job_type" className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.job_type || "collection"}
onValueChange={handleJobTypeChange}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jobTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="flex items-center gap-2">
<span>{getJobTypeIcon(type.value)}</span>
{type.label}
</span>
<SelectItem key={type.value} value={type.value} className="text-xs sm:text-sm">
{type.label}
</SelectItem>
))}
</SelectContent>
@ -235,8 +217,8 @@ export default function BatchJobModal({
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm"></Label>
<Textarea
id="description"
value={formData.description || ""}
@ -244,6 +226,7 @@ export default function BatchJobModal({
setFormData(prev => ({ ...prev, description: e.target.value }))
}
placeholder="배치 작업에 대한 설명을 입력하세요"
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
rows={3}
/>
</div>
@ -251,21 +234,21 @@ export default function BatchJobModal({
{/* 작업 설정 */}
{formData.job_type === 'collection' && (
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="space-y-2">
<Label htmlFor="collection_config"> </Label>
<div>
<Label htmlFor="collection_config" className="text-xs sm:text-sm"> </Label>
<Select
value={formData.config_json?.collectionConfigId?.toString() || ""}
onValueChange={handleCollectionConfigChange}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="수집 설정을 선택하세요" />
</SelectTrigger>
<SelectContent>
{collectionConfigs.map((config) => (
<SelectItem key={config.id} value={config.id.toString()}>
<SelectItem key={config.id} value={config.id.toString()} className="text-xs sm:text-sm">
{config.config_name} - {config.source_table}
</SelectItem>
))}
@ -276,11 +259,11 @@ export default function BatchJobModal({
)}
{/* 스케줄 설정 */}
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="space-y-2">
<Label htmlFor="schedule_cron">Cron </Label>
<div>
<Label htmlFor="schedule_cron" className="text-xs sm:text-sm">Cron </Label>
<div className="flex gap-2">
<Input
id="schedule_cron"
@ -289,15 +272,15 @@ export default function BatchJobModal({
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
}
placeholder="예: 0 0 * * * (매일 자정)"
className="flex-1"
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
/>
<Select onValueChange={handleSchedulePresetSelect}>
<SelectTrigger className="w-32">
<SelectTrigger className="h-8 w-24 text-xs sm:h-10 sm:w-32 sm:text-sm">
<SelectValue placeholder="프리셋" />
</SelectTrigger>
<SelectContent>
{schedulePresets.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
<SelectItem key={preset.value} value={preset.value} className="text-xs sm:text-sm">
{preset.label}
</SelectItem>
))}
@ -309,43 +292,43 @@ export default function BatchJobModal({
{/* 실행 통계 (수정 모드일 때만) */}
{job?.id && (
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold text-primary">
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="text-xl font-bold text-primary sm:text-2xl">
{formData.execution_count || 0}
</div>
<div className="text-sm text-muted-foreground"> </div>
<div className="text-xs text-muted-foreground sm:text-sm"> </div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold text-green-600">
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="text-xl font-bold text-primary sm:text-2xl">
{formData.success_count || 0}
</div>
<div className="text-sm text-muted-foreground"> </div>
<div className="text-xs text-muted-foreground sm:text-sm"></div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold text-destructive">
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="text-xl font-bold text-destructive sm:text-2xl">
{formData.failure_count || 0}
</div>
<div className="text-sm text-muted-foreground"> </div>
<div className="text-xs text-muted-foreground sm:text-sm"></div>
</div>
</div>
{formData.last_executed_at && (
<div className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground sm:text-sm">
: {new Date(formData.last_executed_at).toLocaleString()}
</div>
</p>
)}
</div>
)}
{/* 활성화 설정 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
<Switch
id="is_active"
checked={formData.is_active === "Y"}
@ -353,19 +336,28 @@ export default function BatchJobModal({
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
}
/>
<Label htmlFor="is_active"></Label>
<Label htmlFor="is_active" className="text-xs sm:text-sm"></Label>
</div>
<Badge className={getStatusColor(formData.is_active || "N")}>
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button type="submit" disabled={isLoading}>
<Button
type="submit"
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>

View File

@ -40,22 +40,21 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
return (
<div
className={cn(
"group cursor-pointer rounded-lg border p-3 transition-all hover:shadow-sm",
isSelected ? "border-gray-300 bg-gray-100" : "border-gray-200 bg-white hover:bg-gray-50",
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
isSelected
? "shadow-md"
: "hover:shadow-md",
)}
onClick={onSelect}
>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{category.category_name}</h3>
<h4 className="text-sm font-semibold">{category.category_name}</h4>
<Badge
variant={category.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer transition-colors",
category.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-muted-foreground hover:bg-gray-200 hover:text-gray-700",
"cursor-pointer text-xs transition-colors",
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
@ -71,17 +70,17 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
{category.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{category.category_code}</p>
{category.description && <p className="mt-1 text-sm text-gray-500">{category.description}</p>}
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
</div>
{/* 액션 버튼 */}
{isSelected && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="ghost" onClick={onEdit}>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete}>
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash2 className="h-3 w-3" />
</Button>
</div>

View File

@ -165,26 +165,26 @@ export function CodeCategoryFormModal({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
<DialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 카테고리 코드 */}
{!isEditing && (
<div className="space-y-2">
<Label htmlFor="categoryCode"> *</Label>
<Label htmlFor="categoryCode" className="text-xs sm:text-sm"> *</Label>
<Input
id="categoryCode"
{...createForm.register("categoryCode")}
disabled={isLoading}
placeholder="카테고리 코드를 입력하세요"
className={createForm.formState.errors.categoryCode ? "border-destructive" : ""}
className={createForm.formState.errors.categoryCode ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
onBlur={() => handleFieldBlur("categoryCode")}
/>
{createForm.formState.errors.categoryCode && (
<p className="text-sm text-destructive">{createForm.formState.errors.categoryCode.message}</p>
<p className="text-[10px] sm:text-xs text-destructive">{createForm.formState.errors.categoryCode.message}</p>
)}
{!createForm.formState.errors.categoryCode && (
<ValidationMessage
@ -199,9 +199,9 @@ export function CodeCategoryFormModal({
{/* 카테고리 코드 표시 (수정 시) */}
{isEditing && editingCategory && (
<div className="space-y-2">
<Label htmlFor="categoryCodeDisplay"> </Label>
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="bg-muted" />
<p className="text-sm text-gray-500"> .</p>
<Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm"> </Label>
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
<p className="text-[10px] sm:text-xs text-muted-foreground"> .</p>
</div>
)}
@ -350,8 +350,14 @@ export function CodeCategoryFormModal({
)}
{/* 버튼 */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
@ -362,6 +368,7 @@ export function CodeCategoryFormModal({
hasDuplicateErrors ||
isDuplicateChecking
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
<>

View File

@ -92,78 +92,77 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
}
return (
<div className="flex h-full flex-col">
{/* 검색 및 필터 */}
<div className="border-b p-4">
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 액션 */}
<div className="space-y-3">
{/* 검색 + 버튼 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="카테고리 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
className="h-10 pl-10 text-sm"
/>
</div>
{/* 활성 필터 */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
</label>
</div>
{/* 새 카테고리 버튼 */}
<Button onClick={handleNewCategory} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
<Button onClick={handleNewCategory} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 활성 필터 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
</label>
</div>
</div>
{/* 카테고리 목록 (무한 스크롤) */}
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
<div className="space-y-3" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : categories.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
</p>
</div>
) : (
<>
<div className="space-y-1 p-2">
{categories.map((category, index) => (
<CategoryItem
key={`${category.category_code}-${index}`}
category={category}
isSelected={selectedCategoryCode === category.category_code}
onSelect={() => onSelectCategory(category.category_code)}
onEdit={() => handleEditCategory(category.category_code)}
onDelete={() => handleDeleteCategory(category.category_code)}
/>
))}
</div>
{categories.map((category, index) => (
<CategoryItem
key={`${category.category_code}-${index}`}
category={category}
isSelected={selectedCategoryCode === category.category_code}
onSelect={() => onSelectCategory(category.category_code)}
onEdit={() => handleEditCategory(category.category_code)}
onDelete={() => handleDeleteCategory(category.category_code)}
/>
))}
{/* 추가 로딩 표시 */}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
)}
{/* 더 이상 데이터가 없을 때 */}
{!hasNextPage && categories.length > 0 && (
<div className="py-4 text-center text-sm text-gray-400"> .</div>
<div className="py-4 text-center text-sm text-muted-foreground"> .</div>
)}
</>
)}

View File

@ -109,20 +109,18 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
if (!categoryCode) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<p> </p>
</div>
<div className="flex h-96 items-center justify-center">
<p className="text-sm text-muted-foreground"> </p>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<p className="text-destructive"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
<p className="text-sm font-semibold text-destructive"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-4 h-10 text-sm font-medium">
</Button>
</div>
@ -131,129 +129,120 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
}
return (
<div className="flex h-full flex-col">
{/* 검색 및 필터 */}
<div className="border-b p-4">
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 액션 */}
<div className="space-y-3">
{/* 검색 + 버튼 */}
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="코드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
className="h-10 pl-10 text-sm"
/>
</div>
{/* 활성 필터 */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnlyCodes"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="activeOnlyCodes" className="text-sm text-muted-foreground">
</label>
</div>
{/* 새 코드 버튼 */}
<Button onClick={handleNewCode} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
<Button onClick={handleNewCode} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 활성 필터 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="activeOnlyCodes"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="activeOnlyCodes" className="text-sm text-muted-foreground">
</label>
</div>
</div>
{/* 코드 목록 (무한 스크롤) */}
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
<div className="space-y-3" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : filteredCodes.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
</p>
</div>
) : (
<>
<div className="p-2">
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={filteredCodes.map((code) => code.codeValue || code.code_value)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{filteredCodes.map((code, index) => (
<SortableCodeItem
key={`${code.codeValue || code.code_value}-${index}`}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
/>
))}
</div>
</SortableContext>
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={filteredCodes.map((code) => code.codeValue || code.code_value)}
strategy={verticalListSortingStrategy}
>
{filteredCodes.map((code, index) => (
<SortableCodeItem
key={`${code.codeValue || code.code_value}-${index}`}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
/>
))}
</SortableContext>
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<div className="cursor-grabbing rounded-lg border border-gray-300 bg-white p-3 shadow-lg">
{(() => {
const activeCode = dragAndDrop.activeItem;
if (!activeCode) return null;
return (
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">
{activeCode.codeName || activeCode.code_name}
</h3>
<Badge
variant={
activeCode.isActive === "Y" || activeCode.is_active === "Y"
? "default"
: "secondary"
}
className={cn(
"transition-colors",
activeCode.isActive === "Y" || activeCode.is_active === "Y"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-muted-foreground",
)}
>
{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{activeCode.codeValue || activeCode.code_value}
</p>
{activeCode.description && (
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
)}
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<div className="cursor-grabbing rounded-lg border bg-card p-4 shadow-lg">
{(() => {
const activeCode = dragAndDrop.activeItem;
if (!activeCode) return null;
return (
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">
{activeCode.codeName || activeCode.code_name}
</h4>
<Badge
variant={
activeCode.isActive === "Y" || activeCode.is_active === "Y"
? "default"
: "secondary"
}
>
{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{activeCode.codeValue || activeCode.code_value}
</p>
{activeCode.description && (
<p className="mt-1 text-xs text-muted-foreground">{activeCode.description}</p>
)}
</div>
);
})()}
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
);
})()}
</div>
) : null}
</DragOverlay>
</DndContext>
{/* 무한 스크롤 로딩 인디케이터 */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
)}
{/* 모든 코드 로드 완료 메시지 */}
{!hasNextPage && codes.length > 0 && (
<div className="py-4 text-center text-sm text-gray-500"> .</div>
<div className="py-4 text-center text-sm text-muted-foreground"> .</div>
)}
</>
)}

View File

@ -154,21 +154,21 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
<DialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* 코드값 */}
<div className="space-y-2">
<Label htmlFor="codeValue"> *</Label>
<Label htmlFor="codeValue" className="text-xs sm:text-sm"> *</Label>
<Input
id="codeValue"
{...form.register("codeValue")}
disabled={isLoading || isEditing} // 수정 시에는 비활성화
disabled={isLoading || isEditing}
placeholder="코드값을 입력하세요"
className={(form.formState.errors as any)?.codeValue ? "border-destructive" : ""}
className={(form.formState.errors as any)?.codeValue ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
onBlur={(e) => {
const value = e.target.value.trim();
if (value && !isEditing) {
@ -180,7 +180,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
}}
/>
{(form.formState.errors as any)?.codeValue && (
<p className="text-sm text-destructive">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
)}
{!isEditing && !(form.formState.errors as any)?.codeValue && (
<ValidationMessage
@ -193,13 +193,13 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{/* 코드명 */}
<div className="space-y-2">
<Label htmlFor="codeName"> *</Label>
<Label htmlFor="codeName" className="text-xs sm:text-sm"> *</Label>
<Input
id="codeName"
{...form.register("codeName")}
disabled={isLoading}
placeholder="코드명을 입력하세요"
className={form.formState.errors.codeName ? "border-destructive" : ""}
className={form.formState.errors.codeName ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) {
@ -211,7 +211,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
}}
/>
{form.formState.errors.codeName && (
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.codeName)}</p>
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage(form.formState.errors.codeName)}</p>
)}
{!form.formState.errors.codeName && (
<ValidationMessage
@ -224,13 +224,13 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="codeNameEng"> *</Label>
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm"> *</Label>
<Input
id="codeNameEng"
{...form.register("codeNameEng")}
disabled={isLoading}
placeholder="코드 영문명을 입력하세요"
className={form.formState.errors.codeNameEng ? "border-destructive" : ""}
className={form.formState.errors.codeNameEng ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) {
@ -242,7 +242,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
}}
/>
{form.formState.errors.codeNameEng && (
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
)}
{!form.formState.errors.codeNameEng && (
<ValidationMessage
@ -255,57 +255,65 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Label htmlFor="description" className="text-xs sm:text-sm"> *</Label>
<Textarea
id="description"
{...form.register("description")}
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={form.formState.errors.description ? "border-destructive" : ""}
className={form.formState.errors.description ? "text-xs sm:text-sm border-destructive" : "text-xs sm:text-sm"}
/>
{form.formState.errors.description && (
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.description)}</p>
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage(form.formState.errors.description)}</p>
)}
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sortOrder"> </Label>
<Label htmlFor="sortOrder" className="text-xs sm:text-sm"> </Label>
<Input
id="sortOrder"
type="number"
{...form.register("sortOrder", { valueAsNumber: true })}
disabled={isLoading}
min={1}
className={form.formState.errors.sortOrder ? "border-destructive" : ""}
className={form.formState.errors.sortOrder ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
/>
{form.formState.errors.sortOrder && (
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.sortOrder)}</p>
<p className="text-[10px] sm:text-xs text-destructive">{getErrorMessage(form.formState.errors.sortOrder)}</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{isEditing && (
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
<Switch
id="isActive"
checked={form.watch("isActive") === "Y"}
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
disabled={isLoading}
aria-label="활성 상태"
/>
<Label htmlFor="isActive">{form.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
<Label htmlFor="isActive" className="text-xs sm:text-sm">{form.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
</div>
)}
{/* 버튼 */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
<div className="flex gap-2 pt-4 sm:justify-end sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="submit"
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? (
<>

View File

@ -3,7 +3,6 @@ import { Company } from "@/types/company";
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
interface CompanyTableProps {
companies: Company[];
@ -14,13 +13,15 @@ interface CompanyTableProps {
/**
*
* 데스크톱: 테이블
* /태블릿: 카드
*/
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
// 디스크 사용량 포맷팅 함수
const formatDiskUsage = (company: Company) => {
if (!company.diskUsage) {
return (
<div className="text-muted-foreground flex items-center gap-1">
<div className="flex items-center gap-1 text-muted-foreground">
<HardDrive className="h-3 w-3" />
<span className="text-xs"> </span>
</div>
@ -32,45 +33,142 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<FileText className="h-3 w-3 text-blue-500" />
<FileText className="h-3 w-3 text-primary" />
<span className="text-xs font-medium">{fileCount} </span>
</div>
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3 text-green-500" />
<HardDrive className="h-3 w-3 text-primary" />
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
</div>
</div>
);
};
// 상태에 따른 Badge 색상 결정
// console.log(companies);
// 로딩 상태 렌더링
if (isLoading) {
return (
<div className="rounded-md border">
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
{COMPANY_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} className="h-12 text-sm font-semibold">
{column.label}
</TableHead>
))}
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="flex gap-2">
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
</div>
))}
</div>
</div>
))}
</div>
</>
);
}
// 데이터가 없을 때
if (companies.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> .</p>
</div>
</div>
);
}
// 실제 데이터 렌더링
return (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
{COMPANY_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }}>
<TableHead key={column.key} className="h-12 text-sm font-semibold">
{column.label}
</TableHead>
))}
<TableHead className="w-[140px]"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{COMPANY_TABLE_COLUMNS.map((column) => (
<TableCell key={column.key}>
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
))}
<TableCell>
{companies.map((company) => (
<TableRow key={company.regdate + company.company_code} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 font-mono text-sm">{company.company_code}</TableCell>
<TableCell className="h-16 text-sm font-medium">{company.company_name}</TableCell>
<TableCell className="h-16 text-sm">{company.writer}</TableCell>
<TableCell className="h-16">{formatDiskUsage(company)}</TableCell>
<TableCell className="h-16">
<div className="flex gap-2">
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(company)}
className="h-8 w-8"
aria-label="수정"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(company)}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
@ -78,86 +176,58 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
</TableBody>
</Table>
</div>
);
}
// 데이터가 없을 때
if (companies.length === 0) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{COMPANY_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }}>
{column.label}
</TableHead>
))}
<TableHead className="w-[140px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={COMPANY_TABLE_COLUMNS.length + 1} className="h-24 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<p> .</p>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{companies.map((company) => (
<div
key={company.regdate + company.company_code}
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{company.company_name}</h3>
<p className="mt-1 font-mono text-sm text-muted-foreground">{company.company_code}</p>
</div>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{company.writer}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<div>{formatDiskUsage(company)}</div>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(company)}
className="h-9 flex-1 gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(company)}
className="h-9 flex-1 gap-2 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
);
}
// 실제 데이터 렌더링
return (
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted">
<TableRow>
{COMPANY_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }}>
{column.label}
</TableHead>
))}
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((company) => (
<TableRow key={company.regdate + company.company_code} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{company.company_code}</TableCell>
<TableCell className="font-medium">{company.company_name}</TableCell>
<TableCell>{company.writer}</TableCell>
<TableCell className="py-2">{formatDiskUsage(company)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(company)}
className="h-8 w-8 p-0"
title="수정"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(company)}
className="text-destructive hover:text-destructive h-8 w-8 p-0 hover:font-bold"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
);
}

View File

@ -15,22 +15,19 @@ interface CompanyToolbarProps {
*
* , ,
*/
export function CompanyToolbar({ onCreateClick }: CompanyToolbarProps) {
// 검색어 변경 처리
// 상태 필터 변경 처리
// 검색 조건이 있는지 확인
export function CompanyToolbar({ totalCount, onCreateClick }: CompanyToolbarProps) {
return (
<div className="space-y-4">
{/* 상단: 제목과 등록 버튼 */}
<div className="flex items-center justify-end">
<Button onClick={onCreateClick} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 왼쪽: 카운트 정보 */}
<div className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span>
</div>
{/* 오른쪽: 등록 버튼 */}
<Button onClick={onCreateClick} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -1,7 +1,6 @@
import { RefreshCw, HardDrive, FileText, Building2, Clock } from "lucide-react";
import { AllDiskUsageInfo } from "@/types/company";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface DiskUsageSummaryProps {
@ -16,25 +15,30 @@ interface DiskUsageSummaryProps {
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
if (!diskUsageInfo) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<CardTitle className="text-sm font-medium"> </CardTitle>
<CardDescription> </CardDescription>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading} className="h-8 w-8 p-0">
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={isLoading}
className="h-8 w-8"
aria-label="새로고침"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex items-center justify-center py-6">
<div className="text-center">
<HardDrive className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> ...</p>
</div>
</div>
<div className="flex items-center justify-center py-6 text-muted-foreground">
<div className="text-center">
<HardDrive className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> ...</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@ -42,97 +46,96 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
const lastCheckedDate = new Date(lastChecked);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<CardTitle className="text-sm font-medium"> </CardTitle>
<CardDescription> </CardDescription>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Button
variant="outline"
size="sm"
size="icon"
onClick={onRefresh}
disabled={isLoading}
className="h-8 w-8 p-0"
title="새로고침"
className="h-8 w-8"
aria-label="새로고침"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{/* 총 회사 수 */}
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4 text-blue-500" />
<div>
<p className="text-muted-foreground text-xs"> </p>
<p className="text-lg font-semibold">{summary.totalCompanies}</p>
</div>
</div>
</div>
{/* 총 파일 수 */}
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-green-500" />
<div>
<p className="text-muted-foreground text-xs"> </p>
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}</p>
</div>
</div>
{/* 총 용량 */}
<div className="flex items-center space-x-2">
<HardDrive className="h-4 w-4 text-orange-500" />
<div>
<p className="text-muted-foreground text-xs"> </p>
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
</div>
</div>
{/* 마지막 업데이트 */}
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-gray-500" />
<div>
<p className="text-muted-foreground text-xs"> </p>
<p className="text-xs font-medium">
{lastCheckedDate.toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{/* 총 회사 수 */}
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4 text-primary" />
<div>
<p className="text-xs text-muted-foreground"> </p>
<p className="text-lg font-semibold">{summary.totalCompanies}</p>
</div>
</div>
{/* 용량 기준 상태 표시 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs"> </span>
<Badge
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
>
{summary.totalSizeMB > 1000 ? "용량 주의" : summary.totalSizeMB > 500 ? "보통" : "여유"}
</Badge>
</div>
{/* 간단한 진행 바 */}
<div className="mt-2 h-2 w-full rounded-full bg-gray-200">
<div
className={`h-2 rounded-full transition-all duration-300 ${
summary.totalSizeMB > 1000 ? "bg-destructive/100" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
}`}
style={{
width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`,
}}
/>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-xs">
<span>0 MB</span>
<span>2,000 MB ( )</span>
{/* 총 파일 수 */}
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-primary" />
<div>
<p className="text-xs text-muted-foreground"> </p>
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
{/* 총 용량 */}
<div className="flex items-center space-x-2">
<HardDrive className="h-4 w-4 text-primary" />
<div>
<p className="text-xs text-muted-foreground"> </p>
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
</div>
</div>
{/* 마지막 업데이트 */}
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground"> </p>
<p className="text-xs font-medium">
{lastCheckedDate.toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
</div>
{/* 용량 기준 상태 표시 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span>
<Badge
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
>
{summary.totalSizeMB > 1000 ? "용량 주의" : summary.totalSizeMB > 500 ? "보통" : "여유"}
</Badge>
</div>
{/* 간단한 진행 바 */}
<div className="mt-2 h-2 w-full rounded-full bg-muted">
<div
className={`h-2 rounded-full transition-all duration-300 ${
summary.totalSizeMB > 1000 ? "bg-destructive" : summary.totalSizeMB > 500 ? "bg-primary/60" : "bg-primary"
}`}
style={{
width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`,
}}
/>
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>0 MB</span>
<span>2,000 MB ( )</span>
</div>
</div>
</div>
);
}

View File

@ -260,45 +260,55 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}</DialogTitle>
<DialogTitle className="text-base sm:text-lg">
{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="max-h-[60vh] space-y-4 overflow-y-auto sm:space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="config_name"> *</Label>
<Label htmlFor="config_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="config_name"
value={formData.config_name}
onChange={(e) => setFormData((prev) => ({ ...prev, config_name: e.target.value }))}
placeholder="예: 개발팀 Discord"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="이 외부 호출 설정에 대한 설명을 입력하세요."
rows={2}
className="text-xs sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<Label htmlFor="call_type"> *</Label>
<Label htmlFor="call_type" className="text-xs sm:text-sm">
*
</Label>
<Select value={formData.call_type} onValueChange={handleCallTypeChange}>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label}
</SelectItem>
))}
@ -307,17 +317,19 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
</div>
<div>
<Label htmlFor="is_active"></Label>
<Label htmlFor="is_active" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.is_active}
onValueChange={(value) => setFormData((prev) => ({ ...prev, is_active: value }))}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label}
</SelectItem>
))}
@ -329,19 +341,21 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* REST API 설정 */}
{formData.call_type === "rest-api" && (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="api_type">API *</Label>
<Label htmlFor="api_type" className="text-xs sm:text-sm">
API *
</Label>
<Select
value={formData.api_type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, api_type: value }))}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label}
</SelectItem>
))}
@ -351,33 +365,42 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* Discord 설정 */}
{formData.api_type === "discord" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium">Discord </h4>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm">Discord </h4>
<div>
<Label htmlFor="discord_webhook"> URL *</Label>
<Label htmlFor="discord_webhook" className="text-xs sm:text-sm">
URL *
</Label>
<Input
id="discord_webhook"
value={discordSettings.webhookUrl}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://discord.com/api/webhooks/..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="discord_username"></Label>
<Label htmlFor="discord_username" className="text-xs sm:text-sm">
</Label>
<Input
id="discord_username"
value={discordSettings.username}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, username: e.target.value }))}
placeholder="ERP 시스템"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="discord_avatar"> URL</Label>
<Label htmlFor="discord_avatar" className="text-xs sm:text-sm">
URL
</Label>
<Input
id="discord_avatar"
value={discordSettings.avatarUrl}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, avatarUrl: e.target.value }))}
placeholder="https://example.com/avatar.png"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
@ -385,33 +408,42 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* Slack 설정 */}
{formData.api_type === "slack" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium">Slack </h4>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm">Slack </h4>
<div>
<Label htmlFor="slack_webhook"> URL *</Label>
<Label htmlFor="slack_webhook" className="text-xs sm:text-sm">
URL *
</Label>
<Input
id="slack_webhook"
value={slackSettings.webhookUrl}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://hooks.slack.com/services/..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="slack_channel"></Label>
<Label htmlFor="slack_channel" className="text-xs sm:text-sm">
</Label>
<Input
id="slack_channel"
value={slackSettings.channel}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, channel: e.target.value }))}
placeholder="#general"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="slack_username"></Label>
<Label htmlFor="slack_username" className="text-xs sm:text-sm">
</Label>
<Input
id="slack_username"
value={slackSettings.username}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, username: e.target.value }))}
placeholder="ERP Bot"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
@ -419,25 +451,31 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* 카카오톡 설정 */}
{formData.api_type === "kakao-talk" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium"> </h4>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm"> </h4>
<div>
<Label htmlFor="kakao_token"> *</Label>
<Label htmlFor="kakao_token" className="text-xs sm:text-sm">
*
</Label>
<Input
id="kakao_token"
type="password"
value={kakaoSettings.accessToken}
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, accessToken: e.target.value }))}
placeholder="카카오 API 액세스 토큰"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="kakao_template">릿 ID</Label>
<Label htmlFor="kakao_template" className="text-xs sm:text-sm">
릿 ID
</Label>
<Input
id="kakao_template"
value={kakaoSettings.templateId}
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, templateId: e.target.value }))}
placeholder="template_001"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
@ -445,54 +483,65 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* 일반 API 설정 */}
{formData.api_type === "generic" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium"> API </h4>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm"> API </h4>
<div>
<Label htmlFor="generic_url">API URL *</Label>
<Label htmlFor="generic_url" className="text-xs sm:text-sm">
API URL *
</Label>
<Input
id="generic_url"
value={genericSettings.url}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, url: e.target.value }))}
placeholder="https://api.example.com/webhook"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<Label htmlFor="generic_method">HTTP </Label>
<Label htmlFor="generic_method" className="text-xs sm:text-sm">
HTTP
</Label>
<Select
value={genericSettings.method}
onValueChange={(value) => setGenericSettings((prev) => ({ ...prev, method: value }))}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="GET" className="text-xs sm:text-sm">GET</SelectItem>
<SelectItem value="POST" className="text-xs sm:text-sm">POST</SelectItem>
<SelectItem value="PUT" className="text-xs sm:text-sm">PUT</SelectItem>
<SelectItem value="DELETE" className="text-xs sm:text-sm">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="generic_timeout"> (ms)</Label>
<Label htmlFor="generic_timeout" className="text-xs sm:text-sm">
(ms)
</Label>
<Input
id="generic_timeout"
type="number"
value={genericSettings.timeout}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, timeout: e.target.value }))}
placeholder="30000"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div>
<Label htmlFor="generic_headers"> (JSON)</Label>
<Label htmlFor="generic_headers" className="text-xs sm:text-sm">
(JSON)
</Label>
<Textarea
id="generic_headers"
value={genericSettings.headers}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, headers: e.target.value }))}
placeholder='{"Content-Type": "application/json"}'
rows={3}
className="text-xs sm:text-sm"
/>
</div>
</div>
@ -502,17 +551,26 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* 다른 호출 타입들 (이메일, FTP, 큐) */}
{formData.call_type !== "rest-api" && (
<div className="text-muted-foreground rounded-lg border p-4 text-center">
<div className="rounded-lg border bg-muted/20 p-3 text-center text-xs text-muted-foreground sm:p-4 sm:text-sm">
{formData.call_type} .
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleSave} disabled={loading}>
<Button
onClick={handleSave}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
</Button>
</DialogFooter>

View File

@ -302,31 +302,36 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-3 sm:space-y-4">
{/* 기본 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<Label htmlFor="connection_name"> *</Label>
<Label htmlFor="connection_name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="connection_name"
value={formData.connection_name}
onChange={(e) => handleInputChange("connection_name", e.target.value)}
placeholder="예: 운영 DB"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="db_type">DB *</Label>
<Label htmlFor="db_type" className="text-xs sm:text-sm">
DB <span className="text-destructive">*</span>
</Label>
<Select value={formData.db_type} onValueChange={handleDbTypeChange}>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -341,67 +346,84 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
</div>
<div>
<Label htmlFor="description"></Label>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="연결에 대한 설명을 입력하세요"
rows={2}
className="text-xs sm:text-sm"
/>
</div>
</div>
{/* 연결 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<Label htmlFor="host"> *</Label>
<Label htmlFor="host" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="host"
value={formData.host}
onChange={(e) => handleInputChange("host", e.target.value)}
placeholder="localhost"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="port"> *</Label>
<Label htmlFor="port" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) => handleInputChange("port", parseInt(e.target.value) || 0)}
placeholder="5432"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div>
<Label htmlFor="database_name"> *</Label>
<Label htmlFor="database_name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="database_name"
value={formData.database_name}
onChange={(e) => handleInputChange("database_name", e.target.value)}
placeholder="database_name"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<Label htmlFor="username"> *</Label>
<Label htmlFor="username" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => handleInputChange("username", e.target.value)}
placeholder="username"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="password"> {isEditMode ? "(변경 시에만 입력)" : "*"}</Label>
<Label htmlFor="password" className="text-xs sm:text-sm">
{isEditMode ? "(변경 시에만 입력)" : <span className="text-destructive">*</span>}
</Label>
<div className="relative">
<Input
id="password"
@ -409,12 +431,13 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder={isEditMode ? "변경하지 않으려면 비워두세요" : "password"}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
@ -570,11 +593,16 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleSave} disabled={loading}>
<Button onClick={handleSave} disabled={loading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
</Button>
</DialogFooter>

View File

@ -8,12 +8,9 @@ import { MenuFormModal } from "./MenuFormModal";
import { Button } from "@/components/ui/button";
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
@ -28,7 +25,6 @@ import { useMenu } from "@/contexts/MenuContext";
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client";
import { ScreenAssignmentTab } from "./ScreenAssignmentTab";
type MenuType = "admin" | "user";
@ -805,297 +801,251 @@ export const MenuManagement: React.FC = () => {
return (
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
<div className="flex h-full flex-col">
{/* 탭 컨테이너 */}
<Tabs defaultValue="menus" className="flex flex-1 flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="menus"> </TabsTrigger>
<TabsTrigger value="screen-assignment"> </TabsTrigger>
</TabsList>
<div className="flex h-full gap-6">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r pr-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">{getUITextSync("menu.type.title")}</h3>
{/* 메뉴 관리 탭 */}
<TabsContent value="menus" className="flex-1 overflow-hidden">
<div className="flex h-full">
{/* 메인 컨텐츠 - 2:8 비율 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r bg-gray-50">
<div className="p-6">
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50 pb-3">
<CardTitle className="text-lg">{getUITextSync("menu.type.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pt-4">
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-gray-300"
}`}
onClick={() => handleMenuTypeChange("admin")}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{getUITextSync("menu.management.admin.description")}
</p>
</div>
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
{adminMenus.length}
</Badge>
</div>
</CardContent>
</Card>
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-gray-300"
}`}
onClick={() => handleMenuTypeChange("user")}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{getUITextSync("menu.management.user.description")}
</p>
</div>
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>
{userMenus.length}
</Badge>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* 메뉴 타입 선택 카드들 */}
<div className="space-y-3">
<div
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border"
}`}
onClick={() => handleMenuTypeChange("admin")}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4>
<p className="mt-1 text-xs text-muted-foreground">
{getUITextSync("menu.management.admin.description")}
</p>
</div>
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
{adminMenus.length}
</Badge>
</div>
</div>
{/* 우측 메인 영역 - 메뉴 목록 (80%) */}
<div className="w-[80%] overflow-hidden">
<div className="flex h-full flex-col p-6">
<Card className="flex-1 shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="text-xl">
{getMenuTypeString()} {getUITextSync("menu.list.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
{/* 검색 및 필터 영역 */}
<div className="mb-4 flex-shrink-0">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
<div className="company-dropdown relative">
<button
type="button"
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all"
? getUITextSync("filter.company.all")
: selectedCompany === "*"
? getUITextSync("filter.company.common")
: companies.find((c) => c.code === selectedCompany)?.name ||
getUITextSync("filter.company.all")}
</span>
<svg
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isCompanyDropdownOpen && (
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md">
{/* 검색 입력 */}
<div className="border-b p-2">
<Input
placeholder={getUITextSync("filter.company.search")}
value={companySearchText}
onChange={(e) => setCompanySearchText(e.target.value)}
className="h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* 회사 목록 */}
<div className="max-h-48 overflow-y-auto">
<div
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => {
setSelectedCompany("all");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.all")}
</div>
<div
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => {
setSelectedCompany("*");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.common")}
</div>
{companies
.filter((company) => company.code && company.code.trim() !== "")
.filter(
(company) =>
company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
company.code.toLowerCase().includes(companySearchText.toLowerCase()),
)
.map((company, index) => (
<div
key={company.code || `company-${index}`}
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => {
setSelectedCompany(company.code);
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
</div>
))}
</div>
</div>
)}
</div>
</div>
<div>
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
<Input
placeholder={getUITextSync("filter.search.placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="flex items-end">
<Button
onClick={() => {
setSearchText("");
setSelectedCompany("all");
setCompanySearchText("");
}}
variant="outline"
className="w-full"
>
{getUITextSync("filter.reset")}
</Button>
</div>
<div className="flex items-end">
<div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
{getUITextSync("button.add.top.level")}
</Button>
{selectedMenus.size > 0 && (
<Button
variant="destructive"
onClick={handleDeleteSelectedMenus}
disabled={deleting}
className="min-w-[120px]"
>
{deleting ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{getUITextSync("button.delete.processing")}
</>
) : (
getUITextSync("button.delete.selected.count", {
count: selectedMenus.size,
})
)}
</Button>
)}
</div>
</div>
<MenuTable
menus={getCurrentMenus()}
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
/>
</div>
</CardContent>
</Card>
<div
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border"
}`}
onClick={() => handleMenuTypeChange("user")}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4>
<p className="mt-1 text-xs text-muted-foreground">
{getUITextSync("menu.management.user.description")}
</p>
</div>
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>
{userMenus.length}
</Badge>
</div>
</div>
</div>
</TabsContent>
</div>
</div>
{/* 화면 할당 탭 */}
<TabsContent value="screen-assignment" className="flex-1 overflow-hidden p-6">
<Card className="h-full shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="h-full overflow-hidden">
<ScreenAssignmentTab menus={[...adminMenus, ...userMenus]} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 우측 메인 영역 - 메뉴 목록 (80%) */}
<div className="w-[80%] pl-0">
<div className="flex h-full flex-col space-y-4">
{/* 상단 헤더: 제목 + 검색 + 버튼 */}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 왼쪽: 제목 */}
<h2 className="text-xl font-semibold">
{getMenuTypeString()} {getUITextSync("menu.list.title")}
</h2>
<MenuFormModal
isOpen={formModalOpen}
onClose={() => setFormModalOpen(false)}
onSuccess={handleFormSuccess}
menuId={formData.menuId}
parentId={formData.parentId}
menuType={formData.menuType}
level={formData.level}
parentCompanyCode={formData.parentCompanyCode}
uiTexts={uiTexts}
/>
{/* 오른쪽: 검색 + 버튼 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* 회사 선택 */}
<div className="w-full sm:w-[160px]">
<div className="company-dropdown relative">
<button
type="button"
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all"
? getUITextSync("filter.company.all")
: selectedCompany === "*"
? getUITextSync("filter.company.common")
: companies.find((c) => c.code === selectedCompany)?.name ||
getUITextSync("filter.company.all")}
</span>
<svg
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isCompanyDropdownOpen && (
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg">
<div className="border-b p-2">
<Input
placeholder={getUITextSync("filter.company.search")}
value={companySearchText}
onChange={(e) => setCompanySearchText(e.target.value)}
className="h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-48 overflow-y-auto">
<div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedCompany("all");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.all")}
</div>
<div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedCompany("*");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.common")}
</div>
{companies
.filter((company) => company.code && company.code.trim() !== "")
.filter(
(company) =>
company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
company.code.toLowerCase().includes(companySearchText.toLowerCase()),
)
.map((company, index) => (
<div
key={company.code || `company-${index}`}
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedCompany(company.code);
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* 검색 입력 */}
<div className="w-full sm:w-[240px]">
<Input
id="search"
placeholder={getUITextSync("filter.search.placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-10 text-sm"
/>
</div>
{/* 초기화 버튼 */}
<Button
onClick={() => {
setSearchText("");
setSelectedCompany("all");
setCompanySearchText("");
}}
variant="outline"
className="h-10 text-sm font-medium"
>
{getUITextSync("filter.reset")}
</Button>
{/* 최상위 메뉴 추가 */}
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
{getUITextSync("button.add.top.level")}
</Button>
{/* 선택 삭제 */}
{selectedMenus.size > 0 && (
<Button
variant="destructive"
onClick={handleDeleteSelectedMenus}
disabled={deleting}
className="h-10 gap-2 text-sm font-medium"
>
{deleting ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{getUITextSync("button.delete.processing")}
</>
) : (
getUITextSync("button.delete.selected.count", {
count: selectedMenus.size,
})
)}
</Button>
)}
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 overflow-hidden">
<MenuTable
menus={getCurrentMenus()}
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
/>
</div>
</div>
</div>
</div>
<MenuFormModal
isOpen={formModalOpen}
onClose={() => setFormModalOpen(false)}
onSuccess={handleFormSuccess}
menuId={formData.menuId}
parentId={formData.parentId}
menuType={formData.menuType}
level={formData.level}
parentCompanyCode={formData.parentCompanyCode}
uiTexts={uiTexts}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</LoadingOverlay>
);
};

View File

@ -202,166 +202,152 @@ export function RestApiConnectionList() {
return (
<>
{/* 검색 및 필터 */}
<Card className="mb-6 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="연결명 또는 URL로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 pl-10"
/>
</div>
{/* 인증 타입 필터 */}
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="인증 타입" />
</SelectTrigger>
<SelectContent>
{supportedAuthTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="w-32">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
</Button>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 검색 */}
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명 또는 URL로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</CardContent>
</Card>
{/* 인증 타입 필터 */}
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]">
<SelectValue placeholder="인증 타입" />
</SelectTrigger>
<SelectContent>
{supportedAuthTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 연결 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-gray-500"> ...</div>
<div className="flex h-64 items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
) : connections.length === 0 ? (
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="py-8 text-center text-gray-500">
<TestTube className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium"> REST API </p>
<p className="mb-4 text-sm text-gray-400"> REST API .</p>
<Button onClick={handleAddConnection}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> REST API </p>
</div>
</div>
) : (
<Card className="shadow-sm">
<CardContent className="p-0">
<Table>
<div className="rounded-lg border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[280px]"> URL</TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="w-[80px]"> </TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> URL</TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((connection) => (
<TableRow key={connection.id} className="hover:bg-gray-50">
<TableCell>
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 text-sm">
<div className="font-medium">{connection.connection_name}</div>
{connection.description && (
<div className="mt-1 text-xs text-gray-500">{connection.description}</div>
<div className="mt-1 text-xs text-muted-foreground">{connection.description}</div>
)}
</TableCell>
<TableCell className="font-mono text-xs">{connection.base_url}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
<TableCell className="h-16 font-mono text-sm">{connection.base_url}</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant="outline">
{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}
</Badge>
</TableCell>
<TableCell className="text-center">
<TableCell className="h-16 text-center text-sm">
{Object.keys(connection.default_headers || {}).length}
</TableCell>
<TableCell>
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-xs">
<TableCell className="h-16 text-sm">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-xs">
<TableCell className="h-16 text-sm">
{connection.last_test_date ? (
<div>
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
<Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1 text-xs"
className="mt-1"
>
{connection.last_test_result === "Y" ? "성공" : "실패"}
</Badge>
</div>
) : (
<span className="text-gray-400">-</span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<TableCell className="h-16 text-sm">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)}
className="h-7 px-2 text-xs"
className="h-9 text-sm"
>
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(connection.id!) && (
<Badge
variant={testResults.get(connection.id!) ? "default" : "destructive"}
className="text-xs text-white"
>
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
{testResults.get(connection.id!) ? "성공" : "실패"}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<TableCell className="h-16 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => handleEditConnection(connection)}
className="h-8 w-8 p-0"
className="h-8 w-8"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => handleDeleteConnection(connection)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
@ -371,8 +357,7 @@ export function RestApiConnectionList() {
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)}
{/* 연결 설정 모달 */}
@ -387,20 +372,25 @@ export function RestApiConnectionList() {
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{connectionToDelete?.connection_name}" ?
<br />
<span className="font-medium text-red-600"> .</span>
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelDeleteConnection}></AlertDialogCancel>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
onClick={cancelDeleteConnection}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConnection}
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>

View File

@ -68,22 +68,18 @@ export function SortableCodeItem({
{...attributes}
{...listeners}
className={cn(
"group cursor-grab rounded-lg border p-3 transition-all hover:shadow-sm",
"border-gray-200 bg-white hover:bg-gray-50",
"group cursor-grab rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md",
isDragging && "cursor-grabbing opacity-50",
)}
>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{code.codeName || code.code_name}</h3>
<h4 className="text-sm font-semibold">{code.codeName || code.code_name}</h4>
<Badge
variant={code.isActive === "Y" || code.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer transition-colors",
code.isActive === "Y" || code.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-muted-foreground hover:bg-gray-200 hover:text-gray-700",
"cursor-pointer text-xs transition-colors",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
@ -100,8 +96,8 @@ export function SortableCodeItem({
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{code.codeValue || code.code_value}</p>
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
<p className="mt-1 text-xs text-muted-foreground">{code.codeValue || code.code_value}</p>
{code.description && <p className="mt-1 text-xs text-muted-foreground">{code.description}</p>}
</div>
{/* 액션 버튼 */}
@ -111,8 +107,8 @@ export function SortableCodeItem({
onMouseDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -122,8 +118,8 @@ export function SortableCodeItem({
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

View File

@ -220,15 +220,15 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
</div>
{/* 테이블 정보 */}
<div className="bg-muted/50 rounded-md border p-4 space-y-4">
<div className="rounded-md border bg-muted/50 p-4 space-y-4">
<div>
<h3 className="mb-2 font-medium"> </h3>
<h3 className="mb-2 font-medium text-sm"> </h3>
<div className="max-h-[200px] overflow-y-auto">
<div className="pr-2 space-y-2">
<div className="space-y-2 pr-2">
{tables.map((table) => (
<div key={table.table_name} className="bg-white rounded-lg shadow-sm border p-3">
<div key={table.table_name} className="rounded-lg border bg-card p-3 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="font-mono font-bold">{table.table_name}</h4>
<h4 className="font-mono font-bold text-sm">{table.table_name}</h4>
<Button
variant="ghost"
size="sm"
@ -237,12 +237,13 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
loadTableColumns(table.table_name);
setQuery(`SELECT * FROM ${table.table_name}`);
}}
className="h-8 text-xs"
>
</Button>
</div>
{table.description && (
<p className="text-muted-foreground mt-1 text-sm">{table.description}</p>
<p className="mt-1 text-sm text-muted-foreground">{table.description}</p>
)}
</div>
))}
@ -253,12 +254,12 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
{/* 선택된 테이블의 컬럼 정보 */}
{selectedTable && (
<div>
<h3 className="mb-2 font-medium"> : {selectedTable}</h3>
<h3 className="mb-2 font-medium text-sm"> : {selectedTable}</h3>
{loadingColumns ? (
<div className="text-sm text-muted-foreground"> ...</div>
) : selectedTableColumns.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto">
<div className="bg-white rounded-lg shadow-sm border">
<div className="rounded-lg border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
@ -315,20 +316,20 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
{/* 결과 섹션 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
{loading ? "쿼리 실행 중..." : results.length > 0 ? `${results.length}개의 결과가 있습니다.` : "실행된 쿼리가 없습니다."}
</div>
</div>
{/* 결과 그리드 */}
<div className="rounded-md border">
<div className="rounded-md border bg-card">
<div className="max-h-[300px] overflow-y-auto">
<div className="min-w-full inline-block align-middle">
<div className="inline-block min-w-full align-middle">
<div className="overflow-x-auto">
<Table>
{results.length > 0 ? (
<>
<TableHeader className="sticky top-0 bg-white z-10">
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{Object.keys(results[0]).map((key) => (
<TableHead key={key} className="font-mono font-bold">

View File

@ -101,14 +101,18 @@ export function UserManagement() {
{/* 에러 메시지 */}
{error && (
<div className="bg-destructive/10 border-destructive/20 rounded-lg border p-4">
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-center justify-between">
<p className="text-destructive font-medium"> </p>
<button onClick={clearError} className="text-destructive hover:text-destructive/80">
<p className="text-sm font-semibold text-destructive"> </p>
<button
onClick={clearError}
className="text-destructive transition-colors hover:text-destructive/80"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="text-destructive/80 mt-1">{error}</p>
<p className="mt-1.5 text-sm text-destructive/80">{error}</p>
</div>
)}

View File

@ -98,31 +98,145 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
// 로딩 상태 렌더링
if (isLoading) {
return (
<div className="rounded-md border">
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50">
{USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold">
{column.label}
</TableHead>
))}
<TableHead className="h-12 w-[200px] text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
{USER_TABLE_COLUMNS.map((column) => (
<TableCell key={column.key} className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
</TableCell>
))}
<TableCell className="h-16">
<div className="flex gap-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="h-8 w-8 animate-pulse rounded bg-muted"></div>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</div>
<div className="h-6 w-11 animate-pulse rounded-full bg-muted"></div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
</div>
))}
</div>
<div className="mt-4 flex gap-2 border-t pt-4">
<div className="h-9 flex-1 animate-pulse rounded bg-muted"></div>
<div className="h-9 flex-1 animate-pulse rounded bg-muted"></div>
</div>
</div>
))}
</div>
</>
);
}
// 데이터가 없을 때
if (users.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> .</p>
</div>
</div>
);
}
// 실제 데이터 렌더링
return (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
{USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }}>
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold">
{column.label}
</TableHead>
))}
<TableHead className="w-[200px]"></TableHead>
<TableHead className="h-12 w-[200px] text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index}>
{USER_TABLE_COLUMNS.map((column) => (
<TableCell key={column.key}>
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
))}
<TableCell>
<div className="flex gap-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-muted h-8 w-8 animate-pulse rounded"></div>
))}
{users.map((user, index) => (
<TableRow
key={`${user.userId}-${index}`}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="h-16 font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
<TableCell className="h-16 font-mono text-sm">{user.sabun || "-"}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.companyCode || "-"}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.deptName || "-"}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.positionName || "-"}</TableCell>
<TableCell className="h-16 font-mono text-sm">{user.userId}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.userName}</TableCell>
<TableCell className="h-16 text-sm">{user.tel || user.cellPhone || "-"}</TableCell>
<TableCell className="h-16 max-w-[200px] truncate text-sm" title={user.email}>
{user.email || "-"}
</TableCell>
<TableCell className="h-16 text-sm">{formatDate(user.regDate || "")}</TableCell>
<TableCell className="h-16">
<div className="flex items-center">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
aria-label={`${user.userName} 상태 토글`}
/>
</div>
</TableCell>
<TableCell className="h-16">
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
className="h-8 w-8"
title="비밀번호 초기화"
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenHistoryModal(user)}
className="h-8 w-8"
title="변경이력 조회"
>
<History className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
@ -130,102 +244,95 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
</TableBody>
</Table>
</div>
);
}
// 데이터가 없을 때
if (users.length === 0) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }}>
{column.label}
</TableHead>
))}
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={USER_TABLE_COLUMNS.length + 1} className="h-24 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<p> .</p>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{users.map((user, index) => (
<div
key={`${user.userId}-${index}`}
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
>
{/* 헤더: 이름과 상태 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{user.userName}</h3>
<p className="mt-1 font-mono text-sm text-muted-foreground">{user.userId}</p>
</div>
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
aria-label={`${user.userName} 상태 토글`}
/>
</div>
{/* 정보 그리드 */}
<div className="space-y-2 border-t pt-4">
{user.sabun && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{user.sabun}</span>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
)}
{user.companyCode && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.companyCode}</span>
</div>
)}
{user.deptName && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.deptName}</span>
</div>
)}
{user.positionName && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.positionName}</span>
</div>
)}
{(user.tel || user.cellPhone) && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{user.tel || user.cellPhone}</span>
</div>
)}
{user.email && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground"></span>
<span className="break-all">{user.email}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{formatDate(user.regDate || "")}</span>
</div>
</div>
{/* 액션 버튼 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
className="h-9 flex-1 gap-2 text-sm"
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenHistoryModal(user)}
className="h-9 flex-1 gap-2 text-sm"
>
<History className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
);
}
// 실제 데이터 렌더링
return (
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted">
<TableRow>
{USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }}>
{column.label}
</TableHead>
))}
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user, index) => (
<TableRow key={`${user.userId}-${index}`} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
<TableCell className="font-mono text-sm">{user.sabun || "-"}</TableCell>
<TableCell className="font-medium">{user.companyCode || "-"}</TableCell>
<TableCell className="font-medium">{user.deptName || "-"}</TableCell>
<TableCell className="font-medium">{user.positionName || "-"}</TableCell>
<TableCell className="font-mono">{user.userId}</TableCell>
<TableCell className="font-medium">{user.userName}</TableCell>
<TableCell>{user.tel || user.cellPhone || "-"}</TableCell>
<TableCell className="max-w-[200px] truncate" title={user.email}>
{user.email || "-"}
</TableCell>
<TableCell>{formatDate(user.regDate || "")}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
aria-label={`${user.userName} 상태 토글`}
/>
</div>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
className="h-8 w-8 p-0"
title="비밀번호 초기화"
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenHistoryModal(user)}
className="h-8 w-8 p-0"
title="변경이력 조회"
>
<History className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 상태 변경 확인 모달 */}
<UserStatusConfirmDialog
@ -243,6 +350,6 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
userId={historyModal.userId}
userName={historyModal.userName}
/>
</div>
</>
);
}

View File

@ -65,15 +65,15 @@ export function UserToolbar({
return (
<div className="space-y-4">
{/* 메인 검색 영역 */}
<div className="bg-muted/30 rounded-lg p-4">
{/* 통합 검색 */}
<div className="mb-4 flex items-center gap-4">
<div className="flex-1">
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search
className={`absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform ${
isSearching ? "animate-pulse text-blue-500" : "text-muted-foreground"
className={`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${
isSearching ? "animate-pulse text-primary" : "text-muted-foreground"
}`}
/>
<Input
@ -81,14 +81,14 @@ export function UserToolbar({
value={searchFilter.searchValue || ""}
onChange={(e) => handleUnifiedSearchChange(e.target.value)}
disabled={isAdvancedSearchMode}
className={`pl-10 ${isSearching ? "border-blue-300 ring-1 ring-blue-200" : ""} ${
isAdvancedSearchMode ? "bg-muted text-muted-foreground cursor-not-allowed" : ""
}`}
className={`h-10 pl-10 text-sm ${
isSearching ? "border-primary ring-2 ring-primary/20" : ""
} ${isAdvancedSearchMode ? "cursor-not-allowed bg-muted text-muted-foreground" : ""}`}
/>
</div>
{isSearching && <p className="mt-1 text-xs text-blue-500"> ...</p>}
{isSearching && <p className="mt-1.5 text-xs text-primary"> ...</p>}
{isAdvancedSearchMode && (
<p className="mt-1 text-xs text-amber-600">
<p className="mt-1.5 text-xs text-warning">
. .
</p>
)}
@ -97,95 +97,96 @@ export function UserToolbar({
{/* 고급 검색 토글 버튼 */}
<Button
variant="outline"
size="sm"
size="default"
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
className="gap-2"
className="h-10 gap-2 text-sm font-medium"
>
🔍
{showAdvancedSearch ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
{/* 고급 검색 옵션 */}
{showAdvancedSearch && (
<div className="border-t pt-4">
<div className="mb-3">
<h4 className="text-sm font-medium"> </h4>
<span className="text-muted-foreground text-xs">( )</span>
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
{/* 조회 결과 정보 */}
<div className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span>
</div>
{/* 사용자 등록 버튼 */}
<Button onClick={onCreateClick} size="default" className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 고급 검색 옵션 */}
{showAdvancedSearch && (
<div className="space-y-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold"> </h4>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 고급 검색 필드들 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Input
placeholder="회사명 검색"
value={searchFilter.search_companyName || ""}
onChange={(e) => handleAdvancedSearchChange("search_companyName", e.target.value)}
className="h-10 text-sm"
/>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="회사명 검색"
value={searchFilter.search_companyName || ""}
onChange={(e) => handleAdvancedSearchChange("search_companyName", e.target.value)}
/>
</div>
<Input
placeholder="부서명 검색"
value={searchFilter.search_deptName || ""}
onChange={(e) => handleAdvancedSearchChange("search_deptName", e.target.value)}
className="h-10 text-sm"
/>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="부서명 검색"
value={searchFilter.search_deptName || ""}
onChange={(e) => handleAdvancedSearchChange("search_deptName", e.target.value)}
/>
</div>
<Input
placeholder="직책 검색"
value={searchFilter.search_positionName || ""}
onChange={(e) => handleAdvancedSearchChange("search_positionName", e.target.value)}
className="h-10 text-sm"
/>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="직책 검색"
value={searchFilter.search_positionName || ""}
onChange={(e) => handleAdvancedSearchChange("search_positionName", e.target.value)}
/>
</div>
<Input
placeholder="사용자 ID 검색"
value={searchFilter.search_userId || ""}
onChange={(e) => handleAdvancedSearchChange("search_userId", e.target.value)}
className="h-10 text-sm"
/>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"> ID</label>
<Input
placeholder="사용자 ID 검색"
value={searchFilter.search_userId || ""}
onChange={(e) => handleAdvancedSearchChange("search_userId", e.target.value)}
/>
</div>
<Input
placeholder="사용자명 검색"
value={searchFilter.search_userName || ""}
onChange={(e) => handleAdvancedSearchChange("search_userName", e.target.value)}
className="h-10 text-sm"
/>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="사용자명 검색"
value={searchFilter.search_userName || ""}
onChange={(e) => handleAdvancedSearchChange("search_userName", e.target.value)}
/>
</div>
<Input
placeholder="전화번호/휴대폰 검색"
value={searchFilter.search_tel || ""}
onChange={(e) => handleAdvancedSearchChange("search_tel", e.target.value)}
className="h-10 text-sm"
/>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="전화번호/휴대폰 검색"
value={searchFilter.search_tel || ""}
onChange={(e) => handleAdvancedSearchChange("search_tel", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="이메일 검색"
value={searchFilter.search_email || ""}
onChange={(e) => handleAdvancedSearchChange("search_email", e.target.value)}
/>
</div>
<Input
placeholder="이메일 검색"
value={searchFilter.search_email || ""}
onChange={(e) => handleAdvancedSearchChange("search_email", e.target.value)}
className="h-10 text-sm"
/>
</div>
{/* 고급 검색 초기화 버튼 */}
{isAdvancedSearchMode && (
<div className="mt-4 border-t pt-2">
<div>
<Button
variant="ghost"
size="sm"
onClick={() =>
onSearchChange({
search_sabun: undefined,
@ -198,7 +199,7 @@ export function UserToolbar({
search_email: undefined,
})
}
className="text-muted-foreground hover:text-foreground"
className="h-9 text-sm text-muted-foreground hover:text-foreground"
>
</Button>
@ -206,23 +207,6 @@ export function UserToolbar({
)}
</div>
)}
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center justify-between">
{/* 조회 결과 정보 */}
<div className="text-muted-foreground text-sm">
<span className="text-foreground font-medium">{totalCount}</span>
</div>
{/* 액션 버튼들 */}
<div className="flex gap-2">
<Button onClick={onCreateClick} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -2,7 +2,7 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, QueryResult } from "./types";
import { DashboardElement, QueryResult, Position } from "./types";
import { ChartRenderer } from "./charts/ChartRenderer";
import { GRID_CONFIG } from "./gridUtils";
@ -105,7 +105,7 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
// 기사 관리 위젯 임포트
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
import { ListWidget } from "./widgets/ListWidget";
import { MoreHorizontal, X } from "lucide-react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
// 야드 관리 3D 위젯
@ -137,12 +137,11 @@ interface CanvasElementProps {
canvasWidth?: number;
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; // 🔥 다중 드래그 시작
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중
onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void;
onMultiDragEnd?: () => void;
onRemove: (id: string) => void;
onSelect: (id: string | null) => void;
onConfigure?: (element: DashboardElement) => void;
}
/**
@ -167,7 +166,6 @@ export function CanvasElement({
onMultiDragEnd,
onRemove,
onSelect,
onConfigure,
}: CanvasElementProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
@ -226,10 +224,10 @@ export function CanvasElement({
};
setDragStart(startPos);
dragStartRef.current = startPos; // 🔥 ref에도 저장
// 🔥 드래그 시작 시 마우스 위치 초기화 (화면 중간)
lastMouseYRef.current = window.innerHeight / 2;
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
const offsets: Record<string, { x: number; y: number }> = {};
@ -246,10 +244,19 @@ export function CanvasElement({
});
onMultiDragStart(element.id, offsets);
}
e.preventDefault();
},
[element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart],
[
element.id,
element.position.x,
element.position.y,
onSelect,
isSelected,
selectedElements,
allElements,
onMultiDragStart,
],
);
// 리사이즈 핸들 마우스다운
@ -280,22 +287,23 @@ export function CanvasElement({
(e: MouseEvent) => {
if (isDragging) {
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
const isFirstSelectedElement =
!selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
if (isFirstSelectedElement) {
const scrollThreshold = 100;
const viewportHeight = window.innerHeight;
const mouseY = e.clientY;
// 🔥 항상 마우스 위치 업데이트
lastMouseYRef.current = mouseY;
// console.log("🖱️ 마우스 위치 업데이트:", { mouseY, viewportHeight, top: scrollThreshold, bottom: viewportHeight - scrollThreshold });
}
// 🔥 현재 스크롤 위치를 고려한 deltaY 계산
const currentScrollY = window.pageYOffset;
const scrollDelta = currentScrollY - dragStartRef.current.initialScrollY;
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
@ -312,7 +320,7 @@ export function CanvasElement({
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
setTempPosition({ x: snappedX, y: snappedY });
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
onMultiDragMove(element, { x: snappedX, y: snappedY });
@ -410,11 +418,20 @@ export function CanvasElement({
.map((id) => {
const targetElement = allElements.find((el) => el.id === id);
if (!targetElement) return null;
// 현재 요소와의 상대적 위치 유지
const relativeX = targetElement.position.x - dragStart.elementX;
const relativeY = targetElement.position.y - dragStart.elementY;
const newPosition: Position = {
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
y: Math.max(0, finalY + relativeY),
};
if (targetElement.position.z !== undefined) {
newPosition.z = targetElement.position.z;
}
return {
id,
updates: {
@ -425,8 +442,8 @@ export function CanvasElement({
},
};
})
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
.filter((update): update is { id: string; updates: { position: Position } } => update !== null);
if (updates.length > 0) {
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
onUpdateMultiple(updates);
@ -434,7 +451,7 @@ export function CanvasElement({
}
setTempPosition(null);
// 🔥 다중 드래그 종료
if (onMultiDragEnd) {
onMultiDragEnd();
@ -464,7 +481,7 @@ export function CanvasElement({
setIsDragging(false);
setIsResizing(false);
// 🔥 자동 스크롤 정리
autoScrollDirectionRef.current = null;
if (autoScrollFrameRef.current) {
@ -501,32 +518,32 @@ export function CanvasElement({
const autoScrollLoop = (currentTime: number) => {
const viewportHeight = window.innerHeight;
const lastMouseY = lastMouseYRef.current;
// 🔥 스크롤 방향 결정
let shouldScroll = false;
let scrollDirection = 0;
if (lastMouseY < scrollThreshold) {
// 위쪽 영역
shouldScroll = true;
scrollDirection = -scrollSpeed;
// console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold });
} else if (lastMouseY > viewportHeight - scrollThreshold) {
// 아래쪽 영역
shouldScroll = true;
scrollDirection = scrollSpeed;
// console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold });
}
// 🔥 프레임 간격 계산
const deltaTime = currentTime - lastTime;
// 🔥 10ms 간격으로 스크롤
if (shouldScroll && deltaTime >= 10) {
window.scrollBy(0, scrollDirection);
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
lastTime = currentTime;
}
if (lastMouseY < scrollThreshold) {
// 위쪽 영역
shouldScroll = true;
scrollDirection = -scrollSpeed;
// console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold });
} else if (lastMouseY > viewportHeight - scrollThreshold) {
// 아래쪽 영역
shouldScroll = true;
scrollDirection = scrollSpeed;
// console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold });
}
// 🔥 프레임 간격 계산
const deltaTime = currentTime - lastTime;
// 🔥 10ms 간격으로 스크롤
if (shouldScroll && deltaTime >= 10) {
window.scrollBy(0, scrollDirection);
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
lastTime = currentTime;
}
// 계속 반복
animationFrameId = requestAnimationFrame(autoScrollLoop);
@ -671,10 +688,15 @@ export function CanvasElement({
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
const displayPosition = tempPosition || (multiDragOffset && !isDragging ? {
x: element.position.x + multiDragOffset.x,
y: element.position.y + multiDragOffset.y,
} : element.position);
const displayPosition: Position =
tempPosition ||
(multiDragOffset && !isDragging
? {
x: element.position.x + multiDragOffset.x,
y: element.position.y + multiDragOffset.y,
...(element.position.z !== undefined && { z: element.position.z }),
}
: element.position);
const displaySize = tempSize || element.size;
return (
@ -696,18 +718,6 @@ export function CanvasElement({
<div className="flex cursor-move items-center justify-between p-3">
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400"
onClick={() => onConfigure(element)}
title="설정"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
)}
{/* 삭제 버튼 */}
<Button
variant="ghost"

View File

@ -5,7 +5,6 @@ import { ChartConfig, QueryResult } from "./types";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
@ -97,32 +96,17 @@ export function ChartConfigPanel({
// (SELECT에 없어도 WHERE 절에 사용 가능)
setDateColumns(schema.dateColumns);
})
.catch((error) => {
// console.error("❌ 테이블 스키마 조회 실패:", error);
.catch(() => {
// 실패 시 빈 배열 (날짜 필터 비활성화)
setDateColumns([]);
});
}, [query, queryResult, dataSourceType]);
return (
<div className="space-y-6">
<div className="space-y-3">
{/* 데이터 필드 매핑 */}
{queryResult && (
<>
{/* API 응답 미리보기 */}
{queryResult.rows && queryResult.rows.length > 0 && (
<Card className="border-blue-200 bg-blue-50 p-4">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-blue-600" />
<h4 className="font-semibold text-blue-900">API </h4>
</div>
<div className="rounded bg-white p-3 text-xs">
<div className="mb-2 text-gray-600"> {queryResult.totalRows} :</div>
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
</div>
</Card>
)}
{/* 복잡한 타입 경고 */}
{complexColumns.length > 0 && (
<Alert variant="destructive">
@ -150,26 +134,27 @@ export function ChartConfigPanel({
)}
{/* 차트 제목 */}
<div className="space-y-2">
<Label> </Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700"> </Label>
<Input
type="text"
value={currentConfig.title || ""}
onChange={(e) => updateConfig({ title: e.target.value })}
placeholder="차트 제목을 입력하세요"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* X축 설정 */}
<div className="space-y-2">
<Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700">
X축 ()
<span className="ml-1 text-red-500">*</span>
</Label>
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent className="z-[99999]">
@ -183,41 +168,41 @@ export function ChartConfigPanel({
: "";
return (
<SelectItem key={col} value={col}>
<SelectItem key={col} value={col} className="text-xs">
{col}
{previewText && <span className="ml-2 text-xs text-gray-500">(: {previewText})</span>}
{previewText && <span className="ml-1.5 text-[10px] text-gray-500">(: {previewText})</span>}
</SelectItem>
);
})}
</SelectContent>
</Select>
{simpleColumns.length === 0 && (
<p className="text-xs text-red-500"> . JSON Path를 .</p>
<p className="text-[11px] text-red-500"> . JSON Path를 .</p>
)}
</div>
{/* Y축 설정 (다중 선택 가능) */}
<div className="space-y-2">
<Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700">
Y축 () -
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
{(isPieChart || isApiSource) && (
<span className="ml-2 text-xs text-gray-500">( - + )</span>
<span className="ml-1.5 text-[11px] text-gray-500">( - + )</span>
)}
</Label>
<Card className="max-h-60 overflow-y-auto p-3">
<div className="space-y-2">
<div className="max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2">
<div className="space-y-1.5">
{/* 숫자 타입 우선 표시 */}
{numericColumns.length > 0 && (
<>
<div className="mb-2 text-xs font-medium text-green-700"> ()</div>
<div className="mb-1.5 text-[11px] font-medium text-green-700"> ()</div>
{numericColumns.map((col) => {
const isSelected = Array.isArray(currentConfig.yAxis)
? currentConfig.yAxis.includes(col)
: currentConfig.yAxis === col;
return (
<div key={col} className="flex items-center gap-2 rounded border-green-500 bg-green-50 p-2">
<div key={col} className="flex items-center gap-1.5 rounded border-green-500 bg-green-50 p-1.5">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => {
@ -241,10 +226,10 @@ export function ChartConfigPanel({
updateConfig({ yAxis: newYAxis });
}}
/>
<Label className="flex-1 cursor-pointer text-sm font-normal">
<Label className="flex-1 cursor-pointer text-xs font-normal">
<span className="font-medium">{col}</span>
{sampleData[col] !== undefined && (
<span className="ml-2 text-xs text-gray-600">(: {sampleData[col]})</span>
<span className="ml-1.5 text-[10px] text-gray-600">(: {sampleData[col]})</span>
)}
</Label>
</div>
@ -256,8 +241,8 @@ export function ChartConfigPanel({
{/* 기타 간단한 타입 */}
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
<>
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
<div className="mb-2 text-xs font-medium text-gray-600"> </div>
{numericColumns.length > 0 && <div className="my-1.5 border-t"></div>}
<div className="mb-1.5 text-[11px] font-medium text-gray-600"> </div>
{simpleColumns
.filter((col) => !numericColumns.includes(col))
.map((col) => {
@ -266,7 +251,7 @@ export function ChartConfigPanel({
: currentConfig.yAxis === col;
return (
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
<div key={col} className="flex items-center gap-1.5 rounded p-1.5 hover:bg-gray-50">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => {
@ -290,10 +275,10 @@ export function ChartConfigPanel({
updateConfig({ yAxis: newYAxis });
}}
/>
<Label className="flex-1 cursor-pointer text-sm font-normal">
<Label className="flex-1 cursor-pointer text-xs font-normal">
{col}
{sampleData[col] !== undefined && (
<span className="ml-2 text-xs text-gray-500">
<span className="ml-1.5 text-[10px] text-gray-500">
(: {String(sampleData[col]).substring(0, 30)})
</span>
)}
@ -304,11 +289,11 @@ export function ChartConfigPanel({
</>
)}
</div>
</Card>
</div>
{simpleColumns.length === 0 && (
<p className="text-xs text-red-500"> . JSON Path를 .</p>
<p className="text-[11px] text-red-500"> . JSON Path를 .</p>
)}
<p className="text-xs text-gray-500">
<p className="text-[11px] text-gray-500">
: 여러 (: 갤럭시 vs )
</p>
</div>
@ -316,10 +301,10 @@ export function ChartConfigPanel({
<Separator />
{/* 집계 함수 */}
<div className="space-y-2">
<Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700">
<span className="ml-2 text-xs text-gray-500">( )</span>
<span className="ml-1.5 text-[11px] text-gray-500">( )</span>
</Label>
<Select
value={currentConfig.aggregation || "none"}
@ -329,40 +314,54 @@ export function ChartConfigPanel({
})
}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectItem value="none"> - SQL에서 </SelectItem>
<SelectItem value="sum"> (SUM) - </SelectItem>
<SelectItem value="avg"> (AVG) - </SelectItem>
<SelectItem value="count"> (COUNT) - </SelectItem>
<SelectItem value="max"> (MAX) - </SelectItem>
<SelectItem value="min"> (MIN) - </SelectItem>
<SelectItem value="none" className="text-xs">
- SQL에서
</SelectItem>
<SelectItem value="sum" className="text-xs">
(SUM) -
</SelectItem>
<SelectItem value="avg" className="text-xs">
(AVG) -
</SelectItem>
<SelectItem value="count" className="text-xs">
(COUNT) -
</SelectItem>
<SelectItem value="max" className="text-xs">
(MAX) -
</SelectItem>
<SelectItem value="min" className="text-xs">
(MIN) -
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
<p className="text-[11px] text-gray-500">
. (: 부서별 , )
</p>
</div>
{/* 그룹핑 필드 (선택사항) */}
<div className="space-y-2">
<Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700">
()
<span className="ml-2 text-xs text-gray-500">( )</span>
<span className="ml-1.5 text-[11px] text-gray-500">( )</span>
</Label>
<Select
value={currentConfig.groupBy || undefined}
onValueChange={(value) => updateConfig({ groupBy: value })}
>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectItem value="__none__"></SelectItem>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col} value={col}>
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
@ -373,8 +372,8 @@ export function ChartConfigPanel({
<Separator />
{/* 차트 색상 */}
<div className="space-y-2">
<Label> </Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700"> </Label>
<div className="grid grid-cols-4 gap-2">
{[
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본

View File

@ -4,9 +4,7 @@ import React, { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigModal } from "./ElementConfigModal";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
import { ElementConfigSidebar } from "./ElementConfigSidebar";
import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
@ -44,9 +42,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const { refreshMenus } = useMenu();
const [elements, setElements] = useState<DashboardElement[]>([]);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 🔥 다중 선택
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 다중 선택
const [elementCounter, setElementCounter] = useState(0);
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
const [dashboardTitle, setDashboardTitle] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
@ -59,6 +56,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
// 사이드바 상태
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarElement, setSidebarElement] = useState<DashboardElement | null>(null);
// 클립보드 (복사/붙여넣기용)
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
@ -290,8 +291,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
if (selectedElement === id) {
setSelectedElement(null);
}
// 삭제된 요소의 사이드바가 열려있으면 닫기
if (sidebarElement?.id === id) {
setSidebarOpen(false);
setSidebarElement(null);
}
},
[selectedElement],
[selectedElement, sidebarElement],
);
// 키보드 단축키 핸들러들
@ -336,7 +342,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onDelete: handleDeleteSelected,
onCopy: handleCopyElement,
onPaste: handlePasteElement,
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen,
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !sidebarOpen,
});
// 전체 삭제 확인 모달 열기
@ -352,32 +358,32 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setClearConfirmOpen(false);
}, []);
// 요소 설정 모달 열기
const openConfigModal = useCallback((element: DashboardElement) => {
setConfigModalElement(element);
}, []);
// 요소 설정 모달 닫기
const closeConfigModal = useCallback(() => {
setConfigModalElement(null);
}, []);
// 요소 설정 저장
const saveElementConfig = useCallback(
(updatedElement: DashboardElement) => {
updateElement(updatedElement.id, updatedElement);
},
[updateElement],
);
// 리스트 위젯 설정 저장 (Partial 업데이트)
const saveListWidgetConfig = useCallback(
// 리스트/야드 위젯 설정 저장 (Partial 업데이트)
const saveWidgetConfig = useCallback(
(updates: Partial<DashboardElement>) => {
if (configModalElement) {
updateElement(configModalElement.id, updates);
if (sidebarElement) {
updateElement(sidebarElement.id, updates);
}
},
[configModalElement, updateElement],
[sidebarElement, updateElement],
);
// 사이드바 닫기
const handleCloseSidebar = useCallback(() => {
setSidebarOpen(false);
setSidebarElement(null);
setSelectedElement(null);
}, []);
// 사이드바 적용
const handleApplySidebar = useCallback(
(updatedElement: DashboardElement) => {
updateElement(updatedElement.id, updatedElement);
// 사이드바는 열린 채로 유지하여 연속 수정 가능
// 단, sidebarElement도 업데이트해서 최신 상태 반영
setSidebarElement(updatedElement);
},
[updateElement],
);
// 레이아웃 저장
@ -560,14 +566,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onRemoveElement={removeElement}
onSelectElement={(id) => {
setSelectedElement(id);
setSelectedElements([]); // 단일 선택 시 다중 선택 해제
setSelectedElements([]);
// 선택된 요소 찾아서 사이드바 열기
const element = elements.find((el) => el.id === id);
if (element) {
setSidebarElement(element);
setSidebarOpen(true);
}
}}
onSelectMultiple={(ids) => {
console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids);
setSelectedElements(ids);
setSelectedElement(null); // 다중 선택 시 단일 선택 해제
setSelectedElement(null);
setSidebarOpen(false);
setSidebarElement(null);
}}
onConfigureElement={openConfigModal}
onConfigureElement={() => {}}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
@ -575,33 +589,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
</div>
</div>
{/* 요소 설정 모달 */}
{configModalElement && (
<>
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
<ListWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? (
<YardWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</>
)}
{/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */}
<ElementConfigSidebar
element={sidebarElement}
isOpen={sidebarOpen}
onClose={handleCloseSidebar}
onApply={handleApplySidebar}
/>
{/* 저장 모달 */}
<DashboardSaveModal

View File

@ -0,0 +1,371 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
import { QueryEditor } from "./QueryEditor";
import { ChartConfigPanel } from "./ChartConfigPanel";
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface ElementConfigSidebarProps {
element: DashboardElement | null;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
/**
*
* - /
* -
* - "적용"
*/
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
const [dataSource, setDataSource] = useState<ChartDataSource>({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [customTitle, setCustomTitle] = useState<string>("");
const [showHeader, setShowHeader] = useState<boolean>(true);
// 사이드바가 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
setChartConfig(element.chartConfig || {});
setQueryResult(null);
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
}
}, [isOpen, element]);
// Esc 키로 사이드바 닫기
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
} else {
setDataSource({
type: "api",
method: "GET",
refreshInterval: 0,
});
}
setQueryResult(null);
setChartConfig({});
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 차트 설정 변경 처리
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
setChartConfig(newConfig);
}, []);
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
setChartConfig({});
}, []);
// 적용 처리
const handleApply = useCallback(() => {
if (!element) return;
const updatedElement: DashboardElement = {
...element,
dataSource,
chartConfig,
customTitle: customTitle.trim() || undefined,
showHeader,
};
onApply(updatedElement);
// 사이드바는 열린 채로 유지 (연속 수정 가능)
}, [element, dataSource, chartConfig, customTitle, showHeader, onApply]);
// 요소가 없으면 렌더링하지 않음
if (!element) return null;
// 리스트 위젯은 별도 사이드바로 처리
if (element.subtype === "list") {
return (
<ListWidgetConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updatedElement) => {
onApply(updatedElement);
}}
/>
);
}
// 야드 위젯은 사이드바로 처리
if (element.subtype === "yard-management-3d") {
return (
<YardWidgetConfigSidebar
element={element}
isOpen={isOpen}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
onClose={onClose}
/>
);
}
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
element.subtype === "todo" ||
element.subtype === "booking-alert" ||
element.subtype === "maintenance" ||
element.subtype === "document" ||
element.subtype === "risk-alert" ||
element.subtype === "vehicle-status" ||
element.subtype === "vehicle-list" ||
element.subtype === "status-summary" ||
element.subtype === "delivery-status" ||
element.subtype === "delivery-status-summary" ||
element.subtype === "delivery-today-stats" ||
element.subtype === "cargo-list" ||
element.subtype === "customer-issues" ||
element.subtype === "driver-management" ||
element.subtype === "work-history" ||
element.subtype === "transport-stats";
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
const isSelfContainedWidget =
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
// 지도 위젯 (위도/경도 매핑 필요)
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
// 헤더 전용 위젯
const isHeaderOnlyWidget =
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
// 저장 가능 여부 확인
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
const isApiSource = dataSource.type === "api";
const hasYAxis =
chartConfig.yAxis &&
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
const isHeaderChanged = showHeader !== (element.showHeader !== false);
const canApply =
isTitleChanged ||
isHeaderChanged ||
(isSimpleWidget
? queryResult && queryResult.rows.length > 0
: isMapWidget
? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
: queryResult &&
queryResult.rows.length > 0 &&
chartConfig.xAxis &&
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold"></span>
</div>
<span className="text-xs font-semibold text-gray-900">{element.title}</span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
>
<X className="h-3.5 w-3.5 text-gray-500" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 카드 */}
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="space-y-2">
{/* 커스텀 제목 입력 */}
<div>
<input
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="위젯 제목"
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
/>
</div>
{/* 헤더 표시 옵션 */}
<label className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 transition-colors hover:border-gray-300">
<input
type="checkbox"
id="showHeader"
checked={showHeader}
onChange={(e) => setShowHeader(e.target.checked)}
className="text-primary focus:ring-primary h-3 w-3 rounded border-gray-300"
/>
<span className="text-xs text-gray-700"> </span>
</label>
</div>
</div>
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
{!isHeaderOnlyWidget && (
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<Tabs
defaultValue={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
<TabsTrigger
value="database"
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
REST API
</TabsTrigger>
</TabsList>
<TabsContent value="database" className="mt-2 space-y-2">
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
{/* 차트/지도 설정 */}
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
<div className="mt-2">
{isMapWidget ? (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
) : (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
)}
</div>
)}
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
{/* 차트/지도 설정 */}
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
<div className="mt-2">
{isMapWidget ? (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
) : (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
)}
</div>
)}
</TabsContent>
</Tabs>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span className="text-[10px] font-medium text-green-700">
{queryResult.rows.length}
</span>
</div>
)}
</div>
)}
</div>
{/* 푸터: 적용 버튼 */}
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
</button>
<button
onClick={handleApply}
disabled={isHeaderOnlyWidget ? false : !canApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@ -12,7 +12,8 @@ import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Play, Loader2, Database, Code } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
import { applyQueryFilters } from "./utils/queryHelpers";
interface QueryEditorProps {
@ -32,6 +33,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
const [isExecuting, setIsExecuting] = useState(false);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [sampleQueryOpen, setSampleQueryOpen] = useState(false);
// 쿼리 실행
const executeQuery = useCallback(async () => {
@ -155,55 +157,75 @@ ORDER BY 하위부서수 DESC`,
}, []);
return (
<div className="space-y-6">
<div className="space-y-3">
{/* 쿼리 에디터 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-600" />
<h4 className="text-lg font-semibold text-gray-800">SQL </h4>
<div className="flex items-center gap-1.5">
<Database className="h-3.5 w-3.5 text-blue-600" />
<h4 className="text-xs font-semibold text-gray-800">SQL </h4>
</div>
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm">
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
{isExecuting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
<Play className="mr-1.5 h-3 w-3" />
</>
)}
</Button>
</div>
{/* 샘플 쿼리 버튼들 */}
<Card className="p-4">
<div className="flex flex-wrap items-center gap-2">
<Label className="text-sm text-gray-600"> :</Label>
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("users")}>
<Code className="mr-2 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("dept")}>
<Code className="mr-2 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("usersByDate")}>
</Button>
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("usersByPosition")}>
</Button>
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("deptHierarchy")}>
</Button>
</div>
</Card>
{/* 샘플 쿼리 아코디언 */}
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100">
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => insertSampleQuery("users")}
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
>
<Code className="h-3 w-3" />
</button>
<button
onClick={() => insertSampleQuery("dept")}
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
>
<Code className="h-3 w-3" />
</button>
<button
onClick={() => insertSampleQuery("usersByDate")}
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
>
</button>
<button
onClick={() => insertSampleQuery("usersByPosition")}
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
>
</button>
<button
onClick={() => insertSampleQuery("deptHierarchy")}
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
>
</button>
</div>
</CollapsibleContent>
</Collapsible>
{/* SQL 쿼리 입력 영역 */}
<div className="space-y-2">
<Label>SQL </Label>
<div className="space-y-1.5">
<Label className="text-xs">SQL </Label>
<div className="relative">
<Textarea
value={query}
@ -213,14 +235,14 @@ ORDER BY 하위부서수 DESC`,
e.stopPropagation();
}}
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
className="h-40 resize-none font-mono text-sm"
className="h-32 resize-none font-mono text-[11px]"
/>
</div>
</div>
{/* 새로고침 간격 설정 */}
<div className="flex items-center gap-3">
<Label className="text-sm"> :</Label>
<div className="flex items-center gap-2">
<Label className="text-xs"> :</Label>
<Select
value={String(dataSource?.refreshInterval ?? 0)}
onValueChange={(value) =>
@ -232,26 +254,38 @@ ORDER BY 하위부서수 DESC`,
})
}
>
<SelectTrigger className="w-32">
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectItem value="0"></SelectItem>
<SelectItem value="10000">10</SelectItem>
<SelectItem value="30000">30</SelectItem>
<SelectItem value="60000">1</SelectItem>
<SelectItem value="300000">5</SelectItem>
<SelectItem value="600000">10</SelectItem>
<SelectItem value="0" className="text-xs">
</SelectItem>
<SelectItem value="10000" className="text-xs">
10
</SelectItem>
<SelectItem value="30000" className="text-xs">
30
</SelectItem>
<SelectItem value="60000" className="text-xs">
1
</SelectItem>
<SelectItem value="300000" className="text-xs">
5
</SelectItem>
<SelectItem value="600000" className="text-xs">
10
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 오류 메시지 */}
{error && (
<Alert variant="destructive">
<Alert variant="destructive" className="py-2">
<AlertDescription>
<div className="text-sm font-medium"></div>
<div className="mt-1 text-sm">{error}</div>
<div className="text-xs font-medium"></div>
<div className="mt-0.5 text-xs">{error}</div>
</AlertDescription>
</Alert>
)}
@ -259,24 +293,28 @@ ORDER BY 하위부서수 DESC`,
{/* 쿼리 결과 미리보기 */}
{queryResult && (
<Card>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
<div className="border-b border-gray-200 bg-gray-50 px-2 py-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"> </span>
<Badge variant="secondary">{queryResult.rows.length}</Badge>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700"> </span>
<Badge variant="secondary" className="h-4 text-[10px]">
{queryResult.rows.length}
</Badge>
</div>
<span className="text-xs text-gray-500"> : {queryResult.executionTime}ms</span>
<span className="text-[10px] text-gray-500"> : {queryResult.executionTime}ms</span>
</div>
</div>
<div className="p-3">
<div className="p-2">
{queryResult.rows.length > 0 ? (
<div className="max-h-60 overflow-auto">
<div className="max-h-48 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{queryResult.columns.map((col, idx) => (
<TableHead key={idx}>{col}</TableHead>
<TableHead key={idx} className="h-7 text-[11px]">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
@ -284,7 +322,9 @@ ORDER BY 하위부서수 DESC`,
{queryResult.rows.slice(0, 10).map((row, idx) => (
<TableRow key={idx}>
{queryResult.columns.map((col, colIdx) => (
<TableCell key={colIdx}>{String(row[col] ?? "")}</TableCell>
<TableCell key={colIdx} className="py-1 text-[11px]">
{String(row[col] ?? "")}
</TableCell>
))}
</TableRow>
))}
@ -292,13 +332,13 @@ ORDER BY 하위부서수 DESC`,
</Table>
{queryResult.rows.length > 10 && (
<div className="mt-3 text-center text-xs text-gray-500">
<div className="mt-2 text-center text-[10px] text-gray-500">
... {queryResult.rows.length - 10} ( 10 )
</div>
)}
</div>
) : (
<div className="py-8 text-center text-gray-500"> .</div>
<div className="py-6 text-center text-xs text-gray-500"> .</div>
)}
</div>
</Card>

View File

@ -29,13 +29,13 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
const sampleData = queryResult?.rows?.[0] || {};
return (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-gray-800">🗺 </h4>
<div className="space-y-3">
<h4 className="text-xs font-semibold text-gray-800">🗺 </h4>
{/* 쿼리 결과가 없을 때 */}
{!queryResult && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800 text-sm">
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800 text-xs">
💡 SQL .
</div>
</div>
@ -45,27 +45,27 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
{queryResult && (
<>
{/* 지도 제목 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> </label>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700"> </label>
<input
type="text"
value={currentConfig.title || ''}
onChange={(e) => updateConfig({ title: e.target.value })}
placeholder="차량 위치 지도"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
/>
</div>
{/* 위도 컬럼 설정 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
(Latitude)
<span className="text-red-500 ml-1">*</span>
</label>
<select
value={currentConfig.latitudeColumn || ''}
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
>
<option value=""></option>
{availableColumns.map((col) => (
@ -77,15 +77,15 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</div>
{/* 경도 컬럼 설정 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
(Longitude)
<span className="text-red-500 ml-1">*</span>
</label>
<select
value={currentConfig.longitudeColumn || ''}
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
>
<option value=""></option>
{availableColumns.map((col) => (
@ -97,14 +97,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</div>
{/* 라벨 컬럼 (선택사항) */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
( )
</label>
<select
value={currentConfig.labelColumn || ''}
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
>
<option value=""> ()</option>
{availableColumns.map((col) => (
@ -116,14 +116,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</div>
{/* 상태 컬럼 (선택사항) */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700">
( )
</label>
<select
value={currentConfig.statusColumn || ''}
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
>
<option value=""> ()</option>
{availableColumns.map((col) => (
@ -136,7 +136,7 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
{/* 설정 미리보기 */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">📋 </div>
<div className="text-xs font-medium text-gray-700 mb-2">📋 </div>
<div className="text-xs text-muted-foreground space-y-1">
<div><strong>:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
<div><strong>:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
@ -149,7 +149,7 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
{/* 필수 필드 확인 */}
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="text-red-800 text-sm">
<div className="text-red-800 text-xs">
.
</div>
</div>

View File

@ -2,7 +2,6 @@
import React, { useState, useEffect } from "react";
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -314,55 +313,48 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API </h3>
<p className="mt-1 text-sm text-gray-600"> API에서 </p>
</div>
{/* 외부 커넥션 선택 */}
{apiConnections.length > 0 && (
<Card className="space-y-4 p-4">
<div>
<Label className="text-sm font-medium text-gray-700"> ()</Label>
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="저장된 커넥션 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="manual"> </SelectItem>
{apiConnections.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name}
{conn.description && <span className="ml-2 text-xs text-gray-500">({conn.description})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> REST API </p>
</div>
</Card>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700"> ()</Label>
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="저장된 커넥션 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="manual" className="text-xs">
</SelectItem>
{apiConnections.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
{conn.connection_name}
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-gray-500"> REST API </p>
</div>
)}
{/* API URL */}
<Card className="space-y-4 p-4">
<div>
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
<Input
type="url"
placeholder="https://api.example.com/data"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="mt-2"
/>
<p className="mt-1 text-xs text-gray-500">GET API </p>
</div>
</Card>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
<Input
type="url"
placeholder="https://api.example.com/data"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="h-8 text-xs"
/>
<p className="text-[11px] text-gray-500">GET API </p>
</div>
{/* 쿼리 파라미터 */}
<Card className="space-y-4 p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700">URL </Label>
<Button variant="outline" size="sm" onClick={addQueryParam}>
<Label className="text-xs font-medium text-gray-700">URL </Label>
<Button variant="outline" size="sm" onClick={addQueryParam} className="h-6 text-[11px]">
<Plus className="mr-1 h-3 w-3" />
</Button>
@ -371,39 +363,42 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
{(() => {
const params = normalizeQueryParams();
return params.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1.5">
{params.map((param) => (
<div key={param.id} className="flex gap-2">
<div key={param.id} className="flex gap-1.5">
<Input
placeholder="key"
value={param.key}
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
className="flex-1"
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="value"
value={param.value}
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
className="flex-1"
className="h-7 flex-1 text-xs"
/>
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(param.id)}>
<X className="h-4 w-4" />
</Button>
<button
onClick={() => removeQueryParam(param.id)}
className="flex h-7 w-7 items-center justify-center rounded hover:bg-gray-100"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : (
<p className="py-2 text-center text-sm text-gray-500"> </p>
<p className="py-2 text-center text-[11px] text-gray-500"> </p>
);
})()}
<p className="text-xs text-gray-500">: category=electronics, limit=10</p>
</Card>
<p className="text-[11px] text-gray-500">: category=electronics, limit=10</p>
</div>
{/* 헤더 */}
<Card className="space-y-4 p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Label className="text-xs font-medium text-gray-700"> </Label>
<Button variant="outline" size="sm" onClick={addHeader}>
<Plus className="mr-1 h-3 w-3" />
@ -467,22 +462,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
<p className="py-2 text-center text-sm text-gray-500"> </p>
);
})()}
</Card>
</div>
{/* JSON Path */}
<Card className="space-y-2 p-4">
<Label className="text-sm font-medium text-gray-700">JSON Path ()</Label>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">JSON Path ()</Label>
<Input
placeholder="data.results"
value={dataSource.jsonPath || ""}
onChange={(e) => onChange({ jsonPath: e.target.value })}
/>
<p className="text-xs text-gray-500">
<p className="text-[11px] text-gray-500">
JSON (: data.results, items, response.data)
<br />
</p>
</Card>
</div>
{/* 테스트 버튼 */}
<div className="flex justify-end">
@ -503,7 +498,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
{/* 테스트 오류 */}
{testError && (
<Card className="border-red-200 bg-red-50 p-4">
<div className="rounded bg-red-50 px-2 py-2">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
<div>
@ -511,18 +506,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
<div className="mt-1 text-sm text-red-700">{testError}</div>
</div>
</div>
</Card>
</div>
)}
{/* 테스트 결과 */}
{testResult && (
<Card className="border-green-200 bg-green-50 p-4">
<div className="rounded bg-green-50 px-2 py-2">
<div className="mb-2 text-sm font-medium text-green-800">API </div>
<div className="space-y-1 text-xs text-green-700">
<div> {testResult.rows.length} </div>
<div>: {testResult.columns.join(", ")}</div>
</div>
</Card>
</div>
)}
</div>
);

View File

@ -1,12 +1,11 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ChartDataSource } from "../types";
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ExternalLink, Database, Server } from "lucide-react";
interface DatabaseConfigProps {
@ -20,6 +19,7 @@ interface DatabaseConfigProps {
* -
*/
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
const router = useRouter();
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -49,93 +49,87 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
const selectedConnection = connections.find((conn) => String(conn.id) === dataSource.externalConnectionId);
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800">2단계: 데이터베이스 </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
<div className="space-y-3">
{/* 현재 DB vs 외부 DB 선택 */}
<Card className="p-4">
<Label className="mb-3 block text-sm font-medium text-gray-700"> </Label>
<div className="grid grid-cols-2 gap-3">
<Button
variant={dataSource.connectionType === "current" ? "default" : "outline"}
className="h-auto justify-start py-3"
<div>
<Label className="mb-2 block text-xs font-medium text-gray-700"> </Label>
<div className="flex gap-2">
<button
onClick={() => {
onChange({ connectionType: "current", externalConnectionId: undefined });
}}
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
dataSource.connectionType === "current"
? "bg-primary border-primary text-white"
: "border-gray-200 bg-white hover:bg-gray-50"
}`}
>
<Database className="mr-2 h-4 w-4" />
<div className="text-left">
<div className="font-medium"> </div>
<div className="text-xs opacity-80"> DB</div>
</div>
</Button>
<Database className="h-3 w-3" />
DB
</button>
<Button
variant={dataSource.connectionType === "external" ? "default" : "outline"}
className="h-auto justify-start py-3"
<button
onClick={() => {
onChange({ connectionType: "external" });
}}
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
dataSource.connectionType === "external"
? "bg-primary border-primary text-white"
: "border-gray-200 bg-white hover:bg-gray-50"
}`}
>
<Server className="mr-2 h-4 w-4" />
<div className="text-left">
<div className="font-medium"> </div>
<div className="text-xs opacity-80"> </div>
</div>
</Button>
<Server className="h-3 w-3" />
DB
</button>
</div>
</Card>
</div>
{/* 외부 DB 선택 시 커넥션 목록 */}
{dataSource.connectionType === "external" && (
<Card className="space-y-4 p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Button
variant="ghost"
size="sm"
<Label className="text-xs font-medium text-gray-700"> </Label>
<button
onClick={() => {
window.open("/admin/external-connections", "_blank");
router.push("/admin/external-connections");
}}
className="text-xs"
className="flex items-center gap-1 text-[11px] text-blue-600 transition-colors hover:text-blue-700"
>
<ExternalLink className="mr-1 h-3 w-3" />
<ExternalLink className="h-3 w-3" />
</Button>
</button>
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
<span className="ml-2 text-sm text-gray-600"> ...</span>
<div className="flex items-center justify-center py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
<span className="ml-2 text-xs text-gray-600"> ...</span>
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="text-sm text-red-800"> {error}</div>
<Button variant="ghost" size="sm" onClick={loadExternalConnections} className="mt-2 text-xs">
<div className="rounded bg-red-50 px-2 py-1.5">
<div className="text-xs text-red-800">{error}</div>
<button
onClick={loadExternalConnections}
className="mt-1 text-[11px] text-red-600 underline hover:no-underline"
>
</Button>
</button>
</div>
)}
{!loading && !error && connections.length === 0 && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-center">
<div className="mb-2 text-sm text-yellow-800"> </div>
<Button
variant="outline"
size="sm"
<div className="rounded bg-yellow-50 px-2 py-2 text-center">
<div className="mb-1 text-xs text-yellow-800"> </div>
<button
onClick={() => {
window.open("/admin/external-connections", "_blank");
router.push("/admin/external-connections");
}}
className="text-[11px] text-yellow-700 underline hover:no-underline"
>
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</button>
</div>
)}
@ -147,15 +141,15 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
onChange({ externalConnectionId: value });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="커넥션 선택하세요" />
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="커넥션 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{connections.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
<div className="flex items-center gap-2">
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
<div className="flex items-center gap-1.5">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
<span className="text-[10px] text-gray-500">({conn.db_type.toUpperCase()})</span>
</div>
</SelectItem>
))}
@ -163,27 +157,17 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
</Select>
{selectedConnection && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="space-y-1 text-xs text-gray-600">
<div>
<span className="font-medium">:</span> {selectedConnection.connection_name}
</div>
<div>
<span className="font-medium">:</span> {selectedConnection.db_type.toUpperCase()}
</div>
<div className="space-y-0.5 rounded bg-gray-50 px-2 py-1.5 text-[11px] text-gray-600">
<div>
<span className="font-medium">:</span> {selectedConnection.connection_name}
</div>
<div>
<span className="font-medium">:</span> {selectedConnection.db_type.toUpperCase()}
</div>
</div>
)}
</>
)}
</Card>
)}
{/* 다음 단계 안내 */}
{(dataSource.connectionType === "current" ||
(dataSource.connectionType === "external" && dataSource.externalConnectionId)) && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="text-sm text-blue-800"> . SQL .</div>
</div>
)}
</div>

View File

@ -43,6 +43,7 @@ export type ElementSubtype =
export interface Position {
x: number;
y: number;
z?: number;
}
export interface Size {
@ -255,7 +256,7 @@ export interface ChartDataset {
// 리스트 위젯 설정
export interface ListWidgetConfig {
columnMode: "auto" | "manual"; // 컬럼 설정 방식 (자동 or 수동)
columnMode?: "auto" | "manual"; // [Deprecated] 더 이상 사용하지 않음 (하위 호환성을 위해 유지)
viewMode: "table" | "card"; // 뷰 모드 (테이블 or 카드) (기본: table)
columns: ListColumn[]; // 컬럼 정의
pageSize: number; // 페이지당 행 수 (기본: 10)

View File

@ -1,14 +1,13 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
import { DashboardElement, QueryResult, ListColumn } from "../types";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card } from "@/components/ui/card";
interface ListWidgetProps {
element: DashboardElement;
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
}
/**
@ -17,7 +16,7 @@ interface ListWidgetProps {
* -
* - , ,
*/
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
export function ListWidget({ element }: ListWidgetProps) {
const [data, setData] = useState<QueryResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -53,7 +52,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
if (element.dataSource.queryParams) {
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
if (key && value) {
params.append(key, value);
params.append(key, String(value));
}
});
}
@ -114,13 +113,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
parseInt(element.dataSource.externalConnectionId),
element.dataSource.query,
);
if (!externalResult.success) {
if (!externalResult.success || !externalResult.data) {
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
}
const resultData = externalResult.data as unknown as {
columns: string[];
rows: Record<string, unknown>[];
rowCount: number;
};
queryResult = {
columns: externalResult.data.columns,
rows: externalResult.data.rows,
totalRows: externalResult.data.rowCount,
columns: resultData.columns,
rows: resultData.rows,
totalRows: resultData.rowCount,
executionTime: 0,
};
} else {
@ -154,13 +159,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const interval = setInterval(loadData, refreshInterval);
return () => clearInterval(interval);
}
}, [
element.dataSource?.query,
element.dataSource?.connectionType,
element.dataSource?.externalConnectionId,
element.dataSource?.endpoint,
element.dataSource?.refreshInterval,
]);
}, [element.dataSource]);
// 로딩 중
if (isLoading) {
@ -192,23 +191,22 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
<div className="text-center">
<div className="mb-2 text-4xl">📋</div>
<div className="text-sm font-medium text-gray-700"> </div>
<div className="mt-1 text-xs text-gray-500"> </div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
</div>
);
}
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
const displayColumns =
const displayColumns: ListColumn[] =
config.columns.length > 0
? config.columns
: data.columns.map((col) => ({
id: col,
name: col,
dataKey: col,
label: col,
field: col,
visible: true,
align: "left" as const,
}));
// 페이지네이션
@ -239,7 +237,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
style={{ width: col.width ? `${col.width}px` : undefined }}
>
{col.label || col.name}
{col.label}
</TableHead>
))}
</TableRow>
@ -265,7 +263,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
key={col.id}
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
>
{String(row[col.dataKey || col.field] ?? "")}
{String(row[col.field] ?? "")}
</TableCell>
))}
</TableRow>
@ -295,11 +293,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
.filter((col) => col.visible)
.map((col) => (
<div key={col.id}>
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
<div className="text-xs font-medium text-gray-500">{col.label}</div>
<div
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
>
{String(row[col.dataKey || col.field] ?? "")}
{String(row[col.field] ?? "")}
</div>
</div>
))}

View File

@ -0,0 +1,265 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
import { ApiConfig } from "../data-sources/ApiConfig";
import { QueryEditor } from "../QueryEditor";
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "./list-widget/ListTableOptions";
interface ListWidgetConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
/**
*
*/
export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) {
const [title, setTitle] = useState(element.title || "📋 리스트");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
element.listConfig || {
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
},
);
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen) {
setTitle(element.title || "📋 리스트");
if (element.dataSource) {
setDataSource(element.dataSource);
}
if (element.listConfig) {
setListConfig(element.listConfig);
}
}
}, [isOpen, element]);
// Esc 키로 닫기
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
} else {
setDataSource({
type: "api",
method: "GET",
refreshInterval: 0,
});
}
setQueryResult(null);
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 쿼리 실행 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
// 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지)
setListConfig((prev) => {
const existingFields = prev.columns.map((col) => col.field);
const newColumns = result.columns
.filter((col) => !existingFields.includes(col))
.map((col, idx) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
align: "left" as const,
}));
return {
...prev,
columns: [...prev.columns, ...newColumns],
};
});
}, []);
// 컬럼 설정 변경
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
setListConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 적용
const handleApply = useCallback(() => {
const updatedElement: DashboardElement = {
...element,
title,
dataSource,
listConfig,
};
onApply(updatedElement);
}, [element, title, dataSource, listConfig, onApply]);
// 저장 가능 여부
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">📋</span>
</div>
<span className="text-xs font-semibold text-gray-900"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
>
<X className="h-3.5 w-3.5 text-gray-500" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 */}
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="space-y-2">
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="리스트 이름"
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
/>
</div>
</div>
</div>
{/* 데이터 소스 */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<Tabs
defaultValue={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
<TabsTrigger
value="database"
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
REST API
</TabsTrigger>
</TabsList>
<TabsContent value="database" className="mt-2 space-y-2">
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
</TabsContent>
</Tabs>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span className="text-[10px] font-medium text-green-700">{queryResult.rows.length} </span>
</div>
)}
</div>
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
{queryResult && (
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<UnifiedColumnEditor
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
</div>
)}
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
{listConfig.columns.length > 0 && (
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
</div>
)}
</div>
{/* 푸터: 적용 버튼 */}
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
</button>
<button
onClick={handleApply}
disabled={!canApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DashboardElement } from "../types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface YardWidgetConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (updates: Partial<DashboardElement>) => void;
}
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
useEffect(() => {
if (isOpen) {
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
}
}, [isOpen, element]);
const handleApply = () => {
onApply({
customTitle,
showHeader,
});
onClose();
};
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">🏗</span>
</div>
<span className="text-xs font-semibold text-gray-900"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
>
<X className="h-3.5 w-3.5 text-gray-500" />
</button>
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 위젯 제목 */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
<p className="mt-1 text-[10px] text-gray-500"> 제목: 야드 3D</p>
</div>
{/* 헤더 표시 */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<RadioGroup
value={showHeader ? "show" : "hide"}
onValueChange={(value) => setShowHeader(value === "show")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
</div>
</div>
</div>
{/* 푸터 */}
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
</button>
<button
onClick={handleApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors"
>
</button>
</div>
</div>
);
}

View File

@ -1,19 +1,16 @@
"use client";
import React, { useState } from "react";
import { ListColumn } from "../../types";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { ListColumn, QueryResult, ListWidgetConfig } from "../../types";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GripVertical } from "lucide-react";
interface ColumnSelectorProps {
availableColumns: string[];
selectedColumns: ListColumn[];
sampleData: Record<string, any>;
onChange: (columns: ListColumn[]) => void;
queryResult: QueryResult;
config: ListWidgetConfig;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
@ -23,15 +20,18 @@ interface ColumnSelectorProps {
* - , ,
* -
*/
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSelectorProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const availableColumns = queryResult.columns;
const selectedColumns = config.columns || [];
const sampleData = queryResult.rows[0] || {};
// 컬럼 선택/해제
const handleToggle = (field: string) => {
const exists = selectedColumns.find((col) => col.field === field);
if (exists) {
onChange(selectedColumns.filter((col) => col.field !== field));
onConfigChange({ columns: selectedColumns.filter((col) => col.field !== field) });
} else {
const newCol: ListColumn = {
id: `col_${selectedColumns.length}`,
@ -40,18 +40,22 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
align: "left",
visible: true,
};
onChange([...selectedColumns, newCol]);
onConfigChange({ columns: [...selectedColumns, newCol] });
}
};
// 컬럼 라벨 변경
const handleLabelChange = (field: string, label: string) => {
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
onConfigChange({
columns: selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)),
});
};
// 정렬 방향 변경
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
onConfigChange({
columns: selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)),
});
};
// 드래그 시작
@ -64,40 +68,29 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
e.preventDefault();
if (draggedIndex === null || draggedIndex === hoverIndex) return;
setDragOverIndex(hoverIndex);
const newColumns = [...selectedColumns];
const draggedItem = newColumns[draggedIndex];
newColumns.splice(draggedIndex, 1);
newColumns.splice(hoverIndex, 0, draggedItem);
setDraggedIndex(hoverIndex);
onChange(newColumns);
onConfigChange({ columns: newColumns });
};
// 드롭
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDraggedIndex(null);
setDragOverIndex(null);
};
// 드래그 종료
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
return (
<Card className="p-4">
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<p className="text-sm text-gray-600">
. .
</p>
</div>
<div className="space-y-3">
<div>
<div className="space-y-1.5">
{/* 선택된 컬럼을 먼저 순서대로 표시 */}
{selectedColumns.map((selectedCol, columnIndex) => {
const field = selectedCol.field;
@ -127,52 +120,74 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
handleDragEnd();
e.currentTarget.style.cursor = "grab";
}}
className={`rounded-lg border p-4 transition-all ${
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
className={`group relative rounded-md border transition-all ${
isSelected
? "border-primary/40 bg-primary/5 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
draggedIndex === columnIndex ? "opacity-50" : ""
draggedIndex === columnIndex ? "scale-95 opacity-50" : ""
}`}
>
<div className="mb-3 flex items-start gap-3">
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
<div className="flex-1">
<div className="flex items-center gap-2">
<GripVertical className={`h-4 w-4 ${isDraggable ? "text-blue-500" : "text-gray-400"}`} />
<span className="font-medium text-gray-700">{field}</span>
{previewText && <span className="text-xs text-gray-500">(: {previewText})</span>}
{/* 헤더 */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggle(field)}
className="h-3.5 w-3.5 shrink-0"
/>
<GripVertical
className={`h-3.5 w-3.5 shrink-0 transition-colors ${
isDraggable ? "group-hover:text-primary text-gray-400" : "text-gray-300"
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="truncate text-[11px] font-medium text-gray-900">{field}</span>
{previewText && <span className="shrink-0 text-[9px] text-gray-400">: {previewText}</span>}
</div>
</div>
</div>
{/* 설정 영역 */}
{isSelected && selectedCol && (
<div className="ml-7 grid grid-cols-2 gap-3">
{/* 컬럼명 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={selectedCol.label}
onChange={(e) => handleLabelChange(field, e.target.value)}
placeholder="컬럼명"
className="mt-1"
/>
</div>
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
<div className="grid grid-cols-2 gap-1.5">
{/* 표시 이름 */}
<div className="min-w-0">
<Input
value={selectedCol.label}
onChange={(e) => handleLabelChange(field, e.target.value)}
placeholder="표시 이름"
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
style={{ fontSize: "10px" }}
/>
</div>
{/* 정렬 방향 */}
<div>
<Label className="text-xs"></Label>
<Select
value={selectedCol.align}
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
{/* 정렬 */}
<div className="min-w-0">
<Select
value={selectedCol.align}
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
>
<SelectTrigger
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-[4rem]">
<SelectItem value="left" className="py-1 text-[10px]">
</SelectItem>
<SelectItem value="center" className="py-1 text-[10px]">
</SelectItem>
<SelectItem value="right" className="py-1 text-[10px]">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
@ -191,18 +206,23 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
? JSON.stringify(preview).substring(0, 30)
: String(preview).substring(0, 30)
: "";
const isSelected = false;
const isDraggable = false;
return (
<div key={field} className={`rounded-lg border border-gray-200 p-4 transition-all`}>
<div className="mb-3 flex items-start gap-3">
<Checkbox checked={false} onCheckedChange={() => handleToggle(field)} className="mt-1" />
<div className="flex-1">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-700">{field}</span>
{previewText && <span className="text-xs text-gray-500">(: {previewText})</span>}
<div
key={field}
className="group rounded-md border border-gray-200 bg-white transition-all hover:border-gray-300 hover:shadow-sm"
>
<div className="flex items-center gap-2 px-2.5 py-2">
<Checkbox
checked={false}
onCheckedChange={() => handleToggle(field)}
className="h-3.5 w-3.5 shrink-0"
/>
<GripVertical className="h-3.5 w-3.5 shrink-0 text-gray-300" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="truncate text-[11px] font-medium text-gray-600">{field}</span>
{previewText && <span className="shrink-0 text-[9px] text-gray-400">: {previewText}</span>}
</div>
</div>
</div>
@ -212,10 +232,11 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
</div>
{selectedColumns.length === 0 && (
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
1
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
<span className="text-amber-600"></span>
<span className="text-[10px] text-amber-700"> 1 </span>
</div>
)}
</Card>
</div>
);
}

View File

@ -2,70 +2,42 @@
import React from "react";
import { ListWidgetConfig } from "../../types";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Input } from "@/components/ui/input";
interface ListTableOptionsProps {
config: ListWidgetConfig;
onChange: (updates: Partial<ListWidgetConfig>) => void;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
*
* - , ,
*/
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
export function ListTableOptions({ config, onConfigChange }: ListTableOptionsProps) {
return (
<Card className="p-4">
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<p className="text-sm text-gray-600"> </p>
</div>
<div className="space-y-6">
<div>
<div className="space-y-3">
{/* 뷰 모드 */}
<div>
<Label className="mb-2 block text-sm font-medium"> </Label>
<Label className="mb-1 block text-[9px] font-medium text-gray-600"> </Label>
<RadioGroup
value={config.viewMode}
onValueChange={(value: "table" | "card") => onChange({ viewMode: value })}
onValueChange={(value: "table" | "card") => onConfigChange({ viewMode: value })}
className="flex items-center gap-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="table" id="table" />
<Label htmlFor="table" className="cursor-pointer font-normal">
📊 ()
<div className="flex items-center gap-1.5">
<RadioGroupItem value="table" id="table" className="h-3 w-3" />
<Label htmlFor="table" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="card" id="card" />
<Label htmlFor="card" className="cursor-pointer font-normal">
🗂
</Label>
</div>
</RadioGroup>
</div>
{/* 컬럼 모드 */}
<div>
<Label className="mb-2 block text-sm font-medium"> </Label>
<RadioGroup
value={config.columnMode}
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="auto" />
<Label htmlFor="auto" className="cursor-pointer font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="manual" id="manual" />
<Label htmlFor="manual" className="cursor-pointer font-normal">
( )
<div className="flex items-center gap-1.5">
<RadioGroupItem value="card" id="card" className="h-3 w-3" />
<Label htmlFor="card" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
@ -74,94 +46,122 @@ export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
{/* 카드 뷰 컬럼 수 */}
{config.viewMode === "card" && (
<div>
<Label className="mb-2 block text-sm font-medium"> </Label>
<Label className="mb-1 block text-[9px] font-medium text-gray-600"> </Label>
<Input
type="number"
min="1"
max="6"
value={config.cardColumns || 3}
onChange={(e) => onChange({ cardColumns: parseInt(e.target.value) || 3 })}
className="w-full"
onChange={(e) => onConfigChange({ cardColumns: parseInt(e.target.value) || 3 })}
className="h-6 w-full px-1.5 text-[11px]"
/>
<p className="mt-1 text-xs text-gray-500"> (1-6)</p>
<p className="mt-0.5 text-[9px] text-gray-500"> (1-6)</p>
</div>
)}
{/* 페이지 크기 */}
<div>
<Label className="mb-2 block text-sm font-medium"> </Label>
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
<SelectTrigger>
<Label className="mb-1 block text-[9px] font-medium text-gray-600"> </Label>
<Select
value={String(config.pageSize)}
onValueChange={(value) => onConfigChange({ pageSize: parseInt(value) })}
>
<SelectTrigger className="h-6 px-1.5 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="5" className="text-[11px]">
5
</SelectItem>
<SelectItem value="10" className="text-[11px]">
10
</SelectItem>
<SelectItem value="20" className="text-[11px]">
20
</SelectItem>
<SelectItem value="50" className="text-[11px]">
50
</SelectItem>
<SelectItem value="100" className="text-[11px]">
100
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 기능 활성화 */}
<div className="space-y-3">
<Label className="text-sm font-medium"></Label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="enablePagination"
checked={config.enablePagination}
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
/>
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
<div>
<Label className="mb-1 block text-[9px] font-medium text-gray-600"></Label>
<RadioGroup
value={config.enablePagination ? "enabled" : "disabled"}
onValueChange={(value) => onConfigChange({ enablePagination: value === "enabled" })}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="enabled" id="pagination-enabled" className="h-3 w-3" />
<Label htmlFor="pagination-enabled" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="disabled" id="pagination-disabled" className="h-3 w-3" />
<Label htmlFor="pagination-disabled" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
</div>
{/* 스타일 */}
<div className="space-y-3">
<Label className="text-sm font-medium"></Label>
<div className="space-y-2">
{config.viewMode === "table" && (
<>
<div className="flex items-center gap-2">
<Checkbox
id="showHeader"
checked={config.showHeader}
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
/>
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="stripedRows"
checked={config.stripedRows}
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
/>
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
</Label>
</div>
</>
)}
<div className="flex items-center gap-2">
<Checkbox
id="compactMode"
checked={config.compactMode}
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
/>
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
( )
</Label>
</div>
{/* 헤더 표시 */}
{config.viewMode === "table" && (
<div>
<Label className="mb-1 block text-[9px] font-medium text-gray-600"> </Label>
<RadioGroup
value={config.showHeader ? "show" : "hide"}
onValueChange={(value) => onConfigChange({ showHeader: value === "show" })}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
</div>
</div>
)}
{/* 줄무늬 행 */}
{config.viewMode === "table" && (
<div>
<Label className="mb-1 block text-[9px] font-medium text-gray-600"> </Label>
<RadioGroup
value={config.stripedRows ? "enabled" : "disabled"}
onValueChange={(value) => onConfigChange({ stripedRows: value === "enabled" })}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="enabled" id="striped-enabled" className="h-3 w-3" />
<Label htmlFor="striped-enabled" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="disabled" id="striped-disabled" className="h-3 w-3" />
<Label htmlFor="striped-disabled" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
</div>
)}
</div>
</Card>
</div>
);
}

View File

@ -1,18 +1,14 @@
"use client";
import React, { useState } from "react";
import { ListColumn } from "../../types";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { ListColumn, ListWidgetConfig } from "../../types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, GripVertical } from "lucide-react";
interface ManualColumnEditorProps {
availableFields: string[];
columns: ListColumn[];
onChange: (columns: ListColumn[]) => void;
config: ListWidgetConfig;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
@ -21,30 +17,30 @@ interface ManualColumnEditorProps {
* -
* -
*/
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEditorProps) {
const columns = config.columns || [];
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 새 컬럼 추가
const handleAddColumn = () => {
const newCol: ListColumn = {
id: `col_${Date.now()}`,
label: `컬럼 ${columns.length + 1}`,
field: availableFields[0] || "",
field: "",
align: "left",
visible: true,
};
onChange([...columns, newCol]);
onConfigChange({ columns: [...columns, newCol] });
};
// 컬럼 삭제
const handleRemove = (id: string) => {
onChange(columns.filter((col) => col.id !== id));
onConfigChange({ columns: columns.filter((col) => col.id !== id) });
};
// 컬럼 속성 업데이트
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
onConfigChange({ columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)) });
};
// 드래그 시작
@ -57,46 +53,41 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
e.preventDefault();
if (draggedIndex === null || draggedIndex === hoverIndex) return;
setDragOverIndex(hoverIndex);
const newColumns = [...columns];
const draggedItem = newColumns[draggedIndex];
newColumns.splice(draggedIndex, 1);
newColumns.splice(hoverIndex, 0, draggedItem);
setDraggedIndex(hoverIndex);
onChange(newColumns);
onConfigChange({ columns: newColumns });
};
// 드롭
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDraggedIndex(null);
setDragOverIndex(null);
};
// 드래그 종료
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
return (
<Card className="p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<p className="text-sm text-gray-600">
. .
</p>
</div>
<Button onClick={handleAddColumn} size="sm" className="gap-2">
<Plus className="h-4 w-4" />
<div>
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<p className="text-[10px] text-gray-500"> </p>
<button
onClick={handleAddColumn}
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
>
<Plus className="h-3 w-3" />
</Button>
</button>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
{columns.map((col, index) => (
<div
key={col.id}
@ -111,82 +102,72 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
handleDragEnd();
e.currentTarget.style.cursor = "grab";
}}
className={`cursor-grab rounded-lg border border-gray-200 bg-gray-50 p-4 transition-all active:cursor-grabbing ${
draggedIndex === index ? "opacity-50" : ""
}`}
className={`group relative rounded-md border border-gray-200 bg-white shadow-sm transition-all hover:border-gray-300 hover:shadow-sm ${
draggedIndex === index ? "scale-95 opacity-50" : ""
} cursor-grab active:cursor-grabbing`}
>
<div className="mb-3 flex items-center gap-2">
<GripVertical className="h-4 w-4 text-blue-500" />
<span className="font-medium text-gray-700"> {index + 1}</span>
<Button
{/* 헤더 */}
<div className="flex items-center gap-2 px-2.5 py-2">
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
<span className="text-[11px] font-medium text-gray-900"> {index + 1}</span>
<button
onClick={() => handleRemove(col.id)}
size="sm"
variant="ghost"
className="ml-auto text-red-600 hover:text-red-700"
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
<Trash2 className="h-3 w-3" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{/* 컬럼명 */}
<div>
<Label className="text-xs"> *</Label>
<Input
value={col.label}
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
placeholder="예: 사용자 이름"
className="mt-1"
/>
</div>
{/* 설정 영역 */}
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
<div className="flex flex-col gap-1.5">
{/* 표시 이름 */}
<div className="min-w-0">
<Input
value={col.label}
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
placeholder="표시 이름"
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
style={{ fontSize: "10px" }}
/>
</div>
{/* 데이터 필드 */}
<div>
<Label className="text-xs"> *</Label>
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{availableFields.map((field) => (
<SelectItem key={field} value={field}>
{field}
{/* 데이터 필드 */}
<div className="min-w-0">
<Input
value={col.field}
onChange={(e) => handleUpdate(col.id, { field: e.target.value })}
placeholder="데이터 필드"
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
style={{ fontSize: "10px" }}
/>
</div>
{/* 정렬 */}
<div className="min-w-0">
<Select
value={col.align}
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
>
<SelectTrigger
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-[4rem]">
<SelectItem value="left" className="py-1 text-[10px]">
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 방향 */}
<div>
<Label className="text-xs"></Label>
<Select
value={col.align}
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={col.width || ""}
onChange={(e) =>
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
}
placeholder="자동"
className="mt-1"
/>
<SelectItem value="center" className="py-1 text-[10px]">
</SelectItem>
<SelectItem value="right" className="py-1 text-[10px]">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
@ -194,13 +175,18 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
</div>
{columns.length === 0 && (
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
<div className="text-sm text-gray-600"> </div>
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
<Plus className="h-4 w-4" />
</Button>
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
<span className="text-amber-600"></span>
<span className="text-[10px] text-amber-700"> </span>
<button
onClick={handleAddColumn}
className="bg-primary hover:bg-primary/90 ml-auto flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
>
<Plus className="h-3 w-3" />
</button>
</div>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,219 @@
"use client";
import React, { useState } from "react";
import { ListColumn, ListWidgetConfig, QueryResult } from "../../types";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedColumnEditorProps {
queryResult: QueryResult | null;
config: ListWidgetConfig;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
*
* -
* - (, , )
* -
*/
export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: UnifiedColumnEditorProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const columns = config.columns || [];
const sampleData = queryResult?.rows[0] || {};
// 컬럼 추가
const handleAddColumn = () => {
const newColumn: ListColumn = {
id: `col_${Date.now()}`,
field: "",
label: "",
visible: true,
align: "left",
};
onConfigChange({
columns: [...columns, newColumn],
});
};
// 컬럼 삭제
const handleRemove = (id: string) => {
onConfigChange({
columns: columns.filter((col) => col.id !== id),
});
};
// 컬럼 업데이트
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
onConfigChange({
columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)),
});
};
// 컬럼 토글
const handleToggle = (id: string) => {
onConfigChange({
columns: columns.map((col) => (col.id === id ? { ...col, visible: !col.visible } : col)),
});
};
// 드래그 시작
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
// 드래그 오버
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const newColumns = [...columns];
const draggedItem = newColumns[draggedIndex];
newColumns.splice(draggedIndex, 1);
newColumns.splice(index, 0, draggedItem);
onConfigChange({ columns: newColumns });
setDraggedIndex(index);
};
// 드롭
const handleDrop = () => {
setDraggedIndex(null);
};
// 드래그 종료
const handleDragEnd = () => {
setDraggedIndex(null);
};
return (
<div>
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<p className="text-[10px] text-gray-500"> </p>
<button
onClick={handleAddColumn}
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
>
<Plus className="h-3 w-3" />
</button>
</div>
<div className="space-y-1.5">
{columns.map((col, index) => {
const preview = sampleData[col.field];
const previewText =
preview !== undefined && preview !== null
? typeof preview === "object"
? JSON.stringify(preview).substring(0, 30)
: String(preview).substring(0, 30)
: "";
return (
<div
key={col.id}
draggable
onDragStart={(e) => {
handleDragStart(index);
e.currentTarget.style.cursor = "grabbing";
}}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
onDragEnd={(e) => {
handleDragEnd();
e.currentTarget.style.cursor = "grab";
}}
className={`group relative rounded-md border transition-all ${
col.visible
? "border-primary/40 bg-primary/5 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
} cursor-grab active:cursor-grabbing ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
>
{/* 헤더 */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Checkbox
checked={col.visible}
onCheckedChange={() => handleToggle(col.id)}
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full"
/>
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[11px] font-medium text-gray-900">
{col.field || "(필드명 없음)"}
</span>
{previewText && <span className="shrink-0 text-[9px] text-gray-400">: {previewText}</span>}
</div>
</div>
<button
onClick={() => handleRemove(col.id)}
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* 설정 영역 */}
{col.visible && (
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
<div className="grid grid-cols-2 gap-1.5">
{/* 표시 이름 */}
<div className="min-w-0">
<Input
value={col.label}
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
placeholder="표시 이름"
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
style={{ fontSize: "10px" }}
/>
</div>
{/* 정렬 */}
<div className="min-w-0">
<Select
value={col.align}
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
>
<SelectTrigger
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-[4rem]">
<SelectItem value="left" className="py-1 text-[10px]">
</SelectItem>
<SelectItem value="center" className="py-1 text-[10px]">
</SelectItem>
<SelectItem value="right" className="py-1 text-[10px]">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
{columns.length === 0 && (
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
<span className="text-amber-600"></span>
<span className="text-[10px] text-amber-700"> </span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { useState, useEffect } from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Scroll to Top
* - /릿 (lg )
* - /
* -
*/
export function ScrollToTop() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// 스크롤 이벤트 핸들러
const toggleVisibility = () => {
// 200px 이상 스크롤 시 버튼 표시
if (window.scrollY > 200) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
// 스크롤 이벤트 리스너 등록
window.addEventListener("scroll", toggleVisibility);
// 초기 상태 설정
toggleVisibility();
// 클린업
return () => {
window.removeEventListener("scroll", toggleVisibility);
};
}, []);
// 상단으로 스크롤
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth", // 부드러운 스크롤 애니메이션
});
};
return (
<Button
onClick={scrollToTop}
size="icon"
className={`fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg transition-all duration-300 lg:hidden ${
isVisible ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0"
}`}
aria-label="맨 위로 스크롤"
>
<ArrowUp className="h-5 w-5" />
</Button>
);
}

View File

@ -31,7 +31,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
@ -63,11 +63,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
setCargoList(result.data.rows);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
@ -78,7 +78,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
const getStatusBadge = (status: string) => {
const statusLower = status?.toLowerCase() || "";
if (statusLower.includes("배송중") || statusLower.includes("delivering")) {
return "bg-primary text-primary-foreground";
} else if (statusLower.includes("완료") || statusLower.includes("delivered")) {
@ -93,11 +93,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
const filteredList = cargoList.filter((cargo) => {
if (!searchTerm) return true;
const trackingNum = cargo.tracking_number || cargo.trackingNumber || "";
const customerName = cargo.customer_name || cargo.customerName || "";
const destination = cargo.destination || "";
const searchLower = searchTerm.toLowerCase();
return (
trackingNum.toLowerCase().includes(searchLower) ||
@ -111,7 +111,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
@ -120,11 +120,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-destructive">
<div className="text-destructive text-center">
<p className="text-sm"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
className="bg-destructive/10 hover:bg-destructive/20 mt-2 rounded-md px-3 py-1 text-xs"
>
</button>
@ -136,29 +136,29 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
if (!element?.dataSource?.query) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="text-sm"> </p>
<div className="text-muted-foreground text-center">
<p className="text-sm"> </p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
<div className="bg-background flex h-full flex-col overflow-hidden p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground">📦 </h3>
<h3 className="text-foreground text-lg font-semibold">📦 </h3>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
className="border-input bg-background placeholder:text-muted-foreground focus:ring-ring rounded-md border px-3 py-1 text-sm focus:ring-2 focus:outline-none"
/>
<button
onClick={loadData}
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-full p-1"
title="새로고침"
>
🔄
@ -167,47 +167,38 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
</div>
{/* 총 건수 */}
<div className="mb-3 text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{filteredList.length}</span>
<div className="text-muted-foreground mb-3 text-sm">
<span className="text-foreground font-semibold">{filteredList.length}</span>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto rounded-md border border-border">
<div className="border-border flex-1 overflow-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="border-b border-border p-2 text-left font-medium"></th>
<th className="border-b border-border p-2 text-left font-medium"></th>
<th className="border-b border-border p-2 text-left font-medium"></th>
<th className="border-b border-border p-2 text-left font-medium">(kg)</th>
<th className="border-b border-border p-2 text-left font-medium"></th>
<th className="border-border border-b p-2 text-left font-medium"></th>
<th className="border-border border-b p-2 text-left font-medium"></th>
<th className="border-border border-b p-2 text-left font-medium"></th>
<th className="border-border border-b p-2 text-left font-medium">(kg)</th>
<th className="border-border border-b p-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{filteredList.length === 0 ? (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
<td colSpan={5} className="text-muted-foreground p-8 text-center">
{searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"}
</td>
</tr>
) : (
filteredList.map((cargo, index) => (
<tr
key={cargo.id || index}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-2 font-medium text-foreground">
<tr key={cargo.id || index} className="border-border hover:bg-muted/30 border-b transition-colors">
<td className="text-foreground p-2 font-medium">
{cargo.tracking_number || cargo.trackingNumber || "-"}
</td>
<td className="p-2 text-foreground">
{cargo.customer_name || cargo.customerName || "-"}
</td>
<td className="p-2 text-muted-foreground">
{cargo.destination || "-"}
</td>
<td className="p-2 text-right text-muted-foreground">
{cargo.weight ? `${cargo.weight}kg` : "-"}
</td>
<td className="text-foreground p-2">{cargo.customer_name || cargo.customerName || "-"}</td>
<td className="text-muted-foreground p-2">{cargo.destination || "-"}</td>
<td className="text-muted-foreground p-2 text-right">{cargo.weight ? `${cargo.weight}kg` : "-"}</td>
<td className="p-2">
<span
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${getStatusBadge(cargo.status || "")}`}
@ -224,4 +215,3 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
</div>
);
}

View File

@ -41,11 +41,11 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
const lastQueryRef = React.useRef<string>(""); // 마지막 쿼리 추적
// localStorage 키 생성 (쿼리 기반으로 고유하게 - 편집/보기 모드 공유)
const queryHash = element?.dataSource?.query
const queryHash = element?.dataSource?.query
? btoa(element.dataSource.query) // 전체 쿼리를 base64로 인코딩
: "default";
const storageKey = `custom-stats-widget-${queryHash}`;
// console.log("🔑 storageKey:", storageKey, "(쿼리:", element?.dataSource?.query?.substring(0, 30) + "...)");
// 쿼리가 변경되면 초기화 상태 리셋
@ -148,174 +148,174 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
// 3. 컬럼명 한글 번역 매핑
const columnNameTranslation: { [key: string]: string } = {
// 일반
"id": "ID",
"name": "이름",
"title": "제목",
"description": "설명",
"status": "상태",
"type": "유형",
"category": "카테고리",
"date": "날짜",
"time": "시간",
"created_at": "생성일",
"updated_at": "수정일",
"deleted_at": "삭제일",
id: "ID",
name: "이름",
title: "제목",
description: "설명",
status: "상태",
type: "유형",
category: "카테고리",
date: "날짜",
time: "시간",
created_at: "생성일",
updated_at: "수정일",
deleted_at: "삭제일",
// 물류/운송
"tracking_number": "운송장 번호",
"customer": "고객",
"origin": "출발지",
"destination": "목적지",
"estimated_delivery": "예상 도착",
"actual_delivery": "실제 도착",
"delay_reason": "지연 사유",
"priority": "우선순위",
"cargo_weight": "화물 중량",
"total_weight": "총 중량",
"weight": "중량",
"distance": "거리",
"total_distance": "총 거리",
"delivery_time": "배송 시간",
"delivery_duration": "배송 소요시간",
"is_on_time": "정시 도착 여부",
"on_time": "정시",
tracking_number: "운송장 번호",
customer: "고객",
origin: "출발지",
destination: "목적지",
estimated_delivery: "예상 도착",
actual_delivery: "실제 도착",
delay_reason: "지연 사유",
priority: "우선순위",
cargo_weight: "화물 중량",
total_weight: "총 중량",
weight: "중량",
distance: "거리",
total_distance: "총 거리",
delivery_time: "배송 시간",
delivery_duration: "배송 소요시간",
is_on_time: "정시 도착 여부",
on_time: "정시",
// 수량/금액
"quantity": "수량",
"qty": "수량",
"amount": "금액",
"price": "가격",
"cost": "비용",
"fee": "수수료",
"total": "합계",
"sum": "총합",
quantity: "수량",
qty: "수량",
amount: "금액",
price: "가격",
cost: "비용",
fee: "수수료",
total: "합계",
sum: "총합",
// 비율/효율
"rate": "비율",
"ratio": "비율",
"percent": "퍼센트",
"percentage": "백분율",
"efficiency": "효율",
rate: "비율",
ratio: "비율",
percent: "퍼센트",
percentage: "백분율",
efficiency: "효율",
// 생산/처리
"throughput": "처리량",
"output": "산출량",
"production": "생산량",
"volume": "용량",
throughput: "처리량",
output: "산출량",
production: "생산량",
volume: "용량",
// 재고/설비
"stock": "재고",
"inventory": "재고",
"equipment": "설비",
"facility": "시설",
"machine": "기계",
stock: "재고",
inventory: "재고",
equipment: "설비",
facility: "시설",
machine: "기계",
// 평가
"score": "점수",
"rating": "평점",
"point": "점수",
"grade": "등급",
score: "점수",
rating: "평점",
point: "점수",
grade: "등급",
// 기타
"temperature": "온도",
"temp": "온도",
"speed": "속도",
"velocity": "속도",
"count": "개수",
"number": "번호",
temperature: "온도",
temp: "온도",
speed: "속도",
velocity: "속도",
count: "개수",
number: "번호",
};
// 4. 키워드 기반 자동 라벨링 및 단위 설정
const columnConfig: {
[key: string]: {
keywords: string[];
unit: string;
color: string;
icon: string;
[key: string]: {
keywords: string[];
unit: string;
color: string;
icon: string;
aggregation: "sum" | "avg" | "max" | "min"; // 집계 방식
koreanLabel?: string; // 한글 라벨
};
} = {
// 무게/중량 - 합계
weight: {
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
unit: "톤",
color: "green",
weight: {
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
unit: "톤",
color: "green",
icon: "⚖️",
aggregation: "sum",
koreanLabel: "총 운송량"
koreanLabel: "총 운송량",
},
// 거리 - 합계
distance: {
keywords: ["distance", "total_distance", "km", "kilometer"],
unit: "km",
color: "blue",
distance: {
keywords: ["distance", "total_distance", "km", "kilometer"],
unit: "km",
color: "blue",
icon: "🛣️",
aggregation: "sum",
koreanLabel: "누적 거리"
koreanLabel: "누적 거리",
},
// 시간/기간 - 평균
time: {
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
unit: "분",
color: "orange",
icon: "⏱️",
time: {
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
unit: "분",
color: "orange",
icon: "⏱️",
aggregation: "avg",
koreanLabel: "평균 배송시간"
koreanLabel: "평균 배송시간",
},
// 수량/개수 - 합계
quantity: {
keywords: ["quantity", "qty", "count", "number"],
unit: "개",
color: "purple",
quantity: {
keywords: ["quantity", "qty", "count", "number"],
unit: "개",
color: "purple",
icon: "📦",
aggregation: "sum",
koreanLabel: "총 수량"
koreanLabel: "총 수량",
},
// 금액/가격 - 합계
amount: {
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
unit: "원",
color: "yellow",
amount: {
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
unit: "원",
color: "yellow",
icon: "💰",
aggregation: "sum",
koreanLabel: "총 금액"
koreanLabel: "총 금액",
},
// 비율/퍼센트 - 평균
rate: {
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
unit: "%",
color: "cyan",
icon: "📈",
rate: {
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
unit: "%",
color: "cyan",
icon: "📈",
aggregation: "avg",
koreanLabel: "평균 비율"
koreanLabel: "평균 비율",
},
// 처리량 - 합계
throughput: {
keywords: ["throughput", "output", "production", "volume"],
unit: "개",
color: "pink",
throughput: {
keywords: ["throughput", "output", "production", "volume"],
unit: "개",
color: "pink",
icon: "⚡",
aggregation: "sum",
koreanLabel: "총 처리량"
koreanLabel: "총 처리량",
},
// 재고 - 평균 (현재 재고는 평균이 의미있음)
stock: {
keywords: ["stock", "inventory"],
unit: "개",
color: "teal",
stock: {
keywords: ["stock", "inventory"],
unit: "개",
color: "teal",
icon: "📦",
aggregation: "avg",
koreanLabel: "평균 재고"
koreanLabel: "평균 재고",
},
// 설비/장비 - 평균
equipment: {
keywords: ["equipment", "facility", "machine"],
unit: "대",
color: "gray",
equipment: {
keywords: ["equipment", "facility", "machine"],
unit: "대",
color: "gray",
icon: "🏭",
aggregation: "avg",
koreanLabel: "평균 가동 설비"
koreanLabel: "평균 가동 설비",
},
// 점수/평점 - 평균
score: {
@ -324,7 +324,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
color: "indigo",
icon: "⭐",
aggregation: "avg",
koreanLabel: "평균 점수"
koreanLabel: "평균 점수",
},
// 온도 - 평균
temperature: {
@ -333,7 +333,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
color: "red",
icon: "🌡️",
aggregation: "avg",
koreanLabel: "평균 온도"
koreanLabel: "평균 온도",
},
// 속도 - 평균
speed: {
@ -342,7 +342,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
color: "blue",
icon: "🚀",
aggregation: "avg",
koreanLabel: "평균 속도"
koreanLabel: "평균 속도",
},
};
@ -363,17 +363,19 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
icon = config.icon;
aggregation = config.aggregation;
matchedConfig = config;
// 한글 라벨 사용 또는 자동 변환
if (config.koreanLabel) {
label = config.koreanLabel;
} else {
// 집계 방식에 따라 접두어 추가
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
label = prefix + key
.replace(/_/g, " ")
.replace(/([A-Z])/g, " $1")
.trim();
label =
prefix +
key
.replace(/_/g, " ")
.replace(/([A-Z])/g, " $1")
.trim();
}
break;
}
@ -383,41 +385,45 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
if (!matchedConfig) {
// 컬럼명 번역 시도
const translatedName = columnNameTranslation[key.toLowerCase()];
if (translatedName) {
// 번역된 이름이 있으면 사용
label = translatedName;
} else {
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
if (key.toLowerCase().includes("avg") ||
key.toLowerCase().includes("average") ||
key.toLowerCase().includes("mean")) {
if (
key.toLowerCase().includes("avg") ||
key.toLowerCase().includes("average") ||
key.toLowerCase().includes("mean")
) {
aggregation = "avg";
// 언더스코어로 분리된 각 단어 번역 시도
const cleanKey = key.replace(/avg|average|mean/gi, "").replace(/_/g, " ").trim();
const cleanKey = key
.replace(/avg|average|mean/gi, "")
.replace(/_/g, " ")
.trim();
const words = cleanKey.split(/[_\s]+/);
const translatedWords = words.map(word =>
columnNameTranslation[word.toLowerCase()] || word
);
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
label = "평균 " + translatedWords.join(" ");
}
}
// total, sum이 포함되면 합계로 간주
else if (key.toLowerCase().includes("total") || key.toLowerCase().includes("sum")) {
aggregation = "sum";
// 언더스코어로 분리된 각 단어 번역 시도
const cleanKey = key.replace(/total|sum/gi, "").replace(/_/g, " ").trim();
const cleanKey = key
.replace(/total|sum/gi, "")
.replace(/_/g, " ")
.trim();
const words = cleanKey.split(/[_\s]+/);
const translatedWords = words.map(word =>
columnNameTranslation[word.toLowerCase()] || word
);
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
label = "총 " + translatedWords.join(" ");
}
// 기본값 - 각 단어별로 번역 시도
else {
const words = key.split(/[_\s]+/);
const translatedWords = words.map(word => {
const translatedWords = words.map((word) => {
const translated = columnNameTranslation[word.toLowerCase()];
if (translated) {
return translated;
@ -473,25 +479,25 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
Object.keys(firstRow).forEach((key) => {
const lowerKey = key.toLowerCase();
const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k));
if (matchedKey) {
const label = booleanMapping[matchedKey];
// 이미 추가된 라벨이면 스킵
if (addedBooleanLabels.has(label)) {
return;
}
const validItems = data.filter((item: any) => item[key] !== null && item[key] !== undefined);
if (validItems.length > 0) {
const trueCount = validItems.filter((item: any) => {
const val = item[key];
return val === true || val === "true" || val === 1 || val === "1" || val === "Y";
}).length;
const rate = (trueCount / validItems.length) * 100;
statsItems.push({
label,
value: rate,
@ -499,7 +505,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
color: "purple",
icon: "✅",
});
addedBooleanLabels.add(label);
}
}
@ -507,27 +513,27 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
// console.log("📊 생성된 통계 항목:", statsItems.map(s => s.label));
setAllStats(statsItems);
// 초기화가 아직 안됐으면 localStorage에서 설정 불러오기
if (!isInitializedRef.current) {
const saved = localStorage.getItem(storageKey);
// console.log("💾 저장된 설정:", saved);
if (saved) {
try {
const savedLabels = JSON.parse(saved);
// console.log("✅ 저장된 라벨:", savedLabels);
const filtered = statsItems.filter((s) => savedLabels.includes(s.label));
// console.log("🔍 필터링된 통계:", filtered.map(s => s.label));
// console.log(`📊 일치율: ${filtered.length}/${savedLabels.length} (${Math.round(filtered.length / savedLabels.length * 100)}%)`);
// 50% 이상 일치하면 저장된 설정 사용
const matchRate = filtered.length / savedLabels.length;
if (matchRate >= 0.5 && filtered.length > 0) {
setStats(filtered);
// 실제 표시되는 라벨로 업데이트
const actualLabels = filtered.map(s => s.label);
const actualLabels = filtered.map((s) => s.label);
setSelectedStats(actualLabels);
selectedStatsRef.current = actualLabels;
// localStorage도 업데이트하여 다음에는 정확히 일치하도록
@ -562,11 +568,11 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
// 이미 초기화됐으면 현재 선택된 통계 유지
const currentSelected = selectedStatsRef.current;
// console.log("🔄 현재 선택된 통계:", currentSelected);
if (currentSelected.length > 0) {
const filtered = statsItems.filter((s) => currentSelected.includes(s.label));
// console.log("🔍 필터링 결과:", filtered.map(s => s.label));
if (filtered.length > 0) {
setStats(filtered);
} else {
@ -624,9 +630,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-gray-600">{error}</div>
{!element?.dataSource?.query && (
<div className="mt-2 text-xs text-gray-500"> </div>
)}
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500"> </div>}
<button
onClick={loadData}
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
@ -652,9 +656,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
const handleToggleStat = (label: string) => {
setSelectedStats((prev) => {
const newStats = prev.includes(label)
? prev.filter((l) => l !== label)
: [...prev, label];
const newStats = prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label];
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
return newStats;
});
@ -663,14 +665,14 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
const handleApplySettings = () => {
// console.log("💾 설정 적용:", selectedStats);
// console.log("📊 전체 통계:", allStats.map(s => s.label));
const filtered = allStats.filter((s) => selectedStats.includes(s.label));
// console.log("✅ 필터링 결과:", filtered.map(s => s.label));
setStats(filtered);
selectedStatsRef.current = selectedStats; // ref도 업데이트
setShowSettings(false);
// localStorage에 설정 저장
localStorage.setItem(storageKey, JSON.stringify(selectedStats));
// console.log("💾 localStorage 저장 완료:", selectedStats.length + "개");
@ -693,7 +695,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
<button
onClick={() => {
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
const currentLabels = stats.map(s => s.label);
const currentLabels = stats.map((s) => s.label);
// console.log("⚙️ 설정 모달 열기 - 현재 표시 중:", currentLabels);
setSelectedStats(currentLabels);
setShowSettings(true);
@ -713,9 +715,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
const colors = getColorClasses(stat.color);
return (
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
<div className="text-sm text-gray-600">
{stat.label}
</div>
<div className="text-sm text-gray-600">{stat.label}</div>
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
<span className="ml-1 text-lg">{stat.unit}</span>
@ -737,9 +737,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
</button>
</div>
<div className="mb-4 text-sm text-gray-600">
( )
</div>
<div className="mb-4 text-sm text-gray-600"> ( )</div>
<div className="space-y-2">
{allStats.map((stat, index) => {

View File

@ -33,7 +33,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
@ -65,11 +65,11 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
setIssues(result.data.rows);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
@ -80,7 +80,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
const getPriorityBadge = (priority: string) => {
const priorityLower = priority?.toLowerCase() || "";
if (priorityLower.includes("긴급") || priorityLower.includes("high") || priorityLower.includes("urgent")) {
return "bg-destructive text-destructive-foreground";
} else if (priorityLower.includes("보통") || priorityLower.includes("medium") || priorityLower.includes("normal")) {
@ -93,7 +93,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
const getStatusBadge = (status: string) => {
const statusLower = status?.toLowerCase() || "";
if (statusLower.includes("처리중") || statusLower.includes("processing") || statusLower.includes("pending")) {
return "bg-primary text-primary-foreground";
} else if (statusLower.includes("완료") || statusLower.includes("resolved") || statusLower.includes("closed")) {
@ -102,19 +102,20 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
return "bg-muted text-muted-foreground";
};
const filteredIssues = filterPriority === "all"
? issues
: issues.filter((issue) => {
const priority = (issue.priority || "").toLowerCase();
return priority.includes(filterPriority);
});
const filteredIssues =
filterPriority === "all"
? issues
: issues.filter((issue) => {
const priority = (issue.priority || "").toLowerCase();
return priority.includes(filterPriority);
});
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
@ -123,11 +124,11 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-destructive">
<div className="text-destructive text-center">
<p className="text-sm"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
className="bg-destructive/10 hover:bg-destructive/20 mt-2 rounded-md px-3 py-1 text-xs"
>
</button>
@ -139,21 +140,21 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
if (!element?.dataSource?.query) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="text-sm"> </p>
<div className="text-muted-foreground text-center">
<p className="text-sm"> </p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
<div className="bg-background flex h-full flex-col overflow-hidden p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<h3 className="text-foreground text-lg font-semibold"> /</h3>
<button
onClick={loadData}
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-full p-1"
title="새로고침"
>
🔄
@ -205,48 +206,48 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
</div>
{/* 총 건수 */}
<div className="mb-3 text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{filteredIssues.length}</span>
<div className="text-muted-foreground mb-3 text-sm">
<span className="text-foreground font-semibold">{filteredIssues.length}</span>
</div>
{/* 이슈 리스트 */}
<div className="flex-1 space-y-2 overflow-auto">
{filteredIssues.length === 0 ? (
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
<div className="text-muted-foreground flex h-full items-center justify-center text-center">
<p> </p>
</div>
) : (
filteredIssues.map((issue, index) => (
<div
key={issue.id || index}
className="rounded-lg border border-border bg-card p-3 transition-all hover:shadow-md"
className="border-border bg-card rounded-lg border p-3 transition-all hover:shadow-md"
>
<div className="mb-2 flex items-start justify-between">
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}
>
{issue.priority || "보통"}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}
>
{issue.status || "처리중"}
</span>
</div>
<p className="text-sm font-medium text-foreground">
{issue.issue_type || issue.issueType || "기타"}
</p>
<p className="text-foreground text-sm font-medium">{issue.issue_type || issue.issueType || "기타"}</p>
</div>
</div>
<p className="mb-2 text-xs text-muted-foreground">
<p className="text-muted-foreground mb-2 text-xs">
: {issue.customer_name || issue.customerName || "-"}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{issue.description || "설명 없음"}
</p>
<p className="text-muted-foreground line-clamp-2 text-xs">{issue.description || "설명 없음"}</p>
{(issue.created_at || issue.createdAt) && (
<p className="mt-2 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-2 text-xs">
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
</p>
)}
@ -257,4 +258,3 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
</div>
);
}

View File

@ -23,7 +23,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
@ -55,11 +55,11 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
// 데이터 처리
if (result.success && result.data?.rows) {
const rows = result.data.rows;
// 상태별 카운트 계산
const statusCounts = rows.reduce((acc: any, row: any) => {
const status = row.status || "알 수 없음";
@ -76,7 +76,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
setStatusData(formattedData);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
@ -161,7 +161,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
</div>
</div>
);
@ -183,7 +183,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
</div>
<button
onClick={loadData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
disabled={loading}
>
{loading ? "⏳" : "🔄"}
@ -211,4 +211,3 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
</div>
);
}

View File

@ -24,7 +24,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
@ -56,7 +56,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
// 데이터 처리
if (result.success && result.data?.rows) {
const rows = result.data.rows;
@ -80,7 +80,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
delivered: deliveredToday,
});
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
@ -120,7 +120,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
</div>
</div>
);
@ -131,11 +131,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<button
onClick={loadData}
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"
title="새로고침"
>
<button onClick={loadData} className="rounded-full p-1 text-gray-500 hover:bg-gray-100" title="새로고침">
🔄
</button>
</div>
@ -161,4 +157,3 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
</div>
);
}

View File

@ -22,66 +22,66 @@ interface ColumnInfo {
const translateColumnName = (colName: string): string => {
const columnTranslations: { [key: string]: string } = {
// 공통
"id": "ID",
"name": "이름",
"status": "상태",
"created_at": "생성일",
"updated_at": "수정일",
"created_date": "생성일",
"updated_date": "수정일",
// 기사 관련
"driver_id": "기사ID",
"phone": "전화번호",
"license_number": "면허번호",
"vehicle_id": "차량ID",
"current_location": "현재위치",
"rating": "평점",
"total_deliveries": "총배송건수",
"average_delivery_time": "평균배송시간",
"total_distance": "총운행거리",
"join_date": "가입일",
"last_active": "마지막활동",
id: "ID",
name: "이름",
status: "상태",
created_at: "생성일",
updated_at: "수정일",
created_date: "생성일",
updated_date: "수정일",
// 기사 관련
driver_id: "기사ID",
phone: "전화번호",
license_number: "면허번호",
vehicle_id: "차량ID",
current_location: "현재위치",
rating: "평점",
total_deliveries: "총배송건수",
average_delivery_time: "평균배송시간",
total_distance: "총운행거리",
join_date: "가입일",
last_active: "마지막활동",
// 차량 관련
"vehicle_number": "차량번호",
"model": "모델",
"year": "연식",
"color": "색상",
"type": "종류",
vehicle_number: "차량번호",
model: "모델",
year: "연식",
color: "색상",
type: "종류",
// 배송 관련
"delivery_id": "배송ID",
"order_id": "주문ID",
"customer_name": "고객명",
"address": "주소",
"delivery_date": "배송일",
"estimated_time": "예상시간",
delivery_id: "배송ID",
order_id: "주문ID",
customer_name: "고객명",
address: "주소",
delivery_date: "배송일",
estimated_time: "예상시간",
// 제품 관련
"product_id": "제품ID",
"product_name": "제품명",
"price": "가격",
"stock": "재고",
"category": "카테고리",
"description": "설명",
product_id: "제품ID",
product_name: "제품명",
price: "가격",
stock: "재고",
category: "카테고리",
description: "설명",
// 주문 관련
"order_date": "주문일",
"quantity": "수량",
"total_amount": "총금액",
"payment_status": "결제상태",
order_date: "주문일",
quantity: "수량",
total_amount: "총금액",
payment_status: "결제상태",
// 고객 관련
"customer_id": "고객ID",
"email": "이메일",
"company": "회사",
"department": "부서",
customer_id: "고객ID",
email: "이메일",
company: "회사",
department: "부서",
};
return columnTranslations[colName.toLowerCase()] ||
columnTranslations[colName.replace(/_/g, '').toLowerCase()] ||
colName;
return (
columnTranslations[colName.toLowerCase()] || columnTranslations[colName.replace(/_/g, "").toLowerCase()] || colName
);
};
/**
@ -99,7 +99,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
@ -126,7 +126,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
setLoading(true);
const extractedTableName = extractTableName(element.dataSource.query);
setTableName(extractedTableName);
const token = localStorage.getItem("authToken");
const response = await fetch("/api/dashboards/execute-query", {
method: "POST",
@ -144,10 +144,10 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
// 컬럼 정보 추출 (한글 번역 적용)
if (rows.length > 0) {
const cols: ColumnInfo[] = Object.keys(rows[0]).map((key) => ({
@ -156,10 +156,10 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
}));
setColumns(cols);
}
setData(rows);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
@ -171,34 +171,30 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
// 테이블 이름 한글 번역
const translateTableName = (name: string): string => {
const tableTranslations: { [key: string]: string } = {
"drivers": "기사",
"driver": "기사",
"vehicles": "차량",
"vehicle": "차량",
"products": "제품",
"product": "제품",
"orders": "주문",
"order": "주문",
"customers": "고객",
"customer": "고객",
"deliveries": "배송",
"delivery": "배송",
"users": "사용자",
"user": "사용자",
drivers: "기사",
driver: "기사",
vehicles: "차량",
vehicle: "차량",
products: "제품",
product: "제품",
orders: "주문",
order: "주문",
customers: "고객",
customer: "고객",
deliveries: "배송",
delivery: "배송",
users: "사용자",
user: "사용자",
};
return tableTranslations[name.toLowerCase()] ||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
name;
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
};
const displayTitle = tableName ? `${translateTableName(tableName)} 목록` : "데이터 목록";
// 검색 필터링
const filteredData = data.filter((row) =>
Object.values(row).some((value) =>
String(value).toLowerCase().includes(searchTerm.toLowerCase())
)
Object.values(row).some((value) => String(value).toLowerCase().includes(searchTerm.toLowerCase())),
);
if (loading) {
@ -244,8 +240,6 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
</ul>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
<p className="mt-0.5"> </p>
<p>SQL </p>
</div>
</div>
@ -263,7 +257,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
</div>
<button
onClick={loadData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
disabled={loading}
>
{loading ? "⏳" : "🔄"}
@ -278,7 +272,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
placeholder="검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
className="focus:border-primary focus:ring-primary w-full rounded border border-gray-300 px-2 py-1 text-xs focus:ring-1 focus:outline-none"
/>
</div>
)}
@ -290,10 +284,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
<thead className="sticky top-0 bg-gray-100">
<tr>
{columns.map((col) => (
<th
key={col.key}
className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700"
>
<th key={col.key} className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700">
{col.label}
</th>
))}
@ -303,10 +294,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
{filteredData.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50">
{columns.map((col) => (
<td
key={col.key}
className="border border-gray-300 px-2 py-1 text-gray-800"
>
<td key={col.key} className="border border-gray-300 px-2 py-1 text-gray-800">
{String(row[col.key] || "")}
</td>
))}
@ -323,4 +311,3 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
</div>
);
}

View File

@ -39,23 +39,21 @@ interface MarkerData {
// 테이블명 한글 번역
const translateTableName = (name: string): string => {
const tableTranslations: { [key: string]: string } = {
"vehicle_locations": "차량",
"vehicles": "차량",
"warehouses": "창고",
"warehouse": "창고",
"customers": "고객",
"customer": "고객",
"deliveries": "배송",
"delivery": "배송",
"drivers": "기사",
"driver": "기사",
"stores": "매장",
"store": "매장",
vehicle_locations: "차량",
vehicles: "차량",
warehouses: "창고",
warehouse: "창고",
customers: "고객",
customer: "고객",
deliveries: "배송",
delivery: "배송",
drivers: "기사",
driver: "기사",
stores: "매장",
store: "매장",
};
return tableTranslations[name.toLowerCase()] ||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
name;
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
};
/**
@ -74,14 +72,14 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
if (element?.dataSource?.query) {
loadMapData();
}
// 자동 새로고침 (30초마다)
const interval = setInterval(() => {
if (element?.dataSource?.query) {
loadMapData();
}
}, 30000);
return () => clearInterval(interval);
}, [element]);
@ -124,7 +122,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
if (result.success && result.data?.rows) {
const rows = result.data.rows;
// 위도/경도 컬럼 찾기
const latCol = element.chartConfig?.latitudeColumn || "latitude";
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
@ -162,12 +160,12 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
{element?.dataSource?.query ? (
<p className="text-xs text-gray-500"> {markers.length.toLocaleString()} </p>
) : (
<p className="text-xs text-orange-500"> </p>
<p className="text-xs text-orange-500"> </p>
)}
</div>
<button
onClick={loadMapData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
disabled={loading || !element?.dataSource?.query}
>
{loading ? "⏳" : "🔄"}
@ -182,7 +180,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
)}
{/* 지도 (항상 표시) */}
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
<div className="relative z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
<MapContainer
key={`map-${element.id}`}
center={[36.5, 127.5]}

View File

@ -21,109 +21,109 @@ interface StatusConfig {
// 영어 상태명 → 한글 자동 변환
const statusTranslations: { [key: string]: string } = {
// 배송 관련
"delayed": "지연",
"pickup_waiting": "픽업 대기",
"in_transit": "배송 중",
"delivered": "배송완료",
"pending": "대기중",
"processing": "처리중",
"completed": "완료",
"cancelled": "취소됨",
"failed": "실패",
delayed: "지연",
pickup_waiting: "픽업 대기",
in_transit: "배송 중",
delivered: "배송완료",
pending: "대기중",
processing: "처리중",
completed: "완료",
cancelled: "취소됨",
failed: "실패",
// 일반 상태
"active": "활성",
"inactive": "비활성",
"enabled": "사용중",
"disabled": "사용안함",
"online": "온라인",
"offline": "오프라인",
"available": "사용가능",
"unavailable": "사용불가",
active: "활성",
inactive: "비활성",
enabled: "사용중",
disabled: "사용안함",
online: "온라인",
offline: "오프라인",
available: "사용가능",
unavailable: "사용불가",
// 승인 관련
"approved": "승인됨",
"rejected": "거절됨",
"waiting": "대기중",
approved: "승인됨",
rejected: "거절됨",
waiting: "대기중",
// 차량 관련
"driving": "운행중",
"parked": "주차",
"maintenance": "정비중",
driving: "운행중",
parked: "주차",
maintenance: "정비중",
// 기사 관련 (존중하는 표현)
"waiting": "대기중",
"resting": "휴식중",
"unavailable": "운행불가",
waiting: "대기중",
resting: "휴식중",
unavailable: "운행불가",
// 기사 평가
"excellent": "우수",
"good": "양호",
"average": "보통",
"poor": "미흡",
excellent: "우수",
good: "양호",
average: "보통",
poor: "미흡",
// 기사 경력
"veteran": "베테랑",
"experienced": "숙련",
"intermediate": "중급",
"beginner": "초급",
veteran: "베테랑",
experienced: "숙련",
intermediate: "중급",
beginner: "초급",
};
// 영어 테이블명 → 한글 자동 변환
const tableTranslations: { [key: string]: string } = {
// 배송/물류 관련
"deliveries": "배송",
"delivery": "배송",
"shipments": "출하",
"shipment": "출하",
"orders": "주문",
"order": "주문",
"cargo": "화물",
"cargos": "화물",
"packages": "소포",
"package": "소포",
deliveries: "배송",
delivery: "배송",
shipments: "출하",
shipment: "출하",
orders: "주문",
order: "주문",
cargo: "화물",
cargos: "화물",
packages: "소포",
package: "소포",
// 차량 관련
"vehicles": "차량",
"vehicle": "차량",
"vehicle_locations": "차량위치",
"vehicle_status": "차량상태",
"drivers": "기사",
"driver": "기사",
vehicles: "차량",
vehicle: "차량",
vehicle_locations: "차량위치",
vehicle_status: "차량상태",
drivers: "기사",
driver: "기사",
// 사용자/고객 관련
"users": "사용자",
"user": "사용자",
"customers": "고객",
"customer": "고객",
"members": "회원",
"member": "회원",
users: "사용자",
user: "사용자",
customers: "고객",
customer: "고객",
members: "회원",
member: "회원",
// 제품/재고 관련
"products": "제품",
"product": "제품",
"items": "항목",
"item": "항목",
"inventory": "재고",
"stock": "재고",
products: "제품",
product: "제품",
items: "항목",
item: "항목",
inventory: "재고",
stock: "재고",
// 업무 관련
"tasks": "작업",
"task": "작업",
"projects": "프로젝트",
"project": "프로젝트",
"issues": "이슈",
"issue": "이슈",
"tickets": "티켓",
"ticket": "티켓",
tasks: "작업",
task: "작업",
projects: "프로젝트",
project: "프로젝트",
issues: "이슈",
issue: "이슈",
tickets: "티켓",
ticket: "티켓",
// 기타
"logs": "로그",
"log": "로그",
"reports": "리포트",
"report": "리포트",
"alerts": "알림",
"alert": "알림",
logs: "로그",
log: "로그",
reports: "리포트",
report: "리포트",
alerts: "알림",
alert: "알림",
};
interface StatusData {
@ -136,12 +136,12 @@ interface StatusData {
* -
* - statusConfig로
*/
export default function StatusSummaryWidget({
element,
export default function StatusSummaryWidget({
element,
title = "상태 요약",
icon = "📊",
bgGradient = "from-slate-50 to-blue-50",
statusConfig
statusConfig,
}: StatusSummaryWidgetProps) {
const [statusData, setStatusData] = useState<StatusData[]>([]);
const [loading, setLoading] = useState(true);
@ -150,7 +150,7 @@ export default function StatusSummaryWidget({
useEffect(() => {
loadData();
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
@ -178,7 +178,7 @@ export default function StatusSummaryWidget({
setLoading(true);
const extractedTableName = extractTableName(element.dataSource.query);
setTableName(extractedTableName);
const token = localStorage.getItem("authToken");
const response = await fetch("/api/dashboards/execute-query", {
method: "POST",
@ -196,17 +196,17 @@ export default function StatusSummaryWidget({
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
// 데이터 처리
if (result.success && result.data?.rows) {
const rows = result.data.rows;
// 상태별 카운트 계산
const statusCounts: { [key: string]: number } = {};
// GROUP BY 형식인지 확인
const isGroupedData = rows.length > 0 && rows[0].count !== undefined;
if (isGroupedData) {
// GROUP BY 형식: SELECT status, COUNT(*) as count
rows.forEach((row: any) => {
@ -244,7 +244,7 @@ export default function StatusSummaryWidget({
setStatusData(formattedData);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
@ -320,7 +320,6 @@ export default function StatusSummaryWidget({
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
<p className="mt-0.5"> </p>
<p>SQL </p>
</div>
</div>
@ -341,7 +340,7 @@ export default function StatusSummaryWidget({
return tableTranslations[name.toLowerCase()];
}
// 언더스코어 제거하고 매칭 시도
const nameWithoutUnderscore = name.replace(/_/g, '');
const nameWithoutUnderscore = name.replace(/_/g, "");
if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) {
return tableTranslations[nameWithoutUnderscore.toLowerCase()];
}
@ -357,7 +356,9 @@ export default function StatusSummaryWidget({
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">{icon} {displayTitle}</h3>
<h3 className="text-sm font-bold text-gray-900">
{icon} {displayTitle}
</h3>
{totalCount > 0 ? (
<p className="text-xs text-gray-500"> {totalCount.toLocaleString()}</p>
) : (
@ -366,7 +367,7 @@ export default function StatusSummaryWidget({
</div>
<button
onClick={loadData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
disabled={loading}
>
{loading ? "⏳" : "🔄"}
@ -380,10 +381,7 @@ export default function StatusSummaryWidget({
{statusData.map((item) => {
const colors = getColorClasses(item.status);
return (
<div
key={item.status}
className="rounded border border-gray-200 bg-white p-1.5 shadow-sm"
>
<div key={item.status} className="rounded border border-gray-200 bg-white p-1.5 shadow-sm">
<div className="mb-0.5 flex items-center gap-1">
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
<div className="text-xs font-medium text-gray-600">{item.status}</div>
@ -397,4 +395,3 @@ export default function StatusSummaryWidget({
</div>
);
}

View File

@ -100,7 +100,14 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
const deliveryTimeKeys = ["delivery_duration", "delivery_time", "duration", "배송시간", "소요시간", "배송소요시간"];
const deliveryTimeKeys = [
"delivery_duration",
"delivery_time",
"duration",
"배송시간",
"소요시간",
"배송소요시간",
];
// 총 운송량 찾기
let total_weight = 0;
@ -143,7 +150,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
// 평균 배송시간 계산
let avg_delivery_time = 0;
// 1. 먼저 배송시간 컬럼이 있는지 확인
let foundTimeColumn = false;
for (const key of Object.keys(numericColumns)) {
@ -167,7 +174,14 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
// 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산
if (!foundTimeColumn) {
const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"];
const endTimeKeys = ["actual_delivery", "end_time", "arrival_time", "도착시간", "완료시간", "estimated_delivery"];
const endTimeKeys = [
"actual_delivery",
"end_time",
"arrival_time",
"도착시간",
"완료시간",
"estimated_delivery",
];
let startKey = null;
let endKey = null;
@ -247,9 +261,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
{!element?.dataSource?.query && (
<div className="mt-2 text-xs text-gray-500"> </div>
)}
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500"> </div>}
<button
onClick={loadData}
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"

View File

@ -123,7 +123,12 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
loadVehicles();
const interval = setInterval(loadVehicles, refreshInterval);
return () => clearInterval(interval);
}, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]);
}, [
element?.dataSource?.query,
element?.chartConfig?.latitudeColumn,
element?.chartConfig?.longitudeColumn,
refreshInterval,
]);
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
@ -172,7 +177,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
{/* 지도 영역 - 브이월드 타일맵 */}
<div className="h-[calc(100%-60px)]">
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white z-0">
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
<MapContainer
key={`vehicle-map-${element.id}`}
center={[36.5, 127.5]}
@ -182,54 +187,54 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
preferCanvas={true}
className="z-0"
>
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
<TileLayer
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
attribution='&copy; <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
maxZoom={19}
minZoom={7}
updateWhenIdle={true}
updateWhenZooming={false}
keepBuffer={2}
/>
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
<TileLayer
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
attribution='&copy; <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
maxZoom={19}
minZoom={7}
updateWhenIdle={true}
updateWhenZooming={false}
keepBuffer={2}
/>
{/* 차량 마커 */}
{vehicles.map((vehicle) => (
<React.Fragment key={vehicle.id}>
<Circle
center={[vehicle.lat, vehicle.lng]}
radius={150}
pathOptions={{
color: getStatusColor(vehicle.status),
fillColor: getStatusColor(vehicle.status),
fillOpacity: 0.3,
}}
/>
<Marker position={[vehicle.lat, vehicle.lng]}>
<Popup>
<div className="text-xs">
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
<div>
<strong>:</strong> {vehicle.driver}
</div>
<div>
<strong>:</strong> {getStatusText(vehicle.status)}
</div>
<div>
<strong>:</strong> {vehicle.speed} km/h
</div>
<div>
<strong>:</strong> {vehicle.destination}
</div>
{/* 차량 마커 */}
{vehicles.map((vehicle) => (
<React.Fragment key={vehicle.id}>
<Circle
center={[vehicle.lat, vehicle.lng]}
radius={150}
pathOptions={{
color: getStatusColor(vehicle.status),
fillColor: getStatusColor(vehicle.status),
fillOpacity: 0.3,
}}
/>
<Marker position={[vehicle.lat, vehicle.lng]}>
<Popup>
<div className="text-xs">
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
<div>
<strong>:</strong> {vehicle.driver}
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
<div>
<strong>:</strong> {getStatusText(vehicle.status)}
</div>
<div>
<strong>:</strong> {vehicle.speed} km/h
</div>
<div>
<strong>:</strong> {vehicle.destination}
</div>
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
{/* 지도 정보 */}
<div className="absolute right-2 top-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
<div className="text-xs text-gray-600">
<div className="mb-1 font-semibold">🗺 (VWorld)</div>
<div className="text-xs"> </div>
@ -241,9 +246,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
{vehicles.length > 0 ? (
<div className="text-xs font-semibold text-gray-900"> {vehicles.length} </div>
) : (
<div className="text-xs text-gray-600">
</div>
<div className="text-xs text-gray-600"> </div>
)}
</div>
</div>
@ -251,4 +254,3 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
</div>
);
}

View File

@ -10,13 +10,7 @@
import { useState, useEffect } from "react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import {
WORK_TYPE_LABELS,
WORK_STATUS_LABELS,
WORK_STATUS_COLORS,
WorkType,
WorkStatus,
} from "@/types/workHistory";
import { WORK_TYPE_LABELS, WORK_STATUS_LABELS, WORK_STATUS_COLORS, WorkType, WorkStatus } from "@/types/workHistory";
interface WorkHistoryWidgetProps {
element: DashboardElement;
@ -97,11 +91,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-gray-600">{error}</div>
{!element.dataSource?.query && (
<div className="mt-2 text-xs text-gray-500">
</div>
)}
{!element.dataSource?.query && <div className="mt-2 text-xs text-gray-500"> </div>}
<button
onClick={loadData}
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"

View File

@ -146,139 +146,280 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="플로우명, 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
/>
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onLoadFlow(null)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
{/* 섹션 제목 */}
<div className="space-y-1">
<h2 className="text-xl font-semibold"> </h2>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 플로우 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center">
<Network className="mr-2 h-5 w-5" />
({filteredFlows.length})
</span>
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="플로우명, 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFlows.map((flow) => (
<TableRow
key={flow.flowId}
className="cursor-pointer hover:bg-gray-50"
onClick={() => onLoadFlow(flow.flowId)}
>
<TableCell>
<div className="flex items-center font-medium text-gray-900">
<Network className="mr-2 h-4 w-4 text-blue-500" />
{flow.flowName}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-500">{flow.flowDescription || "설명 없음"}</div>
</TableCell>
<TableCell>
<div className="text-muted-foreground flex items-center text-sm">
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
{new Date(flow.createdAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div className="text-muted-foreground flex items-center text-sm">
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
{new Date(flow.updatedAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
<Network className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(flow)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{filteredFlows.length === 0 && (
<div className="py-8 text-center text-gray-500">
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> .</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{filteredFlows.length}</span>
</div>
<Button onClick={() => onLoadFlow(null)} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{loading ? (
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="h-4 w-48 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</TableCell>
<TableCell className="h-16">
<div className="flex justify-end">
<div className="h-8 w-8 animate-pulse rounded bg-muted"></div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</div>
<div className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
</div>
</div>
</div>
))}
</div>
</>
) : filteredFlows.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Network className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold"> </h3>
<p className="max-w-sm text-sm text-muted-foreground">
.
</p>
<Button onClick={() => onLoadFlow(null)} className="mt-4 h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFlows.map((flow) => (
<TableRow
key={flow.flowId}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
onClick={() => onLoadFlow(flow.flowId)}
>
<TableCell className="h-16 text-sm">
<div className="flex items-center font-medium">
<Network className="mr-2 h-4 w-4 text-primary" />
{flow.flowName}
</div>
</TableCell>
<TableCell className="h-16 text-sm">
<div className="text-muted-foreground">{flow.flowDescription || "설명 없음"}</div>
</TableCell>
<TableCell className="h-16 text-sm">
<div className="flex items-center text-muted-foreground">
<Calendar className="mr-1 h-3 w-3" />
{new Date(flow.createdAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell className="h-16 text-sm">
<div className="flex items-center text-muted-foreground">
<Calendar className="mr-1 h-3 w-3" />
{new Date(flow.updatedAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell className="h-16" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
<Network className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(flow)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{filteredFlows.map((flow) => (
<div
key={flow.flowId}
className="cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
onClick={() => onLoadFlow(flow.flowId)}
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center">
<Network className="mr-2 h-4 w-4 text-primary" />
<h3 className="text-base font-semibold">{flow.flowName}</h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">{flow.flowDescription || "설명 없음"}</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
<Network className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(flow)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{new Date(flow.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-medium">{new Date(flow.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
))}
</div>
</>
)}
{/* 삭제 확인 모달 */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-red-600"> </DialogTitle>
<DialogDescription>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
&ldquo;{selectedFlow?.flowName}&rdquo; ?
<br />
<span className="font-medium text-red-600">
<span className="font-medium text-destructive">
, .
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteModal(false)}>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowDeleteModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={loading}>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "삭제 중..." : "삭제"}
</Button>
</DialogFooter>

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,6 @@ import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
@ -390,7 +389,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
<div className="text-muted-foreground text-sm"> ...</div>
</div>
);
}
@ -398,21 +397,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
className="h-10 pl-10 text-sm"
disabled={activeTab === "trash"}
/>
</div>
</div>
<Button variant="default" onClick={() => setIsCreateOpen(true)} disabled={activeTab === "trash"}>
<Plus className="mr-2 h-4 w-4" />
<Button
onClick={() => setIsCreateOpen(true)}
disabled={activeTab === "trash"}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
</div>
@ -425,212 +428,457 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 활성 화면 탭 */}
<TabsContent value="active">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({screens.length})</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<div className="border-b p-6">
<h3 className="text-lg font-semibold"> ({screens.length})</h3>
</div>
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{screens.map((screen) => (
<TableRow
key={screen.screenId}
className={`hover:bg-muted/50 border-b transition-colors ${
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
<TableCell className="h-16 cursor-pointer">
<div>
<div className="font-medium">{screen.screenName}</div>
{screen.description && (
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
)}
</div>
</TableCell>
<TableCell className="h-16">
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell className="h-16">
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell className="h-16">
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
<div className="text-muted-foreground text-xs">{screen.createdBy}</div>
</TableCell>
<TableCell className="h-16">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDesignScreen(screen);
}}
>
<Palette className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleView(screen);
}}
>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(screen);
}}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopy(screen);
}}
>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(screen);
}}
className="text-destructive"
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
>
<Trash2 className="mr-2 h-4 w-4" />
{checkingDependencies && screenToDelete?.screenId === screen.screenId
? "확인 중..."
: "삭제"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{screens.map((screen) => (
<TableRow
key={screen.screenId}
className={`cursor-pointer hover:bg-gray-50 ${
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
<TableCell>
<div>
<div className="font-medium text-gray-900">{screen.screenName}</div>
{screen.description && <div className="mt-1 text-sm text-gray-500">{screen.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell>
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell>
<Badge
variant={screen.isActive === "Y" ? "default" : "secondary"}
className={
screen.isActive === "Y" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}
>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell>
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
<div className="text-xs text-gray-400">{screen.createdBy}</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDesignScreen(screen)}>
<Palette className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleView(screen)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(screen)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(screen)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(screen)}
className="text-destructive"
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
>
<Trash2 className="mr-2 h-4 w-4" />
{checkingDependencies && screenToDelete?.screenId === screen.screenId
? "확인 중..."
: "삭제"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
))}
</TableBody>
</Table>
{filteredScreens.length === 0 && (
<div className="py-8 text-center text-gray-500"> .</div>
)}
</CardContent>
</Card>
{filteredScreens.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{screens.map((screen) => (
<div
key={screen.screenId}
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
}`}
onClick={() => handleScreenSelect(screen)}
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{screen.screenName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{screen.screenCode}</p>
</div>
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</div>
{/* 설명 */}
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{screen.tableLabel || screen.tableName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.createdDate.toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.createdBy}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDesignScreen(screen);
}}
className="h-9 flex-1 gap-2 text-sm"
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleView(screen);
}}
className="h-9 flex-1 gap-2 text-sm"
>
<Eye className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="outline" size="sm" className="h-9 px-3">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(screen);
}}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopy(screen);
}}
>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(screen);
}}
className="text-destructive"
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
>
<Trash2 className="mr-2 h-4 w-4" />
{checkingDependencies && screenToDelete?.screenId === screen.screenId ? "확인 중..." : "삭제"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{filteredScreens.length === 0 && (
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
</TabsContent>
{/* 휴지통 탭 */}
<TabsContent value="trash">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({deletedScreens.length})</span>
{selectedScreenIds.length > 0 && (
<Button variant="destructive" size="sm" onClick={handleBulkDelete} disabled={bulkDeleting}>
{bulkDeleting ? "삭제 중..." : `선택된 ${selectedScreenIds.length}개 영구삭제`}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="hover:bg-gray-50">
<TableCell>
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium text-gray-900">{screen.screenName}</div>
{screen.description && <div className="mt-1 text-sm text-gray-500">{screen.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell>
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell>
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
</TableCell>
<TableCell>
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
</TableCell>
<TableCell>
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
{screen.deleteReason || "-"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(screen)}
className="text-green-600 hover:text-green-700"
>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePermanentDelete(screen)}
className="text-destructive hover:text-red-700"
>
<Trash className="mr-1 h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{deletedScreens.length === 0 && (
<div className="py-8 text-center text-gray-500"> .</div>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<div className="flex items-center justify-between border-b p-6">
<h3 className="text-lg font-semibold"> ({deletedScreens.length})</h3>
{selectedScreenIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleting}
className="h-9 gap-2 text-sm font-medium"
>
<Trash className="h-4 w-4" />
{bulkDeleting ? "삭제 중..." : `선택된 ${selectedScreenIds.length}개 영구삭제`}
</Button>
)}
</CardContent>
</Card>
</div>
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 w-12">
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
/>
</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16">
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
aria-label={`${screen.screenName} 선택`}
/>
</TableCell>
<TableCell className="h-16">
<div>
<div className="font-medium">{screen.screenName}</div>
{screen.description && (
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
)}
</div>
</TableCell>
<TableCell className="h-16">
<Badge variant="outline" className="font-mono">
{screen.screenCode}
</Badge>
</TableCell>
<TableCell className="h-16">
<span className="text-muted-foreground font-mono text-sm">
{screen.tableLabel || screen.tableName}
</span>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
</TableCell>
<TableCell className="h-16">
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
{screen.deleteReason || "-"}
</div>
</TableCell>
<TableCell className="h-16">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(screen)}
className="text-primary hover:text-primary/80 h-9 gap-2 text-sm"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePermanentDelete(screen)}
className="h-9 gap-2 text-sm"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{deletedScreens.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="space-y-4 lg:hidden">
{/* 헤더 */}
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
<div className="flex items-center gap-3">
<Checkbox
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
/>
<h3 className="text-base font-semibold"> ({deletedScreens.length})</h3>
</div>
{selectedScreenIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleting}
className="h-9 gap-2 text-sm"
>
<Trash className="h-4 w-4" />
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}`}
</Button>
)}
</div>
{/* 카드 목록 */}
<div className="grid gap-4 sm:grid-cols-2">
{deletedScreens.map((screen) => (
<div key={screen.screenId} className="bg-card rounded-lg border p-4 shadow-sm">
{/* 헤더 */}
<div className="mb-4 flex items-start gap-3">
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
className="mt-1"
aria-label={`${screen.screenName} 선택`}
/>
<div className="flex-1">
<h3 className="text-base font-semibold">{screen.screenName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{screen.screenCode}</p>
</div>
</div>
{/* 설명 */}
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{screen.tableLabel || screen.tableName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.deletedDate?.toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{screen.deletedBy}</span>
</div>
{screen.deleteReason && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-medium">{screen.deleteReason}</span>
</div>
)}
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(screen)}
className="text-primary hover:text-primary/80 h-9 flex-1 gap-2 text-sm"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePermanentDelete(screen)}
className="h-9 flex-1 gap-2 text-sm"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{deletedScreens.length === 0 && (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
@ -719,12 +967,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<div className="max-h-60 overflow-y-auto">
<div className="space-y-3">
<h4 className="font-medium text-gray-900"> :</h4>
<h4 className="font-medium"> :</h4>
{dependencies.map((dep, index) => (
<div key={index} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">{dep.screenName}</div>
<div className="font-medium">{dep.screenName}</div>
<div className="text-muted-foreground text-sm"> : {dep.screenCode}</div>
</div>
<div className="text-right">
@ -734,7 +982,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{dep.referenceType === "url" && "URL 링크"}
{dep.referenceType === "menu_assignment" && "메뉴 할당"}
</div>
<div className="text-xs text-gray-500">
<div className="text-muted-foreground text-xs">
{dep.referenceType === "menu_assignment" ? "메뉴" : "컴포넌트"}: {dep.componentId}
</div>
</div>
@ -890,7 +1138,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> ...</div>
<div className="text-sm text-gray-500"> .</div>
<div className="text-muted-foreground text-sm"> .</div>
</div>
</div>
) : previewLayout && previewLayout.components ? (
@ -906,7 +1154,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
return (
<div
className="relative mx-auto rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
className="bg-card relative mx-auto rounded-xl border shadow-lg"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,
@ -1097,8 +1345,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-medium text-gray-600"> </div>
<div className="text-sm text-gray-500"> .</div>
<div className="text-muted-foreground mb-2 text-lg font-medium"> </div>
<div className="text-muted-foreground text-sm"> .</div>
</div>
</div>
)}