전체적인 스타일 수정
This commit is contained in:
parent
ec4d8f9b94
commit
0198426c46
|
|
@ -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) - 커스텀 드롭다운 + 좌우 레이아웃
|
||||
|
|
@ -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: 각 스타일 요소별 상세 가이드
|
||||
|
||||
|
|
@ -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,76 +170,84 @@ 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>
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>배치 추가</span>
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<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" />
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<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="pl-10"
|
||||
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>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<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>
|
||||
|
||||
{/* 배치 목록 */}
|
||||
<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">
|
||||
<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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
|
||||
<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}
|
||||
|
|
@ -255,7 +255,6 @@ export default function BatchManagementPage() {
|
|||
executingBatch={executingBatch}
|
||||
onExecute={executeBatch}
|
||||
onToggleStatus={(batchId, currentStatus) => {
|
||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus });
|
||||
toggleBatchStatus(batchId, currentStatus);
|
||||
}}
|
||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||
|
|
@ -265,29 +264,28 @@ export default function BatchManagementPage() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<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 space-x-1">
|
||||
<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"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="h-10 min-w-[40px] text-sm"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
|
|
@ -299,6 +297,7 @@ export default function BatchManagementPage() {
|
|||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
|
|
@ -307,58 +306,62 @@ export default function BatchManagementPage() {
|
|||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{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">
|
||||
<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 */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||
<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 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 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 */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||||
<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 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 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>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
<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>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
{/* 반응형 레이아웃: 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">
|
||||
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
||||
<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} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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">
|
||||
📋 코드 상세 정보
|
||||
{/* 우측: 코드 상세 패널 */}
|
||||
<div className="min-w-0 flex-1 lg:pl-0">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
코드 상세 정보
|
||||
{selectedCategoryCode && (
|
||||
<span className="text-muted-foreground text-sm font-normal">({selectedCategoryCode})</span>
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
</h2>
|
||||
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,102 +126,108 @@ export default function DashboardListPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex h-full items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-medium text-gray-900">로딩 중...</div>
|
||||
<div className="mt-2 text-sm text-gray-500">대시보드 목록을 불러오고 있습니다</div>
|
||||
<div className="text-sm font-medium">로딩 중...</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">대시보드 목록을 불러오고 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">대시보드 관리</h1>
|
||||
<p className="mt-2 text-sm 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="mb-6 flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
{/* 검색 및 액션 */}
|
||||
<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="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-9"
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 대시보드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</Card>
|
||||
<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={() => setError(null)}
|
||||
className="text-destructive transition-colors hover:text-destructive/80"
|
||||
aria-label="에러 메시지 닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{dashboards.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
||||
<Plus className="h-12 w-12 text-gray-400" />
|
||||
<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>
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">대시보드가 없습니다</h3>
|
||||
<p className="mb-6 text-sm text-gray-500">첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요</p>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="rounded-lg border bg-card shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>제목</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>수정일</TableHead>
|
||||
<TableHead className="w-[80px]">작업</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">생성일</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="cursor-pointer hover:bg-gray-50">
|
||||
<TableCell className="font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="max-w-md truncate text-sm text-gray-500">
|
||||
<TableRow key={dashboard.id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="h-16 max-w-md truncate text-sm text-muted-foreground">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16 text-sm text-muted-foreground">{formatDate(dashboard.createdAt)}</TableCell>
|
||||
<TableCell className="h-16 text-sm text-muted-foreground">{formatDate(dashboard.updatedAt)}</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<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"
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2">
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="gap-2 text-red-600 focus:text-red-600"
|
||||
className="gap-2 text-sm text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
|
|
@ -233,23 +239,27 @@ export default function DashboardListPage() {
|
|||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<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">
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-red-600 hover:bg-red-700">
|
||||
<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="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
@ -258,16 +268,18 @@ export default function DashboardListPage() {
|
|||
|
||||
{/* 성공 모달 */}
|
||||
<Dialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">완료</DialogTitle>
|
||||
<DialogDescription className="text-center">{successMessage}</DialogDescription>
|
||||
<DialogTitle className="text-center text-base sm:text-lg">완료</DialogTitle>
|
||||
<DialogDescription className="text-center text-xs sm:text-sm">{successMessage}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button onClick={() => setSuccessDialogOpen(false)}>확인</Button>
|
||||
<Button onClick={() => setSuccessDialogOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,47 +161,44 @@ 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 min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<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>
|
||||
<Button onClick={handleAddConfig} className="flex items-center gap-2">
|
||||
<Plus size={16} />새 외부 호출 추가
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<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">
|
||||
{/* 검색 및 필터 영역 */}
|
||||
<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>
|
||||
<Button onClick={handleSearch} variant="outline">
|
||||
<Search size={16} />
|
||||
</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={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>
|
||||
{/* 두 번째 줄: 필터 */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Select
|
||||
value={filter.call_type || "all"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -211,8 +208,8 @@ export default function ExternalCallConfigsPage() {
|
|||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="호출 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
|
|
@ -223,10 +220,7 @@ export default function ExternalCallConfigsPage() {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">API 타입</label>
|
||||
<Select
|
||||
value={filter.api_type || "all"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -236,8 +230,8 @@ export default function ExternalCallConfigsPage() {
|
|||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="API 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
|
|
@ -248,10 +242,7 @@ export default function ExternalCallConfigsPage() {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">상태</label>
|
||||
<Select
|
||||
value={filter.is_active || "Y"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -261,8 +252,8 @@ export default function ExternalCallConfigsPage() {
|
|||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||
|
|
@ -274,92 +265,97 @@ export default function ExternalCallConfigsPage() {
|
|||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 설정 목록 */}
|
||||
<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>
|
||||
<Button size="sm" variant="outline" onClick={() => handleEditConfig(config)} title="편집">
|
||||
<Edit 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"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEditConfig(config)}
|
||||
title="편집"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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,24 +251,22 @@ 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="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">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<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="w-64 pl-10"
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DB 타입 필터 */}
|
||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="DB 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -284,7 +280,7 @@ export default function ExternalConnectionsPage() {
|
|||
|
||||
{/* 활성 상태 필터 */}
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -298,121 +294,109 @@ export default function ExternalConnectionsPage() {
|
|||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<Button onClick={handleAddConnection} className="shrink-0">
|
||||
<Plus className="mr-2 h-4 w-4" />새 연결 추가
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 연결 목록 */}
|
||||
{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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,107 +277,128 @@ 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>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<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>
|
||||
|
||||
{/* 플로우 카드 목록 */}
|
||||
{loading ? (
|
||||
<div className="py-8 text-center sm:py-12">
|
||||
<p className="text-muted-foreground text-sm sm:text-base">로딩 중...</p>
|
||||
<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 ? (
|
||||
<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" />첫 플로우 만들기
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 lg:gap-6 xl:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<Card
|
||||
<div
|
||||
key={flow.id}
|
||||
className="cursor-pointer transition-shadow hover:shadow-lg"
|
||||
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors"
|
||||
onClick={() => handleEdit(flow.id)}
|
||||
>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* 헤더 */}
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -415,7 +444,7 @@ export default function FlowManagementPage() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="internal">내부 데이터베이스</SelectItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||
</SelectItem>
|
||||
|
|
@ -554,7 +583,7 @@ export default function FlowManagementPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
정말로 "{selectedFlow?.name}" 플로우를 삭제하시겠습니까?
|
||||
정말로 “{selectedFlow?.name}” 플로우를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -581,5 +610,9 @@ export default function FlowManagementPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,40 +62,28 @@ 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}
|
||||
|
|
@ -107,20 +92,26 @@ export default function ScreenManagementPage() {
|
|||
goToNextStep("design");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 템플릿 관리 단계 */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -20,7 +18,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
|||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
// 가상화 스크롤링을 위한 간단한 구현
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
|
|
@ -541,19 +539,21 @@ 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 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">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
|
||||
</p>
|
||||
{isSuperAdmin && (
|
||||
<p className="mt-1 text-sm font-medium text-blue-600">
|
||||
🔧 최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
||||
<p className="mt-1 text-sm font-medium text-primary">
|
||||
최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -564,67 +564,65 @@ export default function TableManagementPage() {
|
|||
<>
|
||||
<Button
|
||||
onClick={() => setCreateTableModalOpen(true)}
|
||||
className="bg-green-600 text-white hover:bg-green-700"
|
||||
size="sm"
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
size="default"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />새 테이블 생성
|
||||
<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 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" size="sm">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
<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={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
|
||||
<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>
|
||||
</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" />
|
||||
<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="h-5 w-5 text-muted-foreground" />
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</h2>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="mb-4">
|
||||
<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="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<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">
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
|
||||
</span>
|
||||
</div>
|
||||
) : tables.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -637,88 +635,84 @@ export default function TableManagementPage() {
|
|||
.map((table) => (
|
||||
<div
|
||||
key={table.tableName}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all ${
|
||||
selectedTable === table.tableName
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
? "shadow-md"
|
||||
: "hover:shadow-md"
|
||||
}`}
|
||||
onClick={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
|
||||
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<span className="text-xs text-muted-foreground">컬럼</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{table.columnCount}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 타입 관리 */}
|
||||
<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" />
|
||||
{/* 우측 메인 영역: 컬럼 타입 관리 (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="h-5 w-5 text-muted-foreground" />
|
||||
{selectedTable ? <>테이블 설정 - {selectedTable}</> : "테이블 타입 관리"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</h2>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!selectedTable ? (
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<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">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={tableLabel}
|
||||
onChange={(e) => setTableLabel(e.target.value)}
|
||||
placeholder="테이블 표시명을 입력하세요"
|
||||
placeholder="테이블 표시명"
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">설명</label>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={tableDescription}
|
||||
onChange={(e) => setTableDescription(e.target.value)}
|
||||
placeholder="테이블 설명을 입력하세요"
|
||||
placeholder="테이블 설명"
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{columnsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
||||
</span>
|
||||
</div>
|
||||
) : columns.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{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="flex items-center border-b pb-2 text-sm font-semibold text-foreground">
|
||||
<div className="w-40 px-4">컬럼명</div>
|
||||
<div className="w-48 px-4">라벨</div>
|
||||
<div className="w-48 px-4">입력 타입</div>
|
||||
|
|
@ -730,7 +724,7 @@ export default function TableManagementPage() {
|
|||
|
||||
{/* 컬럼 리스트 */}
|
||||
<div
|
||||
className="max-h-96 overflow-y-auto rounded-lg border border-gray-200"
|
||||
className="max-h-96 overflow-y-auto rounded-lg border"
|
||||
onScroll={(e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
||||
|
|
@ -742,17 +736,17 @@ export default function TableManagementPage() {
|
|||
{columns.map((column, index) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="flex items-center border-b border-gray-200 py-2 hover:bg-gray-50"
|
||||
className="flex items-center border-b py-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="w-40 px-4">
|
||||
<div className="font-mono text-sm text-gray-700">{column.columnName}</div>
|
||||
<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-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48 px-4">
|
||||
|
|
@ -760,7 +754,7 @@ export default function TableManagementPage() {
|
|||
value={column.inputType || "text"}
|
||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -779,7 +773,7 @@ export default function TableManagementPage() {
|
|||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -794,23 +788,23 @@ export default function TableManagementPage() {
|
|||
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<div className="space-y-1">
|
||||
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
|
||||
{/* Entity 타입 설정 - 가로 배치 */}
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-blue-800">Entity 설정</span>
|
||||
<span className="text-xs font-medium text-primary">Entity 설정</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* 참조 테이블 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-600">참조 테이블</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">참조 테이블</label>
|
||||
<Select
|
||||
value={column.referenceTable || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 bg-white text-xs">
|
||||
<SelectTrigger className="h-8 bg-background text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -818,7 +812,7 @@ export default function TableManagementPage() {
|
|||
<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>
|
||||
<span className="text-xs text-muted-foreground">{option.value}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -829,7 +823,7 @@ export default function TableManagementPage() {
|
|||
{/* 조인 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-600">조인 컬럼</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">조인 컬럼</label>
|
||||
<Select
|
||||
value={column.referenceColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -840,7 +834,7 @@ export default function TableManagementPage() {
|
|||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 bg-white text-xs">
|
||||
<SelectTrigger className="h-8 bg-background text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -857,7 +851,7 @@ export default function TableManagementPage() {
|
|||
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 className="h-3 w-3 animate-spin rounded-full border border-primary border-t-transparent"></div>
|
||||
로딩중
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -875,8 +869,8 @@ export default function TableManagementPage() {
|
|||
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>
|
||||
<div className="mt-1 flex items-center gap-1 rounded bg-primary/10 px-2 py-1 text-xs text-primary">
|
||||
<span>✓</span>
|
||||
<span className="truncate">
|
||||
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
||||
</span>
|
||||
|
|
@ -887,7 +881,7 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
{/* 다른 웹 타입인 경우 빈 공간 */}
|
||||
{column.inputType !== "code" && column.inputType !== "entity" && (
|
||||
<div className="flex h-7 items-center text-xs text-gray-400">-</div>
|
||||
<div className="flex h-8 items-center text-xs text-muted-foreground">-</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-80 px-4">
|
||||
|
|
@ -895,7 +889,7 @@ export default function TableManagementPage() {
|
|||
value={column.description || ""}
|
||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -906,12 +900,12 @@ export default function TableManagementPage() {
|
|||
{columnsLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-gray-500">더 많은 컬럼 로딩 중...</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground">더 많은 컬럼 로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{columns.length} / {totalColumns} 컬럼 표시됨
|
||||
</div>
|
||||
|
||||
|
|
@ -920,7 +914,7 @@ export default function TableManagementPage() {
|
|||
<Button
|
||||
onClick={saveAllSettings}
|
||||
disabled={!selectedTable || columns.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
전체 설정 저장
|
||||
|
|
@ -930,8 +924,9 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DDL 모달 컴포넌트들 */}
|
||||
|
|
@ -974,6 +969,10 @@ export default function TableManagementPage() {
|
|||
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 line-clamp-1 leading-tight h-3 flex items-start">
|
||||
{batch.description || '\u00A0'}
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{batch.description || '설명 없음'}
|
||||
</p>
|
||||
</div>
|
||||
<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})
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs sm:text-sm">
|
||||
{type.label}
|
||||
</span>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -92,55 +92,55 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="border-b p-4">
|
||||
<div className="flex h-full flex-col space-y-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 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>
|
||||
<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 space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnly"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<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>
|
||||
</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">
|
||||
<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}`}
|
||||
|
|
@ -151,19 +151,18 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
|||
onDelete={() => handleDeleteCategory(category.category_code)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 추가 로딩 표시 */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,61 +129,60 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="border-b p-4">
|
||||
<div className="flex h-full flex-col space-y-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 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>
|
||||
<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 space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnlyCodes"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<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>
|
||||
</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">
|
||||
<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}`}
|
||||
|
|
@ -195,12 +192,11 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
onDelete={() => handleDeleteCode(code)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragAndDrop.activeItem ? (
|
||||
<div className="cursor-grabbing rounded-lg border border-gray-300 bg-white p-3 shadow-lg">
|
||||
<div className="cursor-grabbing rounded-lg border bg-card p-4 shadow-lg">
|
||||
{(() => {
|
||||
const activeCode = dragAndDrop.activeItem;
|
||||
if (!activeCode) return null;
|
||||
|
|
@ -208,30 +204,24 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
<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">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{activeCode.codeName || activeCode.code_name}
|
||||
</h3>
|
||||
</h4>
|
||||
<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">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{activeCode.codeValue || activeCode.code_value}
|
||||
</p>
|
||||
{activeCode.description && (
|
||||
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{activeCode.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -241,19 +231,18 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* 무한 스크롤 로딩 인디케이터 */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,54 @@ 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>
|
||||
<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>
|
||||
{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>
|
||||
<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="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
||||
<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>
|
||||
|
|
@ -78,77 +88,84 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
</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="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 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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 데이터 렌더링
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<>
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow>
|
||||
<TableHeader>
|
||||
<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="w-[120px]">작업</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">디스크 사용량</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">작업</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>
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onEdit(company)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="수정"
|
||||
className="h-8 w-8"
|
||||
aria-label="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onDelete(company)}
|
||||
className="text-destructive hover:text-destructive h-8 w-8 p-0 hover:font-bold"
|
||||
title="삭제"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -159,5 +176,58 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -42,57 +46,57 @@ 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>
|
||||
|
||||
<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" />
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">총 회사</p>
|
||||
<p className="text-xs text-muted-foreground">총 회사</p>
|
||||
<p className="text-lg font-semibold">{summary.totalCompanies}개</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 파일 수 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4 text-green-500" />
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">총 파일</p>
|
||||
<p className="text-xs text-muted-foreground">총 파일</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" />
|
||||
<HardDrive className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">총 용량</p>
|
||||
<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-gray-500" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">마지막 확인</p>
|
||||
<p className="text-xs text-muted-foreground">마지막 확인</p>
|
||||
<p className="text-xs font-medium">
|
||||
{lastCheckedDate.toLocaleString("ko-KR", {
|
||||
month: "short",
|
||||
|
|
@ -108,7 +112,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
|||
{/* 용량 기준 상태 표시 */}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">저장소 상태</span>
|
||||
<span className="text-xs text-muted-foreground">저장소 상태</span>
|
||||
<Badge
|
||||
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
|
||||
>
|
||||
|
|
@ -117,22 +121,21 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
|||
</div>
|
||||
|
||||
{/* 간단한 진행 바 */}
|
||||
<div className="mt-2 h-2 w-full rounded-full bg-gray-200">
|
||||
<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/100" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
|
||||
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="text-muted-foreground mt-1 flex justify-between text-xs">
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>0 MB</span>
|
||||
<span>2,000 MB (권장 최대)</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,38 +801,24 @@ 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>
|
||||
|
||||
{/* 메뉴 관리 탭 */}
|
||||
<TabsContent value="menus" className="flex-1 overflow-hidden">
|
||||
<div className="flex h-full">
|
||||
{/* 메인 컨텐츠 - 2:8 비율 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex h-full gap-6">
|
||||
{/* 좌측 사이드바 - 메뉴 타입 선택 (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"
|
||||
<div className="w-[20%] border-r pr-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{getUITextSync("menu.type.title")}</h3>
|
||||
|
||||
{/* 메뉴 타입 선택 카드들 */}
|
||||
<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")}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -844,20 +826,18 @@ export const MenuManagement: React.FC = () => {
|
|||
{adminMenus.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-gray-300"
|
||||
<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")}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -865,33 +845,30 @@ export const MenuManagement: React.FC = () => {
|
|||
{userMenus.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<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")}
|
||||
</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>
|
||||
</h2>
|
||||
|
||||
{/* 오른쪽: 검색 + 버튼 */}
|
||||
<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="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"
|
||||
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"
|
||||
|
|
@ -912,8 +889,7 @@ export const MenuManagement: React.FC = () => {
|
|||
</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="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")}
|
||||
|
|
@ -924,10 +900,9 @@ export const MenuManagement: React.FC = () => {
|
|||
/>
|
||||
</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"
|
||||
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);
|
||||
|
|
@ -937,7 +912,7 @@ export const MenuManagement: React.FC = () => {
|
|||
{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"
|
||||
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setSelectedCompany("*");
|
||||
setIsCompanyDropdownOpen(false);
|
||||
|
|
@ -957,7 +932,7 @@ export const MenuManagement: React.FC = () => {
|
|||
.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"
|
||||
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);
|
||||
|
|
@ -973,16 +948,18 @@ export const MenuManagement: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
|
||||
{/* 검색 입력 */}
|
||||
<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>
|
||||
|
||||
<div className="flex items-end">
|
||||
{/* 초기화 버튼 */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchText("");
|
||||
|
|
@ -990,35 +967,23 @@ export const MenuManagement: React.FC = () => {
|
|||
setCompanySearchText("");
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
{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]">
|
||||
{/* 최상위 메뉴 추가 */}
|
||||
<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="min-w-[120px]"
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
|
|
@ -1034,6 +999,9 @@ export const MenuManagement: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MenuTable
|
||||
menus={getCurrentMenus()}
|
||||
title=""
|
||||
|
|
@ -1048,26 +1016,9 @@ export const MenuManagement: React.FC = () => {
|
|||
uiTexts={uiTexts}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 화면 할당 탭 */}
|
||||
<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>
|
||||
|
||||
<MenuFormModal
|
||||
isOpen={formModalOpen}
|
||||
|
|
@ -1095,7 +1046,6 @@ export const MenuManagement: React.FC = () => {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -202,24 +202,22 @@ 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="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">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<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="w-64 pl-10"
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입 필터 */}
|
||||
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="인증 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -233,7 +231,7 @@ export function RestApiConnectionList() {
|
|||
|
||||
{/* 활성 상태 필터 */}
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -247,121 +245,109 @@ export function RestApiConnectionList() {
|
|||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<Button onClick={handleAddConnection} className="shrink-0">
|
||||
<Plus className="mr-2 h-4 w-4" />새 연결 추가
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 연결 목록 */}
|
||||
{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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,30 +98,32 @@ 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>
|
||||
<TableRow className="border-b 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}>
|
||||
<TableRow key={index} className="border-b">
|
||||
{USER_TABLE_COLUMNS.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
<TableCell key={column.key} className="h-16">
|
||||
<div className="h-4 animate-pulse rounded bg-muted"></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>
|
||||
<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>
|
||||
|
|
@ -130,69 +132,84 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
|
|||
</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="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>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 데이터 렌더링
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<>
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow>
|
||||
<TableHeader>
|
||||
<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>
|
||||
{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}>
|
||||
<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>{formatDate(user.regDate || "")}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<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)}
|
||||
|
|
@ -200,22 +217,22 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
|
|||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<TableCell className="h-16">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8"
|
||||
title="비밀번호 초기화"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => handleOpenHistoryModal(user)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8"
|
||||
title="변경이력 조회"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
|
|
@ -226,6 +243,96 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
|
|||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* 상태 변경 확인 모달 */}
|
||||
<UserStatusConfirmDialog
|
||||
|
|
@ -243,6 +350,6 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
|
|||
userId={historyModal.userId}
|
||||
userName={historyModal.userName}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<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="border-t pt-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium">고급 검색 옵션</h4>
|
||||
<span className="text-muted-foreground text-xs">(각 필드별로 개별 검색 조건을 설정할 수 있습니다)</span>
|
||||
<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>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
||||
<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>
|
||||
|
||||
<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)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</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)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
|
@ -207,22 +208,5 @@ 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -146,80 +146,164 @@ 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="space-y-1">
|
||||
<h2 className="text-xl font-semibold">플로우 목록</h2>
|
||||
<p className="text-sm text-muted-foreground">저장된 노드 플로우를 불러오거나 새로운 플로우를 생성합니다</p>
|
||||
</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 top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<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="w-80 pl-10"
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onLoadFlow(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 플로우 생성
|
||||
</Button>
|
||||
</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>
|
||||
{/* 액션 버튼 영역 */}
|
||||
<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="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
<>
|
||||
{/* 데스크톱 테이블 스켈레톤 */}
|
||||
<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>
|
||||
<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>
|
||||
<TableHead>플로우명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead className="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">설명</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 hover:bg-gray-50"
|
||||
className="cursor-pointer border-b transition-colors hover:bg-muted/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" />
|
||||
<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>
|
||||
<div className="text-sm text-gray-500">{flow.flowDescription || "설명 없음"}</div>
|
||||
<TableCell className="h-16 text-sm">
|
||||
<div className="text-muted-foreground">{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" />
|
||||
<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>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
<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 onClick={(e) => e.stopPropagation()}>
|
||||
<TableCell className="h-16" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -248,37 +332,94 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
|||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (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>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<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">
|
||||
“{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1723,12 +1723,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 첫 번째 컴포넌트 선택
|
||||
if (newComponents.length > 0) {
|
||||
setSelectedComponent(newComponents[0]);
|
||||
openPanel("properties");
|
||||
}
|
||||
|
||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
||||
);
|
||||
|
||||
// 레이아웃 드래그 처리
|
||||
|
|
@ -1792,11 +1791,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 레이아웃 컴포넌트 선택
|
||||
setSelectedComponent(newLayoutComponent);
|
||||
openPanel("properties");
|
||||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
|
||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
|
||||
);
|
||||
|
||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||
|
|
@ -2127,11 +2125,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 새 컴포넌트 선택
|
||||
setSelectedComponent(newComponent);
|
||||
openPanel("properties");
|
||||
|
||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
||||
);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
|
|
@ -2609,14 +2606,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setSelectedComponent(newComponent);
|
||||
|
||||
// 속성 패널 자동 열기
|
||||
openPanel("properties");
|
||||
} catch (error) {
|
||||
// console.error("드롭 처리 실패:", error);
|
||||
}
|
||||
},
|
||||
[layout, gridInfo, saveToHistory, openPanel],
|
||||
[layout, gridInfo, saveToHistory],
|
||||
);
|
||||
|
||||
// 파일 컴포넌트 업데이트 처리
|
||||
|
|
|
|||
|
|
@ -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,89 +428,107 @@ 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>
|
||||
{/* 데스크톱 테이블 뷰 (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>
|
||||
<TableHead>화면명</TableHead>
|
||||
<TableHead>화면 코드</TableHead>
|
||||
<TableHead>테이블명</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
<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={`cursor-pointer hover:bg-gray-50 ${
|
||||
className={`hover:bg-muted/50 border-b transition-colors ${
|
||||
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => handleScreenSelect(screen)}
|
||||
>
|
||||
<TableCell>
|
||||
<TableCell className="h-16 cursor-pointer">
|
||||
<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 className="font-medium">{screen.screenName}</div>
|
||||
{screen.description && (
|
||||
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{screen.screenCode}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<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"
|
||||
}
|
||||
>
|
||||
<TableCell className="h-16">
|
||||
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
||||
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
|
||||
<div className="text-xs text-gray-400">{screen.createdBy}</div>
|
||||
<div className="text-muted-foreground text-xs">{screen.createdBy}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDesignScreen(screen)}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDesignScreen(screen);
|
||||
}}
|
||||
>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
화면 설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleView(screen)}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(screen);
|
||||
}}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
미리보기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(screen)}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(screen);
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(screen)}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(screen);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(screen)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(screen);
|
||||
}}
|
||||
className="text-destructive"
|
||||
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
|
||||
>
|
||||
|
|
@ -525,98 +546,223 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</Table>
|
||||
|
||||
{filteredScreens.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">검색 결과가 없습니다.</div>
|
||||
<div className="flex h-64 flex-col items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
{/* 데스크톱 테이블 뷰 (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}>
|
||||
<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>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<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>화면명</TableHead>
|
||||
<TableHead>화면 코드</TableHead>
|
||||
<TableHead>테이블명</TableHead>
|
||||
<TableHead>삭제일</TableHead>
|
||||
<TableHead>삭제자</TableHead>
|
||||
<TableHead>삭제 사유</TableHead>
|
||||
<TableHead>작업</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-gray-50">
|
||||
<TableCell>
|
||||
<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>
|
||||
<TableCell className="h-16">
|
||||
<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 className="font-medium">{screen.screenName}</div>
|
||||
{screen.description && (
|
||||
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{screen.screenCode}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{screen.tableLabel || screen.tableName}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="h-16">
|
||||
<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">
|
||||
<TableCell className="h-16">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRestore(screen)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
className="text-primary hover:text-primary/80 h-9 gap-2 text-sm"
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
복원
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handlePermanentDelete(screen)}
|
||||
className="text-destructive hover:text-red-700"
|
||||
className="h-9 gap-2 text-sm"
|
||||
>
|
||||
<Trash className="mr-1 h-3 w-3" />
|
||||
<Trash className="h-4 w-4" />
|
||||
영구삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -627,10 +773,112 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</Table>
|
||||
|
||||
{deletedScreens.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">휴지통이 비어있습니다.</div>
|
||||
<div className="flex h-64 flex-col items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">휴지통이 비어있습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue