Compare commits

..

No commits in common. "458e1018b065235eb0745338702981e2e18ce868" and "45de532b8485e4f149ed9de33b128292894bb76c" have entirely different histories.

37 changed files with 3448 additions and 5278 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,8 +9,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react"; import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
@ -31,7 +32,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function FlowManagementPage() { export default function FlowManagementPage() {
const router = useRouter(); const router = useRouter();
@ -45,15 +45,11 @@ export default function FlowManagementPage() {
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null); const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
// 테이블 목록 관련 상태 // 테이블 목록 관련 상태
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string; description?: string }>>( const [tableList, setTableList] = useState<any[]>([]); // 내부 DB 테이블
[],
);
const [loadingTables, setLoadingTables] = useState(false); const [loadingTables, setLoadingTables] = useState(false);
const [openTableCombobox, setOpenTableCombobox] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
const [externalConnections, setExternalConnections] = useState< const [externalConnections, setExternalConnections] = useState<any[]>([]);
Array<{ id: number; connection_name: string; db_type: string }>
>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]); const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false); const [loadingExternalTables, setLoadingExternalTables] = useState(false);
@ -78,10 +74,10 @@ export default function FlowManagementPage() {
variant: "destructive", variant: "destructive",
}); });
} }
} catch (error) { } catch (error: any) {
toast({ toast({
title: "오류 발생", title: "오류 발생",
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", description: error.message,
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
@ -91,7 +87,6 @@ export default function FlowManagementPage() {
useEffect(() => { useEffect(() => {
loadFlows(); loadFlows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 테이블 목록 로드 (내부 DB) // 테이블 목록 로드 (내부 DB)
@ -133,8 +128,7 @@ export default function FlowManagementPage() {
if (data.success && data.data) { if (data.success && data.data) {
// 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링 // 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
const filtered = data.data.filter( const filtered = data.data.filter(
(conn: { connection_name: string }) => (conn: any) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
!conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
); );
setExternalConnections(filtered); setExternalConnections(filtered);
} }
@ -170,9 +164,7 @@ export default function FlowManagementPage() {
if (data.success && data.data) { if (data.success && data.data) {
const tables = Array.isArray(data.data) ? data.data : []; const tables = Array.isArray(data.data) ? data.data : [];
const tableNames = tables const tableNames = tables
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => .map((t: any) => (typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name))
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
)
.filter(Boolean); .filter(Boolean);
setExternalTableList(tableNames); setExternalTableList(tableNames);
} else { } else {
@ -232,10 +224,10 @@ export default function FlowManagementPage() {
variant: "destructive", variant: "destructive",
}); });
} }
} catch (error) { } catch (error: any) {
toast({ toast({
title: "오류 발생", title: "오류 발생",
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", description: error.message,
variant: "destructive", variant: "destructive",
}); });
} }
@ -262,10 +254,10 @@ export default function FlowManagementPage() {
variant: "destructive", variant: "destructive",
}); });
} }
} catch (error) { } catch (error: any) {
toast({ toast({
title: "오류 발생", title: "오류 발생",
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", description: error.message,
variant: "destructive", variant: "destructive",
}); });
} }
@ -277,128 +269,107 @@ export default function FlowManagementPage() {
}; };
return ( return (
<div className="bg-background flex min-h-screen flex-col"> <div className="container mx-auto space-y-4 p-3 sm:space-y-6 sm:p-4 lg:p-6">
<div className="space-y-6 p-4 sm:p-6"> {/* 헤더 */}
{/* 페이지 헤더 */} <div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="space-y-2 border-b pb-4"> <div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="flex items-center gap-2 text-xl font-bold sm:text-2xl lg:text-3xl">
<p className="text-muted-foreground text-sm"> </p> <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> </div>
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
{/* 액션 버튼 영역 */} <Plus className="mr-2 h-4 w-4" />
<div className="flex items-center justify-end"> <span className="hidden sm:inline"> </span>
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium"> <span className="sm:hidden"></span>
<Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* 플로우 카드 목록 */} {/* 플로우 카드 목록 */}
{loading ? ( {loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="py-8 text-center sm:py-12">
{Array.from({ length: 6 }).map((_, index) => ( <p className="text-muted-foreground text-sm sm:text-base"> ...</p>
<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> </div>
) : flows.length === 0 ? ( ) : flows.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm"> <Card>
<div className="flex flex-col items-center gap-2 text-center"> <CardContent className="py-8 text-center sm:py-12">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full"> <Workflow className="text-muted-foreground mx-auto mb-3 h-10 w-10 sm:mb-4 sm:h-12 sm:w-12" />
<Workflow className="text-muted-foreground h-8 w-8" /> <p className="text-muted-foreground mb-3 text-sm sm:mb-4 sm:text-base"> </p>
</div> <Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
<h3 className="text-lg font-semibold"> </h3> <Plus className="mr-2 h-4 w-4" />
<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> </Button>
</div> </CardContent>
</div> </Card>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 lg:gap-6 xl:grid-cols-3">
{flows.map((flow) => ( {flows.map((flow) => (
<div <Card
key={flow.id} key={flow.id}
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors" className="cursor-pointer transition-shadow hover:shadow-lg"
onClick={() => handleEdit(flow.id)} onClick={() => handleEdit(flow.id)}
> >
{/* 헤더 */} <CardHeader className="p-4 sm:p-6">
<div className="mb-4 flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <CardTitle className="flex flex-col gap-1 text-base sm:flex-row sm:items-center sm:gap-2 sm:text-lg">
<h3 className="truncate text-base font-semibold">{flow.name}</h3> <span className="truncate">{flow.name}</span>
{flow.isActive && ( {flow.isActive && (
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600"></Badge> <Badge variant="success" className="self-start">
</Badge>
)} )}
</CardTitle>
<CardDescription className="mt-1 line-clamp-2 text-xs sm:mt-2 sm:text-sm">
{flow.description || "설명 없음"}
</CardDescription>
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p> </div>
</CardHeader>
<CardContent className="p-4 pt-0 sm:p-6">
<div className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<Table className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">{flow.tableName}</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<User className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">: {flow.createdBy}</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<Calendar className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
</div> </div>
</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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-9 flex-1 gap-2 text-sm" className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleEdit(flow.id); handleEdit(flow.id);
}} }}
> >
<Edit2 className="h-4 w-4" /> <Edit2 className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-9 w-9 p-0" className="h-8 px-2 text-xs sm:h-9 sm:px-3 sm:text-sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedFlow(flow); setSelectedFlow(flow);
setIsDeleteDialogOpen(true); setIsDeleteDialogOpen(true);
}} }}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
</div> </div>
</div> </CardContent>
</Card>
))} ))}
</div> </div>
)} )}
@ -444,7 +415,7 @@ export default function FlowManagementPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="internal"> </SelectItem> <SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn) => ( {externalConnections.map((conn: any) => (
<SelectItem key={conn.id} value={conn.id.toString()}> <SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()}) {conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem> </SelectItem>
@ -583,7 +554,7 @@ export default function FlowManagementPage() {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle> <DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
&ldquo;{selectedFlow?.name}&rdquo; ? "{selectedFlow?.name}" ?
<br /> . <br /> .
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -610,9 +581,5 @@ export default function FlowManagementPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
); );
} }

