Merge pull request 'feature/screen-management' (#119) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/119
This commit is contained in:
kjs 2025-10-21 14:21:51 +09:00
commit 16333eac06
11 changed files with 1011 additions and 56 deletions

857
.cursorrules Normal file
View File

@ -0,0 +1,857 @@
# Cursor Rules for ERP-node Project
## shadcn/ui 웹 스타일 가이드라인
모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다.
### 1. Color System (색상 시스템)
#### CSS Variables 사용
shadcn은 CSS Variables를 사용하여 테마를 관리하며, 모든 색상은 HSL 형식으로 정의됩니다.
**기본 색상 토큰 (항상 사용):**
- `--background`: 페이지 배경
- `--foreground`: 기본 텍스트
- `--primary`: 메인 액션 (Indigo 계열)
- `--primary-foreground`: Primary 위 텍스트
- `--secondary`: 보조 액션
- `--muted`: 약한 배경
- `--muted-foreground`: 보조 텍스트
- `--destructive`: 삭제/에러 (Rose 계열)
- `--border`: 테두리
- `--ring`: 포커스 링
**Tailwind 클래스로 사용:**
```tsx
bg-primary text-primary-foreground
bg-secondary text-secondary-foreground
bg-muted text-muted-foreground
bg-destructive text-destructive-foreground
border-border
```
**추가 시맨틱 컬러:**
- Success: `--success` (Emerald-600 계열)
- Warning: `--warning` (Amber-500 계열)
- Info: `--info` (Cyan-500 계열)
### 2. Spacing System (간격)
**Tailwind Scale (4px 기준):**
- 0.5 = 2px, 1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 6 = 24px, 8 = 32px
**컴포넌트별 권장 간격:**
- 카드 패딩: `p-6` (24px)
- 카드 간 마진: `gap-6` (24px)
- 폼 필드 간격: `space-y-4` (16px)
- 버튼 내부 패딩: `px-4 py-2`
- 섹션 간격: `space-y-8` 또는 `space-y-12`
### 3. Typography (타이포그래피)
**용도별 타이포그래피 (필수):**
- 페이지 제목: `text-3xl font-bold`
- 섹션 제목: `text-2xl font-semibold`
- 카드 제목: `text-xl font-semibold`
- 서브 제목: `text-lg font-medium`
- 본문 텍스트: `text-sm text-muted-foreground`
- 작은 텍스트: `text-xs text-muted-foreground`
- 버튼 텍스트: `text-sm font-medium`
- 폼 라벨: `text-sm font-medium`
### 4. Button Variants (버튼 스타일)
**필수 사용 패턴:**
```tsx
// Primary (기본)
<Button variant="default">저장</Button>
// Secondary
<Button variant="secondary">취소</Button>
// Outline
<Button variant="outline">편집</Button>
// Ghost
<Button variant="ghost">닫기</Button>
// Destructive
<Button variant="destructive">삭제</Button>
```
**버튼 크기:**
- `size="sm"`: 작은 버튼 (h-9 px-3)
- `size="default"`: 기본 버튼 (h-10 px-4 py-2)
- `size="lg"`: 큰 버튼 (h-11 px-8)
- `size="icon"`: 아이콘 전용 (h-10 w-10)
### 5. Input States (입력 필드 상태)
**필수 적용 상태:**
```tsx
// Default
className="border-input"
// Focus (모든 입력 필드 필수)
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
// Error
className="border-destructive focus-visible:ring-destructive"
// Disabled
className="disabled:opacity-50 disabled:cursor-not-allowed"
```
### 6. Card Structure (카드 구조)
**표준 카드 구조 (필수):**
```tsx
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
<CardHeader className="flex flex-col space-y-1.5 p-6">
<CardTitle className="text-2xl font-semibold leading-none tracking-tight">제목</CardTitle>
<CardDescription className="text-sm text-muted-foreground">설명</CardDescription>
</CardHeader>
<CardContent className="p-6 pt-0">
{/* 내용 */}
</CardContent>
<CardFooter className="flex items-center p-6 pt-0">
{/* 액션 버튼들 */}
</CardFooter>
</Card>
```
### 7. Border & Radius (테두리 및 둥근 모서리)
**컴포넌트별 권장:**
- 버튼: `rounded-md` (6px)
- 입력 필드: `rounded-md` (6px)
- 카드: `rounded-lg` (8px)
- 배지: `rounded-full`
- 모달/대화상자: `rounded-lg` (8px)
- 드롭다운: `rounded-md` (6px)
### 8. Shadow (그림자)
**용도별 권장:**
- 카드: `shadow-sm`
- 드롭다운: `shadow-md`
- 모달: `shadow-lg`
- 팝오버: `shadow-md`
- 버튼 호버: `shadow-sm`
### 9. Interactive States (상호작용 상태)
**필수 적용 패턴:**
```tsx
// Hover
hover:bg-primary/90 // 버튼
hover:bg-accent // Ghost 버튼
hover:underline // 링크
hover:shadow-md transition-shadow // 카드
// Focus (모든 인터랙티브 요소 필수)
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
// Active
active:scale-95 transition-transform // 버튼
// Disabled
disabled:opacity-50 disabled:cursor-not-allowed
```
### 10. Animation (애니메이션)
**권장 Duration:**
- 빠른 피드백: `duration-75`
- 기본: `duration-150`
- 부드러운 전환: `duration-300`
**권장 패턴:**
- 버튼 클릭: `transition-transform duration-150 active:scale-95`
- 색상 전환: `transition-colors duration-150`
- 드롭다운 열기: `transition-all duration-200`
### 11. Responsive (반응형)
**Breakpoints:**
- `sm`: 640px (모바일 가로)
- `md`: 768px (태블릿)
- `lg`: 1024px (노트북)
- `xl`: 1280px (데스크톱)
**반응형 패턴:**
```tsx
// 모바일 우선 접근
className="flex-col md:flex-row"
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
className="text-2xl md:text-3xl lg:text-4xl"
className="p-4 md:p-6 lg:p-8"
```
### 12. Accessibility (접근성)
**필수 적용 사항:**
1. 포커스 표시: 모든 인터랙티브 요소에 `focus-visible:ring-2` 적용
2. ARIA 레이블: 적절한 `aria-label`, `aria-describedby` 사용
3. 키보드 네비게이션: Tab, Enter, Space, Esc 지원
4. 색상 대비: 최소 4.5:1 (일반 텍스트), 3:1 (큰 텍스트)
### 13. Class 순서 (일관성 유지)
**항상 이 순서로 작성:**
1. Layout: `flex`, `grid`, `block`
2. Sizing: `w-full`, `h-10`
3. Spacing: `p-4`, `m-2`, `gap-4`
4. Typography: `text-sm`, `font-medium`
5. Colors: `bg-primary`, `text-white`
6. Border: `border`, `rounded-md`
7. Effects: `shadow-sm`, `opacity-50`
8. States: `hover:`, `focus:`, `disabled:`
9. Responsive: `md:`, `lg:`
### 14. 실무 적용 규칙
1. **shadcn 컴포넌트 우선 사용**: 커스텀 스타일보다 shadcn 기본 컴포넌트 활용
2. **cn 유틸리티 사용**: 조건부 클래스는 `cn()` 함수로 결합
3. **테마 변수 사용**: 하드코딩된 색상 대신 CSS 변수 사용
4. **다크모드 고려**: 모든 컴포넌트는 다크모드 호환 필수
5. **일관성 유지**: 같은 용도의 컴포넌트는 같은 스타일 사용
### 15. 금지 사항
1. ❌ 하드코딩된 색상 값 사용 (예: `bg-blue-500` 대신 `bg-primary`)
2. ❌ 인라인 스타일로 색상 지정 (예: `style={{ color: '#3b82f6' }}`)
3. ❌ 포커스 스타일 제거 (`outline-none`만 단독 사용)
4. ❌ 접근성 무시 (ARIA 레이블 누락)
5. ❌ 반응형 무시 (데스크톱 전용 스타일)
6. ❌ **중첩 박스 금지**: 사용자가 명시적으로 요청하지 않는 한 Card 안에 Card, Border 안에 Border 같은 중첩된 컨테이너 구조를 만들지 않음
### 16. 중첩 박스 금지 상세 규칙
**금지되는 패턴 (사용자 요청 없이):**
```tsx
// ❌ Card 안에 Card
<Card>
<CardContent>
<Card> // 중첩 금지!
<CardContent>내용</CardContent>
</Card>
</CardContent>
</Card>
// ❌ Border 안에 Border
<div className="border rounded-lg p-4">
<div className="border rounded-lg p-4"> // 중첩 금지!
내용
</div>
</div>
// ❌ 불필요한 래퍼
<div className="border p-4">
<div className="bg-card border rounded-lg"> // 중첩 금지!
내용
</div>
</div>
```
**허용되는 패턴:**
```tsx
// ✅ 단일 Card
<Card>
<CardHeader>
<CardTitle>제목</CardTitle>
</CardHeader>
<CardContent>
내용
</CardContent>
</Card>
// ✅ 의미적으로 다른 컴포넌트 조합
<Card>
<CardContent>
<Dialog> // Dialog는 별도 UI 레이어
<DialogContent>...</DialogContent>
</Dialog>
</CardContent>
</Card>
// ✅ 그리드/리스트 내부의 Card들
<div className="grid grid-cols-3 gap-4">
<Card>항목 1</Card>
<Card>항목 2</Card>
<Card>항목 3</Card>
</div>
```
**예외 상황 (사용자가 명시적으로 요청한 경우만):**
- 대시보드에서 섹션별 그룹핑이 필요한 경우
- 복잡한 데이터 구조를 시각적으로 구분해야 하는 경우
- 드래그앤드롭 등 특수 기능을 위한 경우
**원칙:**
- 심플하고 깔끔한 디자인 유지
- 불필요한 시각적 레이어 제거
- 사용자가 명시적으로 "박스 안에 박스", "중첩된 카드" 등을 요청하지 않으면 단일 레벨 유지
### 17. 표준 모달(Dialog) 디자인 패턴
**프로젝트 표준 모달 구조 (플로우 관리 기준):**
```tsx
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
{/* 헤더: 제목 + 설명 */}
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
모달에 대한 간단한 설명
</DialogDescription>
</DialogHeader>
{/* 컨텐츠: 폼 필드들 */}
<div className="space-y-3 sm:space-y-4">
{/* 각 입력 필드 */}
<div>
<Label htmlFor="fieldName" className="text-xs sm:text-sm">
필드 라벨 *
</Label>
<Input
id="fieldName"
value={value}
onChange={handleChange}
placeholder="힌트 텍스트"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
도움말 텍스트 (선택사항)
</p>
</div>
</div>
{/* 푸터: 액션 버튼들 */}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
취소
</Button>
<Button
onClick={handleSubmit}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
**필수 적용 사항:**
1. **반응형 크기**
- 모바일: `max-w-[95vw]` (화면 너비의 95%)
- 데스크톱: `sm:max-w-[500px]` (고정 500px)
2. **헤더 구조**
- DialogTitle: `text-base sm:text-lg` (16px → 18px)
- DialogDescription: `text-xs sm:text-sm` (12px → 14px)
- 항상 제목과 설명 모두 포함
3. **컨텐츠 간격**
- 필드 간 간격: `space-y-3 sm:space-y-4` (12px → 16px)
- 각 필드는 `<div>` 로 감싸기
4. **입력 필드 패턴**
- Label: `text-xs sm:text-sm` + 필수 필드는 `*` 표시
- Input/Select: `h-8 text-xs sm:h-10 sm:text-sm` (32px → 40px)
- 도움말: `text-muted-foreground mt-1 text-[10px] sm:text-xs`
5. **푸터 버튼**
- 컨테이너: `gap-2 sm:gap-0` (모바일에서 간격, 데스크톱에서 자동)
- 버튼: `h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm`
- 모바일: 같은 크기 (`flex-1`)
- 데스크톱: 자동 크기 (`flex-none`)
- 순서: 취소(outline) → 확인(default)
6. **접근성**
- Label의 `htmlFor`와 Input의 `id` 매칭
- Button에 적절한 `onClick` 핸들러
- Dialog의 `open`과 `onOpenChange` 필수
**확인 모달 (간단한 경고/확인):**
```tsx
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">작업 확인</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
정말로 이 작업을 수행하시겠습니까?
<br />이 작업은 되돌릴 수 없습니다.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
취소
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
삭제
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
**원칙:**
- 모든 모달은 모바일 우선 반응형 디자인
- 일관된 크기, 간격, 폰트 크기 사용
- 사용자가 다른 크기를 명시하지 않으면 `sm:max-w-[500px]` 사용
- 삭제/위험한 작업은 `variant="destructive"` 사용
### 18. 검색 가능한 Select 박스 (Combobox 패턴)
**적용 조건**: 사용자가 "검색 기능이 있는 Select 박스" 또는 "Combobox"를 명시적으로 요청한 경우만 사용
**표준 Combobox 구조 (플로우 관리 기준):**
```tsx
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// 상태 관리
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
// 렌더링
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{value
? items.find((item) => item.value === value)?.label
: "항목 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="검색..."
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
항목을 찾을 수 없습니다.
</CommandEmpty>
<CommandGroup>
{items.map((item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === item.value ? "opacity-100" : "opacity-0"
)}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
```
**복잡한 데이터 표시 (라벨 + 설명):**
```tsx
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
setValue(currentValue);
setOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === item.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{item.label}</span>
{item.description && (
<span className="text-[10px] text-gray-500">{item.description}</span>
)}
</div>
</CommandItem>
```
**필수 적용 사항:**
1. **반응형 크기**
- 버튼 높이: `h-8 sm:h-10` (32px → 40px)
- 텍스트 크기: `text-xs sm:text-sm` (12px → 14px)
- PopoverContent 너비: `width: "var(--radix-popover-trigger-width)"` (트리거와 동일)
2. **필수 컴포넌트**
- Popover: 드롭다운 컨테이너
- Command: 검색 및 필터링 기능
- CommandInput: 검색 입력 필드
- CommandList: 항목 목록 컨테이너
- CommandEmpty: 검색 결과 없음 메시지
- CommandGroup: 항목 그룹
- CommandItem: 개별 항목
3. **아이콘 사용**
- ChevronsUpDown: 드롭다운 표시 아이콘 (오른쪽)
- Check: 선택된 항목 표시 (왼쪽)
4. **접근성**
- `role="combobox"`: ARIA 역할 명시
- `aria-expanded={open}`: 열림/닫힘 상태
- PopoverTrigger에 `asChild` 사용
5. **로딩 상태**
```tsx
<Button
variant="outline"
role="combobox"
disabled={loading}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{loading ? "로딩 중..." : value ? "선택됨" : "항목 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
```
**일반 Select vs Combobox 선택 기준:**
| 상황 | 컴포넌트 | 이유 |
|------|----------|------|
| 항목 5개 이하 | `<Select>` | 검색 불필요 |
| 항목 5개 초과 | 사용자 요청 시 `<Combobox>` | 검색 필요 시 |
| 테이블/데이터베이스 선택 | `<Combobox>` | 많은 항목 + 검색 필수 |
| 간단한 상태 선택 | `<Select>` | 빠른 선택 |
**원칙:**
- 사용자가 명시적으로 요청하지 않으면 일반 Select 사용
- 많은 항목(10개 이상)을 다룰 때는 Combobox 권장
- 일관된 반응형 크기 유지
- 검색 플레이스홀더는 구체적으로 작성
### 19. Form Validation (폼 검증)
**입력 필드 상태별 스타일:**
```tsx
// Default (기본)
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
// Error (에러)
className="flex h-10 w-full rounded-md border border-destructive bg-background px-3 py-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
// Success (성공)
className="flex h-10 w-full rounded-md border border-success bg-background px-3 py-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-success"
// Disabled (비활성)
className="flex h-10 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm
opacity-50 cursor-not-allowed"
```
**Helper Text (도움말 텍스트):**
```tsx
// 기본 Helper Text
<p className="text-xs text-muted-foreground mt-1.5">
8자 이상 입력해주세요
</p>
// Error Message
<p className="text-xs text-destructive mt-1.5 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
이메일 형식이 올바르지 않습니다
</p>
// Success Message
<p className="text-xs text-success mt-1.5 flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
사용 가능한 이메일입니다
</p>
```
**Form Label (폼 라벨):**
```tsx
// 기본 라벨
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
이메일
</label>
// 필수 항목 표시
<label className="text-sm font-medium leading-none">
이메일 <span className="text-destructive">*</span>
</label>
```
**전체 폼 필드 구조:**
```tsx
<div className="space-y-2">
<label className="text-sm font-medium">
이메일 <span className="text-destructive">*</span>
</label>
<input
type="email"
className={cn(
"flex h-10 w-full rounded-md border px-3 py-2 text-sm",
error ? "border-destructive" : "border-input",
"focus-visible:outline-none focus-visible:ring-2",
error ? "focus-visible:ring-destructive" : "focus-visible:ring-ring"
)}
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{errorMessage}
</p>
)}
{!error && helperText && (
<p className="text-xs text-muted-foreground">{helperText}</p>
)}
</div>
```
**실시간 검증 피드백:**
```tsx
// 로딩 중 (검증 진행)
<div className="flex items-center gap-2">
<input className="..." />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
// 성공
<div className="flex items-center gap-2">
<input className="border-success ..." />
<CheckCircle className="h-4 w-4 text-success" />
</div>
// 실패
<div className="flex items-center gap-2">
<input className="border-destructive ..." />
<XCircle className="h-4 w-4 text-destructive" />
</div>
```
### 20. Loading States (로딩 상태)
**Spinner (스피너) 크기별:**
```tsx
// Small
<Loader2 className="h-4 w-4 animate-spin" />
// Default
<Loader2 className="h-6 w-6 animate-spin" />
// Large
<Loader2 className="h-8 w-8 animate-spin" />
```
**Spinner 색상별:**
```tsx
// Primary
<Loader2 className="h-6 w-6 animate-spin text-primary" />
// Muted
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
// White (다크 배경용)
<Loader2 className="h-6 w-6 animate-spin text-primary-foreground" />
```
**Button Loading:**
```tsx
<button
disabled
className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4"
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
처리 중...
</button>
```
**Skeleton UI:**
```tsx
// 텍스트 스켈레톤
<div className="space-y-2">
<div className="h-4 w-full bg-muted rounded animate-pulse" />
<div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
<div className="h-4 w-1/2 bg-muted rounded animate-pulse" />
</div>
// 카드 스켈레톤
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 bg-muted rounded-full animate-pulse" />
<div className="space-y-2 flex-1">
<div className="h-4 w-1/3 bg-muted rounded animate-pulse" />
<div className="h-3 w-1/2 bg-muted rounded animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="h-3 w-full bg-muted rounded animate-pulse" />
<div className="h-3 w-full bg-muted rounded animate-pulse" />
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
</div>
</div>
```
**Progress Bar (진행률):**
```tsx
// 기본 Progress Bar
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
// 라벨 포함
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">업로드 중...</span>
<span className="font-medium">{progress}%</span>
</div>
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
```
**Full Page Loading:**
```tsx
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">로딩 중...</p>
</div>
</div>
```
### 21. Empty States (빈 상태)
**기본 Empty State:**
```tsx
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Inbox className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">데이터가 없습니다</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
아직 생성된 항목이 없습니다. 새로운 항목을 추가해보세요.
</p>
<button className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4">
<Plus className="mr-2 h-4 w-4" />
새 항목 추가
</button>
</div>
```
**검색 결과 없음:**
```tsx
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">검색 결과가 없습니다</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
"{searchQuery}"에 대한 결과를 찾을 수 없습니다. 다른 검색어로 시도해보세요.
</p>
<button className="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-4">
검색어 초기화
</button>
</div>
```
**에러 상태:**
```tsx
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-2">데이터를 불러올 수 없습니다</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p>
<button className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4">
<RefreshCw className="mr-2 h-4 w-4" />
다시 시도
</button>
</div>
```
**아이콘 가이드:**
- 데이터 없음: Inbox, Package, FileText
- 검색 결과 없음: Search, SearchX
- 필터 결과 없음: Filter, FilterX
- 에러: AlertCircle, XCircle
- 네트워크 오류: WifiOff, CloudOff
- 권한 없음: Lock, ShieldOff
---
## 추가 프로젝트 규칙
- 백엔드 재실행 금지
- 항상 한글로 답변
- 이모지 사용 금지 (명시적 요청 없이)
- 심플하고 깔끔한 디자인 유지

View File

@ -31,13 +31,16 @@ export class FlowController {
*/
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const { name, description, tableName } = req.body;
const { name, description, tableName, dbSourceType, dbConnectionId } =
req.body;
const userId = (req as any).user?.userId || "system";
console.log("🔍 createFlowDefinition called with:", {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
});
if (!name) {
@ -62,7 +65,7 @@ export class FlowController {
}
const flowDef = await this.flowDefinitionService.create(
{ name, description, tableName },
{ name, description, tableName, dbSourceType, dbConnectionId },
userId
);

View File

@ -4,6 +4,7 @@
import { Router } from "express";
import { FlowController } from "../controllers/flowController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
const flowController = new FlowController();
@ -32,8 +33,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList);
router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
// ==================== 데이터 이동 ====================
router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData);
router.post("/move", authenticateToken, flowController.moveData);
router.post("/move-batch", authenticateToken, flowController.moveBatchData);
// ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);

View File

@ -7,7 +7,7 @@
import { Pool as PgPool } from "pg";
import * as mysql from "mysql2/promise";
import db from "../database/db";
import { CredentialEncryption } from "../utils/credentialEncryption";
import { PasswordEncryption } from "../utils/passwordEncryption";
import {
getConnectionTestQuery,
getPlaceholder,
@ -31,24 +31,13 @@ interface ExternalDbConnection {
// 외부 DB 연결 풀 캐시 (타입별로 다른 풀 객체)
const connectionPools = new Map<number, any>();
// 비밀번호 복호화 유틸
const credentialEncryption = new CredentialEncryption(
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-change-in-production"
);
/**
* DB
*/
async function getExternalConnection(
connectionId: number
): Promise<ExternalDbConnection | null> {
const query = `
SELECT
id, connection_name, db_type, host, port,
database_name, username, encrypted_password, is_active
FROM external_db_connections
WHERE id = $1 AND is_active = true
`;
const query = `SELECT * FROM external_db_connections WHERE id = $1 AND is_active = 'Y'`;
const result = await db.query(query, [connectionId]);
@ -58,13 +47,14 @@ async function getExternalConnection(
const row = result[0];
// 비밀번호 복호화
// 비밀번호 복호화 (암호화된 비밀번호는 password 컬럼에 저장됨)
let decryptedPassword = "";
try {
decryptedPassword = credentialEncryption.decrypt(row.encrypted_password);
decryptedPassword = PasswordEncryption.decrypt(row.password);
} catch (error) {
console.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error);
throw new Error("외부 DB 비밀번호 복호화에 실패했습니다");
// 복호화 실패 시 원본 비밀번호 사용 (fallback)
decryptedPassword = row.password;
}
return {

View File

@ -161,6 +161,28 @@ export class FlowDataMoveService {
}
// 5. 감사 로그 기록
let dbConnectionName = null;
if (
flowDefinition.dbSourceType === "external" &&
flowDefinition.dbConnectionId
) {
// 외부 DB인 경우 연결 이름 조회
try {
const connResult = await client.query(
`SELECT connection_name FROM external_db_connections WHERE id = $1`,
[flowDefinition.dbConnectionId]
);
if (connResult.rows && connResult.rows.length > 0) {
dbConnectionName = connResult.rows[0].connection_name;
}
} catch (error) {
console.warn("외부 DB 연결 이름 조회 실패:", error);
}
} else {
// 내부 DB인 경우
dbConnectionName = "내부 데이터베이스";
}
await this.logDataMove(client, {
flowId,
fromStepId,
@ -173,6 +195,11 @@ export class FlowDataMoveService {
statusFrom: fromStep.statusValue,
statusTo: toStep.statusValue,
userId,
dbConnectionId:
flowDefinition.dbSourceType === "external"
? flowDefinition.dbConnectionId
: null,
dbConnectionName,
});
return {
@ -361,8 +388,9 @@ export class FlowDataMoveService {
move_type, source_table, target_table,
source_data_id, target_data_id,
status_from, status_to,
changed_by, note
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
changed_by, note,
db_connection_id, db_connection_name
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`;
await client.query(query, [
@ -378,6 +406,8 @@ export class FlowDataMoveService {
params.statusTo,
params.userId,
params.note || null,
params.dbConnectionId || null,
params.dbConnectionName || null,
]);
}
@ -452,6 +482,8 @@ export class FlowDataMoveService {
targetDataId: row.target_data_id,
statusFrom: row.status_from,
statusTo: row.status_to,
dbConnectionId: row.db_connection_id,
dbConnectionName: row.db_connection_name,
}));
}
@ -496,6 +528,8 @@ export class FlowDataMoveService {
targetDataId: row.target_data_id,
statusFrom: row.status_from,
statusTo: row.status_to,
dbConnectionId: row.db_connection_id,
dbConnectionName: row.db_connection_name,
}));
}
@ -718,7 +752,21 @@ export class FlowDataMoveService {
// 3. 외부 연동 처리는 생략 (외부 DB 자체가 외부이므로)
// 4. 감사 로그 기록 (내부 DB에)
// 4. 외부 DB 연결 이름 조회
let dbConnectionName = null;
try {
const connResult = await db.query(
`SELECT connection_name FROM external_db_connections WHERE id = $1`,
[dbConnectionId]
);
if (connResult.length > 0) {
dbConnectionName = connResult[0].connection_name;
}
} catch (error) {
console.warn("외부 DB 연결 이름 조회 실패:", error);
}
// 5. 감사 로그 기록 (내부 DB에)
// 외부 DB는 내부 DB 트랜잭션 외부이므로 직접 쿼리 실행
const auditQuery = `
INSERT INTO flow_audit_log (
@ -726,8 +774,9 @@ export class FlowDataMoveService {
move_type, source_table, target_table,
source_data_id, target_data_id,
status_from, status_to,
changed_by, note
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
changed_by, note,
db_connection_id, db_connection_name
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`;
await db.query(auditQuery, [
@ -743,6 +792,8 @@ export class FlowDataMoveService {
toStep.statusValue || null, // statusTo
userId,
`외부 DB (${dbType}) 데이터 이동`,
dbConnectionId,
dbConnectionName,
]);
return {

View File

@ -182,6 +182,9 @@ export interface FlowAuditLog {
targetDataId?: string;
statusFrom?: string;
statusTo?: string;
// 외부 DB 연결 정보
dbConnectionId?: number;
dbConnectionName?: string;
// 조인 필드
fromStepName?: string;
toStepName?: string;

View File

@ -243,14 +243,20 @@ export function FlowConditionBuilder({
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span className="font-medium">{col.displayName || col.columnName}</span>
<span className="text-xs text-gray-500">({col.dataType})</span>
</div>
</SelectItem>
))}
{columns.map((col, idx) => {
const columnName = col.column_name || col.columnName || "";
const dataType = col.data_type || col.dataType || "";
const displayName = col.displayName || col.display_name || columnName;
return (
<SelectItem key={`${columnName}-${idx}`} value={columnName}>
<div className="flex items-center gap-2">
<span className="font-medium">{displayName}</span>
<span className="text-xs text-gray-500">({dataType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}

View File

@ -666,8 +666,12 @@ export function FlowStepPanel({
{loadingColumns
? "컬럼 로딩 중..."
: formData.statusColumn
? columns.find((col) => col.columnName === formData.statusColumn)?.columnName ||
formData.statusColumn
? (() => {
const col = columns.find(
(c) => (c.column_name || c.columnName) === formData.statusColumn,
);
return col ? col.column_name || col.columnName : formData.statusColumn;
})()
: "상태 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@ -678,27 +682,32 @@ export function FlowStepPanel({
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
setFormData({ ...formData, statusColumn: column.columnName });
setOpenStatusColumnCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.statusColumn === column.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div>
<div>{column.columnName}</div>
<div className="text-xs text-gray-500">({column.dataType})</div>
</div>
</CommandItem>
))}
{columns.map((column, idx) => {
const columnName = column.column_name || column.columnName || "";
const dataType = column.data_type || column.dataType || "";
return (
<CommandItem
key={`${columnName}-${idx}`}
value={columnName}
onSelect={() => {
setFormData({ ...formData, statusColumn: columnName });
setOpenStatusColumnCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.statusColumn === columnName ? "opacity-100" : "opacity-0",
)}
/>
<div>
<div>{columnName}</div>
<div className="text-xs text-gray-500">({dataType})</div>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>

View File

@ -397,6 +397,7 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
<TableHead className="w-[100px]"> ID</TableHead>
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]">DB </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
@ -450,6 +451,21 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
)}
</TableCell>
<TableCell className="text-xs">{log.changedBy}</TableCell>
<TableCell className="text-xs">
{log.dbConnectionName ? (
<span
className={
log.dbConnectionName === "내부 데이터베이스"
? "text-blue-600"
: "text-green-600"
}
>
{log.dbConnectionName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs">
{log.sourceTable || "-"}
{log.targetTable && log.targetTable !== log.sourceTable && (

View File

@ -21,6 +21,12 @@ import {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api";
// 토큰 가져오기
function getAuthToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("authToken") || sessionStorage.getItem("authToken");
}
// ============================================
// 플로우 정의 API
// ============================================
@ -364,10 +370,12 @@ export async function getAllStepCounts(flowId: number): Promise<ApiResponse<Flow
*/
export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ success: boolean }>> {
try {
const token = getAuthToken();
const response = await fetch(`${API_BASE}/flow/move`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
credentials: "include",
body: JSON.stringify(data),
@ -404,10 +412,12 @@ export async function moveBatchData(
data: MoveBatchDataRequest,
): Promise<ApiResponse<{ success: boolean; results: any[] }>> {
try {
const token = getAuthToken();
const response = await fetch(`${API_BASE}/flow/move-batch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
credentials: "include",
body: JSON.stringify(data),

View File

@ -148,6 +148,15 @@ export interface FlowAuditLog {
note?: string;
fromStepName?: string;
toStepName?: string;
moveType?: "status" | "table" | "both";
sourceTable?: string;
targetTable?: string;
sourceDataId?: string;
targetDataId?: string;
statusFrom?: string;
statusTo?: string;
dbConnectionId?: number;
dbConnectionName?: string;
}
// ============================================