View File

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

View File

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

View File

@ -1,11 +1,13 @@
"use client"; "use client";
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Database, RefreshCw, Settings, Plus, Activity } from "lucide-react"; import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
@ -19,7 +21,7 @@ import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
import { TableLogViewer } from "@/components/admin/TableLogViewer"; import { TableLogViewer } from "@/components/admin/TableLogViewer";
import { ScrollToTop } from "@/components/common/ScrollToTop"; // 가상화 스크롤링을 위한 간단한 구현
interface TableInfo { interface TableInfo {
tableName: string; tableName: string;
@ -544,24 +546,19 @@ export default function TableManagementPage() {
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
return ( return (
<div className="bg-background flex min-h-screen flex-col"> <div className="w-full max-w-none space-y-8 px-4 py-8">
<div className="space-y-6 p-6"> {/* 페이지 제목 */}
{/* 페이지 헤더 */} <div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold text-gray-900">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1> </h1>
<p className="text-muted-foreground mt-2 text-sm"> <p className="mt-2 text-gray-600">
{getTextFromUI( {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
)}
</p> </p>
{isSuperAdmin && ( {isSuperAdmin && (
<p className="text-primary mt-1 text-sm font-medium"> <p className="mt-1 text-sm font-medium text-blue-600">
🔧
</p> </p>
)} )}
</div> </div>
@ -572,78 +569,67 @@ export default function TableManagementPage() {
<> <>
<Button <Button
onClick={() => setCreateTableModalOpen(true)} onClick={() => setCreateTableModalOpen(true)}
className="h-10 gap-2 text-sm font-medium" className="bg-green-600 text-white hover:bg-green-700"
size="default" size="sm"
> >
<Plus className="h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
{selectedTable && ( {selectedTable && (
<Button <Button onClick={() => setAddColumnModalOpen(true)} variant="outline" size="sm">
onClick={() => setAddColumnModalOpen(true)} <Plus className="mr-2 h-4 w-4" />
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button> </Button>
)} )}
<Button <Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" size="sm">
onClick={() => setDdlLogViewerOpen(true)} <Activity className="mr-2 h-4 w-4" />
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<Activity className="h-4 w-4" />
DDL DDL
</Button> </Button>
</> </>
)} )}
<Button <Button onClick={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
onClick={loadTables}
disabled={loading}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button> </Button>
</div> </div>
</div> </div>
</div>
<div className="flex h-full gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 좌측 사이드바: 테이블 목록 (20%) */} {/* 테이블 목록 */}
<div className="w-[20%] border-r pr-6"> <Card className="shadow-sm lg:col-span-1">
<div className="space-y-4"> <CardHeader className="bg-gray-50/50">
<h2 className="flex items-center gap-2 text-lg font-semibold"> <CardTitle className="flex items-center gap-2">
<Database className="text-muted-foreground h-5 w-5" /> <Database className="h-5 w-5 text-gray-600" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
</h2> </CardTitle>
</CardHeader>
<CardContent>
{/* 검색 */} {/* 검색 */}
<div className="mb-4">
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input <Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")} placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="pl-10"
/> />
</div> </div>
</div>
{/* 테이블 목록 */} {/* 테이블 목록 */}
<div className="space-y-3"> <div className="max-h-96 space-y-2 overflow-y-auto">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> <span className="ml-2 text-sm text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span> </span>
</div> </div>
) : tables.length === 0 ? ( ) : tables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="py-8 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div> </div>
) : ( ) : (
@ -656,82 +642,103 @@ export default function TableManagementPage() {
.map((table) => ( .map((table) => (
<div <div
key={table.tableName} key={table.tableName}
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${ className={`cursor-pointer rounded-lg border p-3 transition-colors ${
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md" selectedTable === table.tableName
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`} }`}
onClick={() => handleTableSelect(table.tableName)} onClick={() => handleTableSelect(table.tableName)}
> >
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4> <div className="flex items-center justify-between">
<p className="text-muted-foreground mt-1 text-xs"> <div className="flex-1">
<h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
<p className="text-sm text-gray-500">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p> </p>
<div className="mt-2 flex items-center justify-between border-t pt-2"> </div>
<span className="text-muted-foreground text-xs"></span> <div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary">
{table.columnCount} {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
</Badge> </Badge>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setLogViewerTableName(table.tableName);
setLogViewerOpen(true);
}}
title="변경 이력 조회"
>
<Activity className="h-4 w-4" />
</Button>
</div>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </CardContent>
</div> </Card>
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */} {/* 컬럼 타입 관리 */}
<div className="w-[80%] pl-0"> <Card className="shadow-sm lg:col-span-4">
<div className="flex h-full flex-col space-y-4"> <CardHeader className="bg-gray-50/50">
<h2 className="flex items-center gap-2 text-xl font-semibold"> <CardTitle className="flex items-center gap-2">
<Settings className="text-muted-foreground h-5 w-5" /> <Settings className="h-5 w-5 text-gray-600" />
{selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"} {selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"}
</h2> </CardTitle>
</CardHeader>
<div className="flex-1 overflow-hidden"> <CardContent>
{!selectedTable ? ( {!selectedTable ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm"> <div className="py-12 text-center text-gray-500">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p>
</div>
</div> </div>
) : ( ) : (
<> <>
{/* 테이블 라벨 설정 */} {/* 테이블 라벨 설정 */}
<div className="mb-4 flex items-center gap-4"> <div className="mb-6 space-y-4 rounded-lg border border-gray-200 p-4">
<div className="flex-1"> <h3 className="text-lg font-medium text-gray-900"> </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700"> ( )</label>
<Input value={selectedTable} disabled className="bg-gray-50" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700"></label>
<Input <Input
value={tableLabel} value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)} onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명" placeholder="테이블 표시명을 입력하세요"
className="h-10 text-sm"
/> />
</div> </div>
<div className="flex-1"> <div className="md:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700"></label>
<Input <Input
value={tableDescription} value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)} onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명" placeholder="테이블 설명을 입력하세요"
className="h-10 text-sm"
/> />
</div> </div>
</div> </div>
</div>
{columnsLoading ? ( {columnsLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> <span className="ml-2 text-sm text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span> </span>
</div> </div>
) : columns.length === 0 ? ( ) : columns.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="py-8 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* 컬럼 헤더 */} {/* 컬럼 헤더 */}
<div className="text-foreground flex items-center border-b pb-2 text-sm font-semibold"> <div className="flex items-center border-b border-gray-200 pb-2 text-sm font-medium text-gray-700">
<div className="w-40 px-4"></div> <div className="w-40 px-4"></div>
<div className="w-48 px-4"></div> <div className="w-48 px-4"></div>
<div className="w-48 px-4"> </div> <div className="w-48 px-4"> </div>
@ -743,7 +750,7 @@ export default function TableManagementPage() {
{/* 컬럼 리스트 */} {/* 컬럼 리스트 */}
<div <div
className="max-h-96 overflow-y-auto rounded-lg border" className="max-h-96 overflow-y-auto rounded-lg border border-gray-200"
onScroll={(e) => { onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드 // 스크롤이 끝에 가까워지면 더 많은 데이터 로드
@ -755,17 +762,17 @@ export default function TableManagementPage() {
{columns.map((column, index) => ( {columns.map((column, index) => (
<div <div
key={column.columnName} key={column.columnName}
className="hover:bg-muted/50 flex items-center border-b py-2 transition-colors" className="flex items-center border-b border-gray-200 py-2 hover:bg-gray-50"
> >
<div className="w-40 px-4"> <div className="w-40 px-4">
<div className="font-mono text-sm">{column.columnName}</div> <div className="font-mono text-sm text-gray-700">{column.columnName}</div>
</div> </div>
<div className="w-48 px-4"> <div className="w-48 px-4">
<Input <Input
value={column.displayName || ""} value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)} onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder={column.columnName} placeholder={column.columnName}
className="h-8 text-xs" className="h-7 text-xs"
/> />
</div> </div>
<div className="w-48 px-4"> <div className="w-48 px-4">
@ -773,7 +780,7 @@ export default function TableManagementPage() {
value={column.inputType || "text"} value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)} onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="입력 타입 선택" /> <SelectValue placeholder="입력 타입 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -790,11 +797,9 @@ export default function TableManagementPage() {
{column.inputType === "code" && ( {column.inputType === "code" && (
<Select <Select
value={column.codeCategory || "none"} value={column.codeCategory || "none"}
onValueChange={(value) => onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
handleDetailSettingsChange(column.columnName, "code", value)
}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="공통코드 선택" /> <SelectValue placeholder="공통코드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -809,38 +814,31 @@ export default function TableManagementPage() {
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && ( {column.inputType === "entity" && (
<div className="space-y-1"> <div className="space-y-1">
{/* Entity 타입 설정 - 가로 배치 */} {/* 🎯 Entity 타입 설정 - 가로 배치 */}
<div className="border-primary/20 bg-primary/5 rounded-lg border p-2"> <div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-primary text-xs font-medium">Entity </span> <span className="text-xs font-medium text-blue-800">Entity </span>
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{/* 참조 테이블 */} {/* 참조 테이블 */}
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="mb-1 block text-xs text-gray-600"> </label>
</label>
<Select <Select
value={column.referenceTable || "none"} value={column.referenceTable || "none"}
onValueChange={(value) => onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "entity", value) handleDetailSettingsChange(column.columnName, "entity", value)
} }
> >
<SelectTrigger className="bg-background h-8 text-xs"> <SelectTrigger className="h-7 bg-white text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{referenceTableOptions.map((option, index) => ( {referenceTableOptions.map((option, index) => (
<SelectItem <SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
key={`entity-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{option.label}</span> <span className="font-medium">{option.label}</span>
<span className="text-muted-foreground text-xs"> <span className="text-xs text-gray-500">{option.value}</span>
{option.value}
</span>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@ -851,9 +849,7 @@ export default function TableManagementPage() {
{/* 조인 컬럼 */} {/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && ( {column.referenceTable && column.referenceTable !== "none" && (
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="mb-1 block text-xs text-gray-600"> </label>
</label>
<Select <Select
value={column.referenceColumn || "none"} value={column.referenceColumn || "none"}
onValueChange={(value) => onValueChange={(value) =>
@ -864,7 +860,7 @@ export default function TableManagementPage() {
) )
} }
> >
<SelectTrigger className="bg-background h-8 text-xs"> <SelectTrigger className="h-7 bg-white text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -881,7 +877,7 @@ export default function TableManagementPage() {
referenceTableColumns[column.referenceTable].length === 0) && ( referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled> <SelectItem value="loading" disabled>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div> <div className="h-3 w-3 animate-spin rounded-full border border-blue-500 border-t-transparent"></div>
</div> </div>
</SelectItem> </SelectItem>
@ -899,8 +895,8 @@ export default function TableManagementPage() {
column.referenceColumn !== "none" && column.referenceColumn !== "none" &&
column.displayColumn && column.displayColumn &&
column.displayColumn !== "none" && ( column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-1 text-xs"> <div className="mt-1 flex items-center gap-1 rounded bg-green-100 px-2 py-1 text-xs text-green-700">
<span></span> <span className="text-green-600"></span>
<span className="truncate"> <span className="truncate">
{column.columnName} {column.referenceTable}.{column.displayColumn} {column.columnName} {column.referenceTable}.{column.displayColumn}
</span> </span>
@ -911,7 +907,7 @@ export default function TableManagementPage() {
)} )}
{/* 다른 웹 타입인 경우 빈 공간 */} {/* 다른 웹 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "entity" && ( {column.inputType !== "code" && column.inputType !== "entity" && (
<div className="text-muted-foreground flex h-8 items-center text-xs">-</div> <div className="flex h-7 items-center text-xs text-gray-400">-</div>
)} )}
</div> </div>
<div className="w-80 px-4"> <div className="w-80 px-4">
@ -919,7 +915,7 @@ export default function TableManagementPage() {
value={column.description || ""} value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)} onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명" placeholder="설명"
className="h-8 text-xs" className="h-7 text-xs"
/> />
</div> </div>
</div> </div>
@ -930,12 +926,12 @@ export default function TableManagementPage() {
{columnsLoading && ( {columnsLoading && (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> ...</span> <span className="ml-2 text-sm text-gray-500"> ...</span>
</div> </div>
)} )}
{/* 페이지 정보 */} {/* 페이지 정보 */}
<div className="text-muted-foreground text-center text-sm"> <div className="text-center text-sm text-gray-500">
{columns.length} / {totalColumns} {columns.length} / {totalColumns}
</div> </div>
@ -944,7 +940,7 @@ export default function TableManagementPage() {
<Button <Button
onClick={saveAllSettings} onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0} disabled={!selectedTable || columns.length === 0}
className="h-10 gap-2 text-sm font-medium" className="flex items-center gap-2"
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
@ -954,9 +950,8 @@ export default function TableManagementPage() {
)} )}
</> </>
)} )}
</div> </CardContent>
</div> </Card>
</div>
</div> </div>
{/* DDL 모달 컴포넌트들 */} {/* DDL 모달 컴포넌트들 */}
@ -1002,10 +997,6 @@ export default function TableManagementPage() {
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} /> <TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
</> </>
)} )}
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,19 +15,22 @@ interface CompanyToolbarProps {
* *
* , , * , ,
*/ */
export function CompanyToolbar({ totalCount, onCreateClick }: CompanyToolbarProps) { export function CompanyToolbar({ onCreateClick }: CompanyToolbarProps) {
return ( // 검색어 변경 처리
<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">
// 검색 조건이 있는지 확인
return (
<div className="space-y-4">
{/* 상단: 제목과 등록 버튼 */}
<div className="flex items-center justify-end">
<Button onClick={onCreateClick} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -8,9 +8,12 @@ import { MenuFormModal } from "./MenuFormModal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -25,6 +28,7 @@ import { useMenu } from "@/contexts/MenuContext";
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang"; import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { ScreenAssignmentTab } from "./ScreenAssignmentTab";
type MenuType = "admin" | "user"; type MenuType = "admin" | "user";
@ -801,24 +805,38 @@ export const MenuManagement: React.FC = () => {
return ( return (
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}> <LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
<div className="flex h-full gap-6"> <div className="flex h-full flex-col">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */} {/* 탭 컨테이너 */}
<div className="w-[20%] border-r pr-6"> <Tabs defaultValue="menus" className="flex flex-1 flex-col">
<div className="space-y-4"> <TabsList className="grid w-full grid-cols-2">
<h3 className="text-lg font-semibold">{getUITextSync("menu.type.title")}</h3> <TabsTrigger value="menus"> </TabsTrigger>
<TabsTrigger value="screen-assignment"> </TabsTrigger>
</TabsList>
{/* 메뉴 타입 선택 카드들 */} {/* 메뉴 관리 탭 */}
<div className="space-y-3"> <TabsContent value="menus" className="flex-1 overflow-hidden">
<div <div className="flex h-full">
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${ {/* 메인 컨텐츠 - 2:8 비율 */}
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border" <div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r bg-gray-50">
<div className="p-6">
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50 pb-3">
<CardTitle className="text-lg">{getUITextSync("menu.type.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pt-4">
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-gray-300"
}`} }`}
onClick={() => handleMenuTypeChange("admin")} onClick={() => handleMenuTypeChange("admin")}
> >
<CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div>
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4> <h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{getUITextSync("menu.management.admin.description")} {getUITextSync("menu.management.admin.description")}
</p> </p>
</div> </div>
@ -826,18 +844,20 @@ export const MenuManagement: React.FC = () => {
{adminMenus.length} {adminMenus.length}
</Badge> </Badge>
</div> </div>
</div> </CardContent>
</Card>
<div <Card
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${ className={`cursor-pointer transition-all ${
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border" selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-gray-300"
}`} }`}
onClick={() => handleMenuTypeChange("user")} onClick={() => handleMenuTypeChange("user")}
> >
<CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div>
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4> <h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{getUITextSync("menu.management.user.description")} {getUITextSync("menu.management.user.description")}
</p> </p>
</div> </div>
@ -845,30 +865,33 @@ export const MenuManagement: React.FC = () => {
{userMenus.length} {userMenus.length}
</Badge> </Badge>
</div> </div>
</div> </CardContent>
</div> </Card>
</CardContent>
</Card>
</div> </div>
</div> </div>
{/* 우측 메인 영역 - 메뉴 목록 (80%) */} {/* 우측 메인 영역 - 메뉴 목록 (80%) */}
<div className="w-[80%] pl-0"> <div className="w-[80%] overflow-hidden">
<div className="flex h-full flex-col space-y-4"> <div className="flex h-full flex-col p-6">
{/* 상단 헤더: 제목 + 검색 + 버튼 */} <Card className="flex-1 shadow-sm">
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <CardHeader className="bg-gray-50/50">
{/* 왼쪽: 제목 */} <CardTitle className="text-xl">
<h2 className="text-xl font-semibold">
{getMenuTypeString()} {getUITextSync("menu.list.title")} {getMenuTypeString()} {getUITextSync("menu.list.title")}
</h2> </CardTitle>
</CardHeader>
{/* 오른쪽: 검색 + 버튼 */} <CardContent className="flex-1 overflow-hidden">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> {/* 검색 및 필터 영역 */}
{/* 회사 선택 */} <div className="mb-4 flex-shrink-0">
<div className="w-full sm:w-[160px]"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
<div className="company-dropdown relative"> <div className="company-dropdown relative">
<button <button
type="button" type="button"
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)} onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
> >
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}> <span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all" {selectedCompany === "all"
@ -889,7 +912,8 @@ export const MenuManagement: React.FC = () => {
</button> </button>
{isCompanyDropdownOpen && ( {isCompanyDropdownOpen && (
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg"> <div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md">
{/* 검색 입력 */}
<div className="border-b p-2"> <div className="border-b p-2">
<Input <Input
placeholder={getUITextSync("filter.company.search")} placeholder={getUITextSync("filter.company.search")}
@ -900,9 +924,10 @@ export const MenuManagement: React.FC = () => {
/> />
</div> </div>
{/* 회사 목록 */}
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
<div <div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground" className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => { onClick={() => {
setSelectedCompany("all"); setSelectedCompany("all");
setIsCompanyDropdownOpen(false); setIsCompanyDropdownOpen(false);
@ -912,7 +937,7 @@ export const MenuManagement: React.FC = () => {
{getUITextSync("filter.company.all")} {getUITextSync("filter.company.all")}
</div> </div>
<div <div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground" className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => { onClick={() => {
setSelectedCompany("*"); setSelectedCompany("*");
setIsCompanyDropdownOpen(false); setIsCompanyDropdownOpen(false);
@ -932,7 +957,7 @@ export const MenuManagement: React.FC = () => {
.map((company, index) => ( .map((company, index) => (
<div <div
key={company.code || `company-${index}`} key={company.code || `company-${index}`}
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground" className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => { onClick={() => {
setSelectedCompany(company.code); setSelectedCompany(company.code);
setIsCompanyDropdownOpen(false); setIsCompanyDropdownOpen(false);
@ -948,18 +973,16 @@ export const MenuManagement: React.FC = () => {
</div> </div>
</div> </div>
{/* 검색 입력 */} <div>
<div className="w-full sm:w-[240px]"> <Label htmlFor="search">{getUITextSync("filter.search")}</Label>
<Input <Input
id="search"
placeholder={getUITextSync("filter.search.placeholder")} placeholder={getUITextSync("filter.search.placeholder")}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="h-10 text-sm"
/> />
</div> </div>
{/* 초기화 버튼 */} <div className="flex items-end">
<Button <Button
onClick={() => { onClick={() => {
setSearchText(""); setSearchText("");
@ -967,23 +990,35 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText(""); setCompanySearchText("");
}} }}
variant="outline" variant="outline"
className="h-10 text-sm font-medium" className="w-full"
> >
{getUITextSync("filter.reset")} {getUITextSync("filter.reset")}
</Button> </Button>
</div>
{/* 최상위 메뉴 추가 */} <div className="flex items-end">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium"> <div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
{getUITextSync("button.add.top.level")} {getUITextSync("button.add.top.level")}
</Button> </Button>
{/* 선택 삭제 */}
{selectedMenus.size > 0 && ( {selectedMenus.size > 0 && (
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDeleteSelectedMenus} onClick={handleDeleteSelectedMenus}
disabled={deleting} disabled={deleting}
className="h-10 gap-2 text-sm font-medium" className="min-w-[120px]"
> >
{deleting ? ( {deleting ? (
<> <>
@ -999,9 +1034,6 @@ export const MenuManagement: React.FC = () => {
)} )}
</div> </div>
</div> </div>
{/* 테이블 영역 */}
<div className="flex-1 overflow-hidden">
<MenuTable <MenuTable
menus={getCurrentMenus()} menus={getCurrentMenus()}
title="" title=""
@ -1016,9 +1048,26 @@ export const MenuManagement: React.FC = () => {
uiTexts={uiTexts} uiTexts={uiTexts}
/> />
</div> </div>
</CardContent>
</Card>
</div> </div>
</div> </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 <MenuFormModal
isOpen={formModalOpen} isOpen={formModalOpen}
@ -1046,6 +1095,7 @@ export const MenuManagement: React.FC = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div>
</LoadingOverlay> </LoadingOverlay>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -98,32 +98,30 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
// 로딩 상태 렌더링 // 로딩 상태 렌더링
if (isLoading) { if (isLoading) {
return ( return (
<> <div className="rounded-md border">
{/* 데스크톱 테이블 스켈레톤 */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-b bg-muted/50"> <TableRow>
{USER_TABLE_COLUMNS.map((column) => ( {USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold"> <TableHead key={column.key} style={{ width: column.width }}>
{column.label} {column.label}
</TableHead> </TableHead>
))} ))}
<TableHead className="h-12 w-[200px] text-sm font-semibold"></TableHead> <TableHead className="w-[200px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{Array.from({ length: 10 }).map((_, index) => ( {Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b"> <TableRow key={index}>
{USER_TABLE_COLUMNS.map((column) => ( {USER_TABLE_COLUMNS.map((column) => (
<TableCell key={column.key} className="h-16"> <TableCell key={column.key}>
<div className="h-4 animate-pulse rounded bg-muted"></div> <div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell> </TableCell>
))} ))}
<TableCell className="h-16"> <TableCell>
<div className="flex gap-2"> <div className="flex gap-1">
{Array.from({ length: 2 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-8 w-8 animate-pulse rounded bg-muted"></div> <div key={i} className="bg-muted h-8 w-8 animate-pulse rounded"></div>
))} ))}
</div> </div>
</TableCell> </TableCell>
@ -132,84 +130,69 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
</TableBody> </TableBody>
</Table> </Table>
</div> </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) { if (users.length === 0) {
return ( return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="rounded-md border">
<div className="flex flex-col items-center gap-2 text-center"> <Table>
<p className="text-sm text-muted-foreground"> .</p> <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> </div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div> </div>
); );
} }
// 실제 데이터 렌더링 // 실제 데이터 렌더링
return ( return (
<> <div className="rounded-md border">
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table> <Table>
<TableHeader> <TableHeader className="bg-muted">
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> <TableRow>
{USER_TABLE_COLUMNS.map((column) => ( {USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold"> <TableHead key={column.key} style={{ width: column.width }}>
{column.label} {column.label}
</TableHead> </TableHead>
))} ))}
<TableHead className="h-12 w-[200px] text-sm font-semibold"></TableHead> <TableHead className="w-[200px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((user, index) => ( {users.map((user, index) => (
<TableRow <TableRow key={`${user.userId}-${index}`} className="hover:bg-muted/50">
key={`${user.userId}-${index}`} <TableCell className="font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
className="border-b transition-colors hover:bg-muted/50" <TableCell className="font-mono text-sm">{user.sabun || "-"}</TableCell>
> <TableCell className="font-medium">{user.companyCode || "-"}</TableCell>
<TableCell className="h-16 font-mono text-sm font-medium">{getRowNumber(index)}</TableCell> <TableCell className="font-medium">{user.deptName || "-"}</TableCell>
<TableCell className="h-16 font-mono text-sm">{user.sabun || "-"}</TableCell> <TableCell className="font-medium">{user.positionName || "-"}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.companyCode || "-"}</TableCell> <TableCell className="font-mono">{user.userId}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.deptName || "-"}</TableCell> <TableCell className="font-medium">{user.userName}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.positionName || "-"}</TableCell> <TableCell>{user.tel || user.cellPhone || "-"}</TableCell>
<TableCell className="h-16 font-mono text-sm">{user.userId}</TableCell> <TableCell className="max-w-[200px] truncate" title={user.email}>
<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 || "-"} {user.email || "-"}
</TableCell> </TableCell>
<TableCell className="h-16 text-sm">{formatDate(user.regDate || "")}</TableCell> <TableCell>{formatDate(user.regDate || "")}</TableCell>
<TableCell className="h-16"> <TableCell>
<div className="flex items-center"> <div className="flex items-center gap-2">
<Switch <Switch
checked={user.status === "active"} checked={user.status === "active"}
onCheckedChange={(checked) => handleStatusToggle(user, checked)} onCheckedChange={(checked) => handleStatusToggle(user, checked)}
@ -217,22 +200,22 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
/> />
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16"> <TableCell>
<div className="flex gap-2"> <div className="flex gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)} onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
className="h-8 w-8" className="h-8 w-8 p-0"
title="비밀번호 초기화" title="비밀번호 초기화"
> >
<Key className="h-4 w-4" /> <Key className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
onClick={() => handleOpenHistoryModal(user)} onClick={() => handleOpenHistoryModal(user)}
className="h-8 w-8" className="h-8 w-8 p-0"
title="변경이력 조회" title="변경이력 조회"
> >
<History className="h-4 w-4" /> <History className="h-4 w-4" />
@ -243,96 +226,6 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
))} ))}
</TableBody> </TableBody>
</Table> </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 <UserStatusConfirmDialog
@ -350,6 +243,6 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
userId={historyModal.userId} userId={historyModal.userId}
userName={historyModal.userName} userName={historyModal.userName}
/> />
</> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -577,11 +577,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
padding: prevLayout.gridSettings.padding, padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false, snapToGrid: prevLayout.gridSettings.snapToGrid || false,
}); });
const snappedSize = snapSizeToGrid( const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, prevLayout.gridSettings as GridUtilSettings);
newComp.size,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = snappedSize; newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정 // 크기 변경 시 gridColumns도 자동 조정
@ -746,10 +742,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
path, path,
oldAction: (prevSelected as any).componentConfig?.action, oldAction: (prevSelected as any).componentConfig?.action,
newAction: (newSelectedComponent as any).componentConfig?.action, newAction: (newSelectedComponent as any).componentConfig?.action,
oldColumnsCount: prevSelected.type === "datatable" ? (prevSelected as any).columns?.length : "N/A", oldColumnsCount:
prevSelected.type === "datatable" ? (prevSelected as any).columns?.length : "N/A",
newColumnsCount: newColumnsCount:
newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).columns?.length : "N/A", newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).columns?.length : "N/A",
oldFiltersCount: prevSelected.type === "datatable" ? (prevSelected as any).filters?.length : "N/A", oldFiltersCount:
prevSelected.type === "datatable" ? (prevSelected as any).filters?.length : "N/A",
newFiltersCount: newFiltersCount:
newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).filters?.length : "N/A", newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).filters?.length : "N/A",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -1332,7 +1330,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
// 🔍 버튼 컴포넌트들의 action.type 확인 // 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter( const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary", (c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary"
); );
console.log("💾 저장 시작:", { console.log("💾 저장 시작:", {
screenId: selectedScreen.screenId, screenId: selectedScreen.screenId,
@ -1770,11 +1768,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 첫 번째 컴포넌트 선택 // 첫 번째 컴포넌트 선택
if (newComponents.length > 0) { if (newComponents.length > 0) {
setSelectedComponent(newComponents[0]); setSelectedComponent(newComponents[0]);
openPanel("properties");
} }
toast.success(`${template.name} 템플릿이 추가되었습니다.`); toast.success(`${template.name} 템플릿이 추가되었습니다.`);
}, },
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory], [layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
); );
// 레이아웃 드래그 처리 // 레이아웃 드래그 처리
@ -1838,10 +1837,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 레이아웃 컴포넌트 선택 // 레이아웃 컴포넌트 선택
setSelectedComponent(newLayoutComponent); setSelectedComponent(newLayoutComponent);
openPanel("properties");
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
}, },
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory], [layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
); );
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨 // handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -2177,7 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
}, },
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory], [layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
); );
// 드래그 앤 드롭 처리 // 드래그 앤 드롭 처리
@ -2662,7 +2662,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// console.error("드롭 처리 실패:", error); // console.error("드롭 처리 실패:", error);
} }
}, },
[layout, gridInfo, saveToHistory], [layout, gridInfo, saveToHistory, openPanel],
); );
// 파일 컴포넌트 업데이트 처리 // 파일 컴포넌트 업데이트 처리
@ -2779,13 +2779,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
})); }));
} }
}, },
[ [handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag, layout.components],
handleComponentSelect,
groupState.isGrouping,
groupState.selectedComponents,
dragState.justFinishedDrag,
layout.components,
],
); );
// 컴포넌트 드래그 시작 // 컴포넌트 드래그 시작
@ -4014,10 +4008,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6" className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
> >
{/* Pan 모드 안내 - 제거됨 */} {/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */} {/* 줌 레벨 표시 */}
<div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200"> <div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200">
🔍 {Math.round(zoomLevel * 100)}% 🔍 {Math.round(zoomLevel * 100)}%
</div> </div>
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
<div <div
className="mx-auto" className="mx-auto"
@ -4260,8 +4256,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`} key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`}
component={relativeChildComponent} component={relativeChildComponent}
isSelected={ isSelected={
selectedComponent?.id === child.id || selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
groupState.selectedComponents.includes(child.id)
} }
isDesignMode={true} // 편집 모드로 설정 isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(child, e)} onClick={(e) => handleComponentClick(child, e)}
@ -4320,8 +4315,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)} )}
</div> </div>
</div> </div>
</div>{" "} </div> {/* 🔥 줌 래퍼 닫기 */}
{/* 🔥 줌 래퍼 닫기 */}
</div> </div>
</div>{" "} </div>{" "}
{/* 메인 컨테이너 닫기 */} {/* 메인 컨테이너 닫기 */}

View File

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