Compare commits
No commits in common. "main" and "fix/error" have entirely different histories.
|
|
@ -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) - 커스텀 드롭다운 + 좌우 레이아웃
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
# AI-개발자 협업 작업 수칙
|
||||
|
||||
## 핵심 원칙: "추측 금지, 확인 필수"
|
||||
|
||||
AI는 코드 작성 전에 반드시 실제 상황을 확인해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 관련 작업
|
||||
|
||||
### 필수 확인 사항
|
||||
|
||||
- ✅ **항상 MCP Postgres로 실제 테이블 구조를 먼저 확인**
|
||||
- ✅ 컬럼명, 데이터 타입, 제약조건을 추측하지 말고 쿼리로 확인
|
||||
- ✅ 변경 후 실제로 데이터가 어떻게 보이는지 SELECT로 검증
|
||||
|
||||
### 확인 방법
|
||||
|
||||
```sql
|
||||
-- 테이블 구조 확인
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- 실제 데이터 확인
|
||||
SELECT * FROM 테이블명 LIMIT 5;
|
||||
```
|
||||
|
||||
### 금지 사항
|
||||
|
||||
- ❌ "아마도 `created_at` 컬럼일 것입니다" → 확인 필수!
|
||||
- ❌ "보통 이렇게 되어있습니다" → 이 프로젝트에서 확인!
|
||||
- ❌ 다른 테이블 구조를 보고 추측 → 각 테이블마다 확인!
|
||||
|
||||
---
|
||||
|
||||
## 2. 코드 수정 작업
|
||||
|
||||
### 작업 전
|
||||
|
||||
1. **관련 파일 읽기**: 수정할 파일의 현재 상태 확인
|
||||
2. **의존성 파악**: 다른 파일에 영향이 있는지 검색
|
||||
3. **기존 패턴 확인**: 프로젝트의 코딩 스타일 준수
|
||||
|
||||
### 작업 중
|
||||
|
||||
1. **한 번에 하나씩**: 하나의 명확한 작업만 수행
|
||||
2. **로그 추가**: 디버깅이 필요하면 명확한 로그 추가
|
||||
3. **점진적 수정**: 큰 변경은 여러 단계로 나눔
|
||||
|
||||
### 작업 후
|
||||
|
||||
1. **로그 제거**: 디버깅 로그는 반드시 제거
|
||||
2. **테스트 제안**: 브라우저로 테스트할 것을 제안
|
||||
3. **변경사항 요약**: 무엇을 어떻게 바꿨는지 명확히 설명
|
||||
|
||||
---
|
||||
|
||||
## 3. 확인 및 검증
|
||||
|
||||
### 확인 도구 사용
|
||||
|
||||
- **MCP Postgres**: 데이터베이스 구조 및 데이터 확인
|
||||
- **MCP Browser**: 실제 화면에서 동작 확인
|
||||
- **codebase_search**: 관련 코드 패턴 검색
|
||||
- **grep**: 특정 문자열 사용처 찾기
|
||||
|
||||
### 검증 프로세스
|
||||
|
||||
1. **변경 전 상태 확인** → 문제 파악
|
||||
2. **변경 적용**
|
||||
3. **변경 후 상태 확인** → 해결 검증
|
||||
4. **부작용 확인** → 다른 기능에 영향 없는지
|
||||
|
||||
### 사용자 피드백 대응
|
||||
|
||||
- 사용자가 "확인 안하지?"라고 하면:
|
||||
1. 즉시 사과
|
||||
2. MCP/브라우저로 실제 확인
|
||||
3. 정확한 정보를 바탕으로 재작업
|
||||
|
||||
---
|
||||
|
||||
## 4. 커뮤니케이션
|
||||
|
||||
### 작업 시작 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"MCP로 item_info 테이블 구조를 먼저 확인하겠습니다."
|
||||
|
||||
❌ 나쁜 예:
|
||||
"보통 created_at 컬럼이 있을 것이므로 수정하겠습니다."
|
||||
```
|
||||
|
||||
### 작업 완료 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"완료! 두 가지를 수정했습니다:
|
||||
1. 기본 높이를 40px → 30px로 변경 (ScreenDesigner.tsx:2174)
|
||||
2. 숨김 컬럼을 created_date, updated_date, writer, company_code로 수정 (TablesPanel.tsx:57)
|
||||
|
||||
테스트해보세요!"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"수정했습니다!"
|
||||
```
|
||||
|
||||
### 불확실할 때
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"컬럼명이 created_at인지 created_date인지 확실하지 않습니다.
|
||||
MCP로 확인해도 될까요?"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"created_at일 것 같으니 일단 이렇게 하겠습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 금지 사항
|
||||
|
||||
### 절대 금지
|
||||
|
||||
1. ❌ **확인 없이 "완료했습니다" 말하기**
|
||||
- 반드시 실제로 확인하고 보고
|
||||
2. ❌ **이전에 실패한 방법 반복하기**
|
||||
- 같은 실수를 두 번 하지 않기
|
||||
3. ❌ **디버깅 로그를 남겨둔 채 작업 종료**
|
||||
- 모든 console.log 제거 확인
|
||||
4. ❌ **추측으로 답변하기**
|
||||
|
||||
- "아마도", "보통", "일반적으로" 금지
|
||||
- 확실하지 않으면 먼저 확인
|
||||
|
||||
5. ❌ **여러 문제를 한 번에 수정하려고 시도**
|
||||
- 한 번에 하나씩 해결
|
||||
|
||||
---
|
||||
|
||||
## 6. 프로젝트 특별 규칙
|
||||
|
||||
### 백엔드 관련
|
||||
|
||||
- 🔥 **백엔드 재시작 절대 금지** (사용자 명시 규칙)
|
||||
- 🔥 Node.js 프로세스를 건드리지 않음
|
||||
|
||||
### 데이터베이스 관련
|
||||
|
||||
- 🔥 **멀티테넌시 규칙 준수**
|
||||
- 모든 쿼리에 `company_code` 필터링 필수
|
||||
- `company_code = "*"`는 최고 관리자 전용
|
||||
- 자세한 내용: `.cursor/rules/multi-tenancy-guide.mdc`
|
||||
|
||||
### API 관련
|
||||
|
||||
- 🔥 **API 클라이언트 사용 필수**
|
||||
- `fetch()` 직접 사용 금지
|
||||
- `lib/api/` 의 클라이언트 함수 사용
|
||||
- 환경별 URL 자동 처리
|
||||
|
||||
### UI 관련
|
||||
|
||||
- 🔥 **shadcn/ui 스타일 가이드 준수**
|
||||
- CSS 변수 사용 (하드코딩 금지)
|
||||
- 중첩 박스 금지 (명시 요청 전까지)
|
||||
- 이모지 사용 금지 (명시 요청 전까지)
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
### 에러 발생 시 프로세스
|
||||
|
||||
1. **에러 로그 전체 읽기**
|
||||
|
||||
- 스택 트레이스 확인
|
||||
- 에러 메시지 정확히 파악
|
||||
|
||||
2. **근본 원인 파악**
|
||||
|
||||
- 증상이 아닌 원인 찾기
|
||||
- 왜 이 에러가 발생했는지 이해
|
||||
|
||||
3. **해결책 적용**
|
||||
|
||||
- 임시방편이 아닌 근본적 해결
|
||||
- 같은 에러가 재발하지 않도록
|
||||
|
||||
4. **검증**
|
||||
- 실제로 에러가 해결되었는지 확인
|
||||
- 다른 부작용은 없는지 확인
|
||||
|
||||
### 에러 로깅
|
||||
|
||||
```typescript
|
||||
// ✅ 좋은 로그 (디버깅 시)
|
||||
console.log("🔍 [컴포넌트명] 작업명:", {
|
||||
관련변수1,
|
||||
관련변수2,
|
||||
예상결과,
|
||||
});
|
||||
|
||||
// ❌ 나쁜 로그
|
||||
console.log("here");
|
||||
console.log(data); // 무슨 데이터인지 알 수 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 완료 체크리스트
|
||||
|
||||
모든 작업 완료 전에 다음을 확인:
|
||||
|
||||
- [ ] 실제 데이터베이스/파일을 확인했는가?
|
||||
- [ ] 변경사항이 의도대로 작동하는가?
|
||||
- [ ] 디버깅 로그를 모두 제거했는가?
|
||||
- [ ] 다른 기능에 부작용이 없는가?
|
||||
- [ ] 멀티테넌시 규칙을 준수했는가?
|
||||
- [ ] 사용자에게 명확히 설명했는가?
|
||||
|
||||
---
|
||||
|
||||
## 9. 모범 사례
|
||||
|
||||
### 데이터베이스 확인 예시
|
||||
|
||||
```typescript
|
||||
// 1. MCP로 테이블 구조 확인
|
||||
mcp_postgres_query: SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'item_info';
|
||||
|
||||
// 2. 실제 컬럼명 확인 후 코드 작성
|
||||
const hiddenColumns = new Set([
|
||||
'id',
|
||||
'created_date', // ✅ 실제 확인한 컬럼명
|
||||
'updated_date', // ✅ 실제 확인한 컬럼명
|
||||
'writer', // ✅ 실제 확인한 컬럼명
|
||||
'company_code'
|
||||
]);
|
||||
```
|
||||
|
||||
### 브라우저 테스트 제안 예시
|
||||
|
||||
```
|
||||
"수정이 완료되었습니다!
|
||||
|
||||
다음을 테스트해주세요:
|
||||
1. 화면관리 > 테이블 탭 열기
|
||||
2. item_info 테이블 확인
|
||||
3. 기본 5개 컬럼(id, created_date 등)이 안 보이는지 확인
|
||||
4. 새 컬럼 드래그앤드롭 시 높이가 30px인지 확인
|
||||
|
||||
브라우저 테스트를 원하시면 말씀해주세요!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 요약: 핵심 3원칙
|
||||
|
||||
1. **확인 우선** 🔍
|
||||
|
||||
- 추측하지 말고, 항상 확인하고 작업
|
||||
|
||||
2. **한 번에 하나** 🎯
|
||||
|
||||
- 여러 문제를 동시에 해결하려 하지 말기
|
||||
|
||||
3. **철저한 마무리** ✨
|
||||
- 로그 제거, 테스트, 명확한 설명
|
||||
|
||||
---
|
||||
|
||||
## 11. 화면관리 시스템 위젯 개발 가이드
|
||||
|
||||
### 위젯 크기 설정의 핵심 원칙
|
||||
|
||||
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
|
||||
|
||||
#### ✅ 올바른 크기 설정 패턴
|
||||
|
||||
```tsx
|
||||
// 위젯 컴포넌트 내부
|
||||
export function YourWidget({ component }: YourWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-between gap-2"
|
||||
style={{
|
||||
padding: component.style?.padding || "0.75rem",
|
||||
backgroundColor: component.style?.backgroundColor,
|
||||
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
|
||||
}}
|
||||
>
|
||||
{/* 위젯 내용 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 크기 설정 패턴
|
||||
|
||||
```tsx
|
||||
// 이렇게 하면 안 됩니다!
|
||||
<div
|
||||
style={{
|
||||
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
|
||||
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
|
||||
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
### 이유
|
||||
|
||||
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
|
||||
|
||||
```tsx
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: getWidth(), // size.width 사용
|
||||
height: getHeight(), // size.height 사용
|
||||
};
|
||||
```
|
||||
|
||||
2. 위젯 내부에서 크기를 다시 설정하면:
|
||||
- 중복 설정으로 인한 충돌
|
||||
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
|
||||
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
|
||||
|
||||
### 위젯이 관리해야 할 스타일
|
||||
|
||||
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
|
||||
|
||||
- ✅ `padding`: 내부 여백
|
||||
- ✅ `backgroundColor`: 배경색
|
||||
- ✅ `border`, `borderRadius`: 테두리
|
||||
- ✅ `gap`: 자식 요소 간격
|
||||
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
|
||||
|
||||
### 위젯 등록 시 defaultSize
|
||||
|
||||
```tsx
|
||||
ComponentRegistry.registerComponent({
|
||||
id: "your-widget",
|
||||
name: "위젯 이름",
|
||||
category: "utility",
|
||||
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
|
||||
component: YourWidget,
|
||||
defaultProps: {
|
||||
style: {
|
||||
padding: "0.75rem",
|
||||
// width, height는 defaultSize로 제어되므로 여기 불필요
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 전체 높이를 차지하고 내부 요소를 정렬
|
||||
<div className="flex h-full w-full items-center justify-between gap-2">
|
||||
{/* 왼쪽 컨텐츠 */}
|
||||
<div className="flex items-center gap-3">{/* ... */}</div>
|
||||
|
||||
{/* 오른쪽 버튼들 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 체크리스트
|
||||
|
||||
위젯 개발 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
|
||||
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
|
||||
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
|
||||
- [ ] `defaultSize`에 적절한 기본 크기 설정
|
||||
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
|
||||
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
|
||||
|
||||
---
|
||||
|
||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 강제하는 규칙
|
||||
---
|
||||
|
||||
# API 클라이언트 사용 규칙
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**절대 `fetch`를 직접 사용하지 않고, 반드시 전용 API 클라이언트를 사용해야 합니다.**
|
||||
|
||||
## 이유
|
||||
|
||||
1. **환경별 URL 자동 처리**: 프로덕션(`v1.vexplor.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
|
||||
2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링
|
||||
3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가
|
||||
4. **유지보수성**: API 변경 시 한 곳에서만 수정
|
||||
|
||||
## API 클라이언트 위치
|
||||
|
||||
```
|
||||
frontend/lib/api/
|
||||
├── client.ts # Axios 기반 공통 클라이언트
|
||||
├── flow.ts # 플로우 관리 API
|
||||
├── dashboard.ts # 대시보드 API
|
||||
├── mail.ts # 메일 API
|
||||
├── externalCall.ts # 외부 호출 API
|
||||
├── company.ts # 회사 관리 API
|
||||
└── file.ts # 파일 업로드/다운로드 API
|
||||
```
|
||||
|
||||
## 올바른 사용법
|
||||
|
||||
### ❌ 잘못된 방법 (절대 사용 금지)
|
||||
|
||||
```typescript
|
||||
// 직접 fetch 사용 - 환경별 URL이 자동 처리되지 않음
|
||||
const response = await fetch("/api/flow/definitions/29/steps");
|
||||
const data = await response.json();
|
||||
|
||||
// 상대 경로 - 프로덕션에서 잘못된 도메인으로 요청
|
||||
const response = await fetch(`/api/flow/${flowId}/steps`);
|
||||
```
|
||||
|
||||
### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// 1. API 클라이언트 함수 import
|
||||
import { getFlowSteps } from "@/lib/api/flow";
|
||||
|
||||
// 2. 함수 호출
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
if (stepsResponse.success && stepsResponse.data) {
|
||||
setSteps(stepsResponse.data);
|
||||
}
|
||||
```
|
||||
|
||||
## 주요 API 클라이언트 함수
|
||||
|
||||
### 플로우 관리 ([flow.ts](mdc:frontend/lib/api/flow.ts))
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getFlowDefinitions, // 플로우 목록
|
||||
getFlowById, // 플로우 상세
|
||||
createFlowDefinition, // 플로우 생성
|
||||
updateFlowDefinition, // 플로우 수정
|
||||
deleteFlowDefinition, // 플로우 삭제
|
||||
getFlowSteps, // 스텝 목록 ⭐
|
||||
createFlowStep, // 스텝 생성
|
||||
updateFlowStep, // 스텝 수정
|
||||
deleteFlowStep, // 스텝 삭제
|
||||
getFlowConnections, // 연결 목록 ⭐
|
||||
createFlowConnection, // 연결 생성
|
||||
deleteFlowConnection, // 연결 삭제
|
||||
getStepDataCount, // 스텝 데이터 카운트
|
||||
getStepDataList, // 스텝 데이터 목록
|
||||
getAllStepCounts, // 모든 스텝 카운트
|
||||
moveData, // 데이터 이동
|
||||
moveBatchData, // 배치 데이터 이동
|
||||
getAuditLogs, // 오딧 로그
|
||||
} from "@/lib/api/flow";
|
||||
```
|
||||
|
||||
### Axios 클라이언트 ([client.ts](mdc:frontend/lib/api/client.ts))
|
||||
|
||||
```typescript
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
// GET 요청
|
||||
const response = await apiClient.get("/api/endpoint");
|
||||
|
||||
// POST 요청
|
||||
const response = await apiClient.post("/api/endpoint", { data });
|
||||
|
||||
// PUT 요청
|
||||
const response = await apiClient.put("/api/endpoint", { data });
|
||||
|
||||
// DELETE 요청
|
||||
const response = await apiClient.delete("/api/endpoint");
|
||||
```
|
||||
|
||||
## 새로운 API 함수 추가 가이드
|
||||
|
||||
기존 API 클라이언트에 함수가 없는 경우:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/yourModule.ts
|
||||
|
||||
// 1. API URL 동적 설정 (필수)
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
// 2. API 함수 작성
|
||||
export async function getYourData(id: number): Promise<ApiResponse<YourType>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/your-endpoint/${id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 환경별 URL 매핑
|
||||
|
||||
API 클라이언트는 자동으로 환경을 감지합니다:
|
||||
|
||||
| 현재 호스트 | 백엔드 API URL |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `v1.vexplor.com` | `https://api.vexplor.com/api` |
|
||||
| `localhost:9771` | `http://localhost:8080/api` |
|
||||
| `localhost:3000` | `http://localhost:8080/api` |
|
||||
|
||||
## 체크리스트
|
||||
|
||||
코드 작성 시 다음을 확인하세요:
|
||||
|
||||
- [ ] `fetch('/api/...')` 직접 사용하지 않음
|
||||
- [ ] 적절한 API 클라이언트 함수를 import 함
|
||||
- [ ] API 응답의 `success` 필드를 체크함
|
||||
- [ ] 에러 처리를 구현함
|
||||
- [ ] 새로운 API가 필요하면 `lib/api/` 에 함수 추가
|
||||
|
||||
## 예외 상황
|
||||
|
||||
다음 경우에만 `fetch`를 직접 사용할 수 있습니다:
|
||||
|
||||
1. **외부 서비스 호출**: 다른 도메인의 API 호출 시
|
||||
2. **특수한 헤더가 필요한 경우**: FormData, Blob 등
|
||||
|
||||
이 경우에도 가능하면 전용 API 클라이언트 함수로 래핑하세요.
|
||||
|
||||
## 실제 적용 예시
|
||||
|
||||
### 플로우 위젯 ([FlowWidget.tsx](mdc:frontend/components/screen/widgets/FlowWidget.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
const connectionsResponse = await getFlowConnections(flowId);
|
||||
```
|
||||
|
||||
### 플로우 가시성 패널 ([FlowVisibilityConfigPanel.tsx](mdc:frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
```
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [API 클라이언트 공통 설정](mdc:frontend/lib/api/client.ts)
|
||||
- [플로우 API 클라이언트](mdc:frontend/lib/api/flow.ts)
|
||||
- [API URL 유틸리티](mdc:frontend/lib/utils/apiUrl.ts)
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
# inputType 사용 가이드
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**컬럼 타입 판단 시 반드시 `inputType`을 사용해야 합니다. `webType`은 레거시이며 더 이상 사용하지 않습니다.**
|
||||
|
||||
---
|
||||
|
||||
## 올바른 사용법
|
||||
|
||||
### ✅ inputType 사용 (권장)
|
||||
|
||||
```typescript
|
||||
// 카테고리 타입 체크
|
||||
if (columnMeta.inputType === "category") {
|
||||
// 카테고리 처리 로직
|
||||
}
|
||||
|
||||
// 코드 타입 체크
|
||||
if (meta.inputType === "code") {
|
||||
// 코드 처리 로직
|
||||
}
|
||||
|
||||
// 필터링
|
||||
const categoryColumns = Object.entries(columnMeta)
|
||||
.filter(([_, meta]) => meta.inputType === "category")
|
||||
.map(([columnName, _]) => columnName);
|
||||
```
|
||||
|
||||
### ❌ webType 사용 (금지)
|
||||
|
||||
```typescript
|
||||
// ❌ 절대 사용 금지!
|
||||
if (columnMeta.webType === "category") { ... }
|
||||
|
||||
// ❌ 이것도 금지!
|
||||
const categoryColumns = columns.filter(col => col.webType === "category");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API에서 inputType 가져오기
|
||||
|
||||
### Backend API
|
||||
|
||||
```typescript
|
||||
// 컬럼 입력 타입 정보 가져오기
|
||||
const inputTypes = await tableTypeApi.getColumnInputTypes(tableName);
|
||||
|
||||
// inputType 맵 생성
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
});
|
||||
```
|
||||
|
||||
### columnMeta 구조
|
||||
|
||||
```typescript
|
||||
interface ColumnMeta {
|
||||
webType?: string; // 레거시, 사용 금지
|
||||
codeCategory?: string;
|
||||
inputType?: string; // ✅ 반드시 이것 사용!
|
||||
}
|
||||
|
||||
const columnMeta: Record<string, ColumnMeta> = {
|
||||
material: {
|
||||
webType: "category", // 무시
|
||||
codeCategory: "",
|
||||
inputType: "category", // ✅ 이것만 사용
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 캐시 사용 시 주의사항
|
||||
|
||||
### ❌ 잘못된 캐시 처리 (inputType 누락)
|
||||
|
||||
```typescript
|
||||
const cached = tableColumnCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const meta: Record<string, ColumnMeta> = {};
|
||||
|
||||
cached.columns.forEach((col: any) => {
|
||||
meta[col.columnName] = {
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
// ❌ inputType 누락!
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 올바른 캐시 처리 (inputType 포함)
|
||||
|
||||
```typescript
|
||||
const cached = tableColumnCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const meta: Record<string, ColumnMeta> = {};
|
||||
|
||||
// 캐시된 inputTypes 맵 생성
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
if (cached.inputTypes) {
|
||||
cached.inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
});
|
||||
}
|
||||
|
||||
cached.columns.forEach((col: any) => {
|
||||
meta[col.columnName] = {
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
inputType: inputTypeMap[col.columnName], // ✅ inputType 포함!
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 inputType 종류
|
||||
|
||||
| inputType | 설명 | 사용 예시 |
|
||||
| ---------- | ---------------- | ------------------ |
|
||||
| `text` | 일반 텍스트 입력 | 이름, 설명 등 |
|
||||
| `number` | 숫자 입력 | 금액, 수량 등 |
|
||||
| `date` | 날짜 입력 | 생성일, 수정일 등 |
|
||||
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
|
||||
| `category` | 카테고리 선택 | 분류, 상태 등 |
|
||||
| `code` | 공통 코드 선택 | 코드 마스터 데이터 |
|
||||
| `boolean` | 예/아니오 | 활성화 여부 등 |
|
||||
| `email` | 이메일 입력 | 이메일 주소 |
|
||||
| `url` | URL 입력 | 웹사이트 주소 |
|
||||
| `image` | 이미지 업로드 | 프로필 사진 등 |
|
||||
| `file` | 파일 업로드 | 첨부파일 등 |
|
||||
|
||||
---
|
||||
|
||||
## 실제 적용 사례
|
||||
|
||||
### 1. TableListComponent - 카테고리 매핑 로드
|
||||
|
||||
```typescript
|
||||
// ✅ inputType으로 카테고리 컬럼 필터링
|
||||
const categoryColumns = Object.entries(columnMeta)
|
||||
.filter(([_, meta]) => meta.inputType === "category")
|
||||
.map(([columnName, _]) => columnName);
|
||||
|
||||
// 각 카테고리 컬럼의 값 목록 조회
|
||||
for (const columnName of categoryColumns) {
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${tableName}/${columnName}/values`
|
||||
);
|
||||
// 매핑 처리...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. InteractiveDataTable - 셀 값 렌더링
|
||||
|
||||
```typescript
|
||||
// ✅ inputType으로 렌더링 분기
|
||||
const inputType = columnMeta[column.columnName]?.inputType;
|
||||
|
||||
switch (inputType) {
|
||||
case "category":
|
||||
// 카테고리 배지 렌더링
|
||||
return <Badge>{categoryLabel}</Badge>;
|
||||
|
||||
case "code":
|
||||
// 코드명 표시
|
||||
return codeName;
|
||||
|
||||
case "date":
|
||||
// 날짜 포맷팅
|
||||
return formatDate(value);
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 검색 필터 생성
|
||||
|
||||
```typescript
|
||||
// ✅ inputType에 따라 다른 검색 UI 제공
|
||||
const renderSearchInput = (column: ColumnConfig) => {
|
||||
const inputType = columnMeta[column.columnName]?.inputType;
|
||||
|
||||
switch (inputType) {
|
||||
case "category":
|
||||
return <CategorySelect column={column} />;
|
||||
|
||||
case "code":
|
||||
return <CodeSelect column={column} />;
|
||||
|
||||
case "date":
|
||||
return <DateRangePicker column={column} />;
|
||||
|
||||
case "number":
|
||||
return <NumberRangeInput column={column} />;
|
||||
|
||||
default:
|
||||
return <TextInput column={column} />;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 체크리스트
|
||||
|
||||
기존 코드에서 `webType`을 `inputType`으로 전환할 때:
|
||||
|
||||
- [ ] `webType` 참조를 모두 `inputType`으로 변경
|
||||
- [ ] API 호출 시 `getColumnInputTypes()` 포함 확인
|
||||
- [ ] 캐시 사용 시 `cached.inputTypes` 매핑 확인
|
||||
- [ ] 타입 정의에서 `inputType` 필드 포함
|
||||
- [ ] 조건문에서 `inputType` 체크로 변경
|
||||
- [ ] 테스트 실행하여 정상 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 팁
|
||||
|
||||
### inputType이 undefined인 경우
|
||||
|
||||
```typescript
|
||||
// 디버깅 로그 추가
|
||||
console.log("columnMeta:", columnMeta);
|
||||
console.log("inputType:", columnMeta[columnName]?.inputType);
|
||||
|
||||
// 체크 포인트:
|
||||
// 1. getColumnInputTypes() 호출 확인
|
||||
// 2. inputTypeMap 생성 확인
|
||||
// 3. meta 객체에 inputType 할당 확인
|
||||
// 4. 캐시 사용 시 cached.inputTypes 확인
|
||||
```
|
||||
|
||||
### webType만 있고 inputType이 없는 경우
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 데이터 구조
|
||||
{
|
||||
material: {
|
||||
webType: "category",
|
||||
codeCategory: "",
|
||||
// inputType 누락!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 올바른 데이터 구조
|
||||
{
|
||||
material: {
|
||||
webType: "category", // 레거시, 무시됨
|
||||
codeCategory: "",
|
||||
inputType: "category" // ✅ 필수!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **컴포넌트**: `/frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||
- **API 클라이언트**: `/frontend/lib/api/tableType.ts`
|
||||
- **타입 정의**: `/frontend/types/table.ts`
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
1. **항상 `inputType` 사용**, `webType` 사용 금지
|
||||
2. **API에서 `getColumnInputTypes()` 호출** 필수
|
||||
3. **캐시 사용 시 `inputTypes` 포함** 확인
|
||||
4. **디버깅 시 `inputType` 값 확인**
|
||||
5. **기존 코드 마이그레이션** 시 체크리스트 활용
|
||||
|
|
@ -1,844 +0,0 @@
|
|||
---
|
||||
priority: critical
|
||||
applies_to: all
|
||||
check_frequency: always
|
||||
enforcement: mandatory
|
||||
---
|
||||
|
||||
# 멀티테넌시(Multi-Tenancy) 필수 구현 가이드
|
||||
|
||||
**🚨 최우선 보안 규칙: 이 문서의 모든 규칙은 예외 없이 반드시 준수해야 합니다.**
|
||||
|
||||
**⚠️ AI 에이전트는 모든 코드 작성/수정 후 반드시 이 체크리스트를 확인해야 합니다.**
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**모든 비즈니스 데이터는 회사별(company_code)로 완벽하게 격리되어야 합니다.**
|
||||
|
||||
이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다.
|
||||
다른 회사의 데이터에 접근하는 것은 **치명적인 보안 취약점**입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 스키마 요구사항
|
||||
|
||||
### 1.1 company_code 컬럼 필수
|
||||
|
||||
**모든 비즈니스 테이블은 `company_code` 컬럼을 반드시 포함해야 합니다.**
|
||||
|
||||
```sql
|
||||
CREATE TABLE example_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL, -- ✅ 필수!
|
||||
name VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 외래키 제약조건 (필수)
|
||||
CONSTRAINT fk_company FOREIGN KEY (company_code)
|
||||
REFERENCES company_mng(company_code)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 성능을 위한 인덱스 (필수)
|
||||
CREATE INDEX idx_example_company_code ON example_table(company_code);
|
||||
|
||||
-- 복합 유니크 제약조건 (중복 방지)
|
||||
CREATE UNIQUE INDEX idx_example_unique
|
||||
ON example_table(name, company_code); -- 회사별로 고유해야 하는 경우
|
||||
```
|
||||
|
||||
### 1.2 예외 테이블 (company_code 불필요)
|
||||
|
||||
**⚠️ 유일한 예외: `company_mng` 테이블만 `company_code`가 없습니다.**
|
||||
|
||||
이 테이블은 회사 정보를 저장하는 마스터 테이블이므로 예외입니다.
|
||||
|
||||
**모든 다른 테이블은 예외 없이 `company_code`가 필수입니다:**
|
||||
|
||||
- ✅ `user_info` → `company_code` 필수 (사용자는 특정 회사 소속)
|
||||
- ✅ `menu_info` → `company_code` 필수 (회사별 메뉴 설정 가능)
|
||||
- ✅ `system_config` → `company_code` 필수 (회사별 시스템 설정)
|
||||
- ✅ `audit_log` → `company_code` 필수 (회사별 감사 로그)
|
||||
- ✅ 모든 비즈니스 테이블 → `company_code` 필수
|
||||
|
||||
**새로운 테이블 생성 시 체크리스트:**
|
||||
|
||||
- [ ] `company_mng` 테이블인가? → `company_code` 불필요 (유일한 예외)
|
||||
- [ ] 그 외 모든 테이블 → `company_code` 필수 (예외 없음)
|
||||
- [ ] `company_code` 없이 테이블을 만들려고 하는가? → 다시 생각하세요!
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 API 구현 필수 사항
|
||||
|
||||
### 2.1 모든 데이터 조회 시 필터링
|
||||
|
||||
**절대 원칙: 모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 데이터 조회 가능
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
logger.info("최고 관리자 전체 데이터 조회");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 데이터만 조회
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [companyCode];
|
||||
logger.info("회사별 데이터 조회", { companyCode });
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법 - 절대 사용 금지
|
||||
|
||||
```typescript
|
||||
// 🚨 치명적 보안 취약점: company_code 필터링 없음
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출!
|
||||
const result = await pool.query(query);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 데이터 생성 (INSERT)
|
||||
|
||||
**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function createData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 서버에서 확정
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, name, description]);
|
||||
|
||||
logger.info("데이터 생성", {
|
||||
companyCode,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 클라이언트 입력 사용 금지
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 클라이언트가 임의의 회사 코드 지정 가능
|
||||
async function createData(req: Request, res: Response) {
|
||||
const { companyCode, name } = req.body; // 사용자가 다른 회사 코드 전달 가능!
|
||||
const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
|
||||
await pool.query(query, [companyCode, name]);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 데이터 수정 (UPDATE)
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function updateData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 데이터 수정 가능
|
||||
query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
params = [name, description, id];
|
||||
} else {
|
||||
// 일반 회사: 자신의 데이터만 수정 가능
|
||||
query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND company_code = $4
|
||||
RETURNING *
|
||||
`;
|
||||
params = [name, description, id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 수정", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 다른 회사의 같은 ID 데이터도 수정됨
|
||||
const query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2
|
||||
WHERE id = $3
|
||||
`;
|
||||
```
|
||||
|
||||
### 2.4 데이터 삭제 (DELETE)
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
async function deleteData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 데이터 삭제 가능
|
||||
query = `DELETE FROM example_table WHERE id = $1 RETURNING id`;
|
||||
params = [id];
|
||||
} else {
|
||||
// 일반 회사: 자신의 데이터만 삭제 가능
|
||||
query = `
|
||||
DELETE FROM example_table
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. company_code = "\*" 의 의미
|
||||
|
||||
### 3.1 최고 관리자 전용 데이터
|
||||
|
||||
**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
|
||||
|
||||
- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
|
||||
- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
|
||||
|
||||
### 3.2 데이터 격리 원칙
|
||||
|
||||
**회사별 데이터 접근 규칙:**
|
||||
|
||||
| 사용자 유형 | company_code | 접근 가능한 데이터 |
|
||||
| ----------- | ------------ | ---------------------------------------------- |
|
||||
| 회사 A | `COMPANY_A` | `company_code = 'COMPANY_A'` 데이터만 |
|
||||
| 회사 B | `COMPANY_B` | `company_code = 'COMPANY_B'` 데이터만 |
|
||||
| 최고 관리자 | `*` | 모든 회사 데이터 + `company_code = '*'` 데이터 |
|
||||
|
||||
**핵심**:
|
||||
|
||||
- 일반 회사는 `company_code = "*"` 데이터를 **절대 볼 수 없음**
|
||||
- 일반 회사는 다른 회사의 데이터를 **절대 볼 수 없음**
|
||||
- 최고 관리자만 모든 데이터에 접근 가능
|
||||
|
||||
---
|
||||
|
||||
## 4. 복잡한 쿼리에서의 멀티테넌시
|
||||
|
||||
### 4.1 JOIN 쿼리
|
||||
|
||||
**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name,
|
||||
c.name as user_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b
|
||||
ON a.category_id = b.id
|
||||
AND a.company_code = b.company_code -- ✅ JOIN 조건에도 company_code 필수
|
||||
LEFT JOIN user_info c
|
||||
ON a.user_id = c.user_id
|
||||
AND a.company_code = c.company_code
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: JOIN에서 다른 회사 데이터와 섞임
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음!
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.2 서브쿼리
|
||||
|
||||
**모든 서브쿼리에도 company_code 필터링을 적용해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1 -- ✅
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 서브쿼리에서 company_code 누락
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true -- company_code 없음!
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.3 집계 함수 (COUNT, SUM 등)
|
||||
|
||||
**집계 함수도 company_code로 필터링해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### ❌ 잘못된 방법
|
||||
|
||||
```typescript
|
||||
// 🚨 보안 취약점: 모든 회사의 총합 반환
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
```
|
||||
|
||||
### 4.4 EXISTS 서브쿼리
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM related_table b
|
||||
WHERE b.example_id = a.id
|
||||
AND b.company_code = a.company_code -- ✅ 필수
|
||||
)
|
||||
AND a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 자동 필터 시스템 (autoFilter)
|
||||
|
||||
### 5.1 백엔드 구현 (이미 완료)
|
||||
|
||||
백엔드에는 `autoFilter` 기능이 구현되어 있습니다:
|
||||
|
||||
```typescript
|
||||
// tableManagementController.ts
|
||||
let enhancedSearch = { ...search };
|
||||
if (autoFilter?.enabled && req.user) {
|
||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||
const userField = autoFilter.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
if (userValue) {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
filterColumn,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 프론트엔드 사용 (필수)
|
||||
|
||||
**모든 테이블 데이터 API 호출 시 `autoFilter`를 반드시 전달해야 합니다.**
|
||||
|
||||
#### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/screen.ts
|
||||
const requestBody = {
|
||||
...params,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
requestBody
|
||||
);
|
||||
```
|
||||
|
||||
#### Entity 조인 API
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/entityJoin.ts
|
||||
const autoFilter = {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
};
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/table-management/tables/${tableName}/data-with-joins`,
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
autoFilter: JSON.stringify(autoFilter),
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 서비스 계층 패턴
|
||||
|
||||
### 6.1 표준 서비스 함수 패턴
|
||||
|
||||
**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다.**
|
||||
|
||||
```typescript
|
||||
class ExampleService {
|
||||
async findAll(companyCode: string, filters?: any) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `SELECT * FROM example_table`;
|
||||
params = [];
|
||||
} else {
|
||||
query = `SELECT * FROM example_table WHERE company_code = $1`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
return await pool.query(query, params);
|
||||
}
|
||||
|
||||
async findById(companyCode: string, id: number) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `SELECT * FROM example_table WHERE id = $1`;
|
||||
params = [id];
|
||||
} else {
|
||||
query = `SELECT * FROM example_table WHERE id = $1 AND company_code = $2`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async create(companyCode: string, data: any) {
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(query, [
|
||||
companyCode,
|
||||
data.name,
|
||||
data.description,
|
||||
]);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 컨트롤러에서 사용
|
||||
const exampleService = new ExampleService();
|
||||
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const data = await exampleService.findAll(companyCode, req.query);
|
||||
return res.json({ success: true, data });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 체크리스트
|
||||
|
||||
### 7.1 새로운 테이블 생성 시
|
||||
|
||||
- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가
|
||||
- [ ] `company_mng` 테이블에 대한 외래키 제약조건 추가
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 복합 유니크 제약조건에 `company_code` 포함
|
||||
- [ ] 샘플 데이터에 올바른 `company_code` 값 포함
|
||||
|
||||
### 7.2 기존 테이블 마이그레이션 시
|
||||
|
||||
```sql
|
||||
-- 1. company_code 컬럼 추가
|
||||
ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20);
|
||||
|
||||
-- 2. 기존 데이터를 모든 회사별로 복제
|
||||
INSERT INTO example_table (company_code, name, description, created_at)
|
||||
SELECT ci.company_code, et.name, et.description, et.created_at
|
||||
FROM (SELECT * FROM example_table WHERE company_code IS NULL) et
|
||||
CROSS JOIN company_mng ci
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM example_table et2
|
||||
WHERE et2.name = et.name
|
||||
AND et2.company_code = ci.company_code
|
||||
);
|
||||
|
||||
-- 3. NULL 데이터 삭제
|
||||
DELETE FROM example_table WHERE company_code IS NULL;
|
||||
|
||||
-- 4. NOT NULL 제약조건
|
||||
ALTER TABLE example_table ALTER COLUMN company_code SET NOT NULL;
|
||||
|
||||
-- 5. 인덱스 및 외래키
|
||||
CREATE INDEX idx_example_company ON example_table(company_code);
|
||||
ALTER TABLE example_table
|
||||
ADD CONSTRAINT fk_example_company
|
||||
FOREIGN KEY (company_code) REFERENCES company_mng(company_code)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 체크리스트
|
||||
|
||||
### 8.1 필수 테스트 시나리오
|
||||
|
||||
**모든 새로운 API는 다음 테스트를 통과해야 합니다:**
|
||||
|
||||
- [ ] **회사 A 테스트**: 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인
|
||||
- [ ] **회사 B 테스트**: 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인
|
||||
- [ ] **격리 테스트**: 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인
|
||||
- [ ] **최고 관리자 테스트**: 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인
|
||||
- [ ] **수정 권한 테스트**: 회사 A가 회사 B의 데이터를 수정할 수 없는지 확인
|
||||
- [ ] **삭제 권한 테스트**: 회사 A가 회사 B의 데이터를 삭제할 수 없는지 확인
|
||||
|
||||
### 8.2 SQL 인젝션 테스트
|
||||
|
||||
```typescript
|
||||
// company_code를 URL 파라미터로 전달하려는 시도 차단
|
||||
// ❌ 이런 요청을 받아서는 안 됨
|
||||
GET /api/data?company_code=COMPANY_B
|
||||
|
||||
// ✅ company_code는 항상 req.user에서 가져와야 함
|
||||
const companyCode = req.user!.companyCode;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 감사 로그 (Audit Log)
|
||||
|
||||
### 9.1 모든 중요 작업에 로깅
|
||||
|
||||
```typescript
|
||||
logger.info("데이터 생성", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
tableName: "example_table",
|
||||
action: "INSERT",
|
||||
recordId: result.rows[0].id,
|
||||
});
|
||||
|
||||
logger.warn("권한 없는 접근 시도", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
attemptedRecordId: req.params.id,
|
||||
message: "다른 회사의 데이터 접근 시도",
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 감사 로그 테이블 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
table_name VARCHAR(100),
|
||||
record_id VARCHAR(100),
|
||||
old_value JSONB,
|
||||
new_value JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_company ON audit_log(company_code);
|
||||
CREATE INDEX idx_audit_action ON audit_log(action, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안 체크리스트 (코드 리뷰 시 필수)
|
||||
|
||||
### 10.1 백엔드 API 체크리스트
|
||||
|
||||
- [ ] 모든 SELECT 쿼리에 `WHERE company_code = $1` 포함 (최고 관리자 예외)
|
||||
- [ ] 모든 INSERT 쿼리에 `company_code` 컬럼 포함
|
||||
- [ ] 모든 UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 포함
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 포함
|
||||
- [ ] 서브쿼리에 `company_code` 필터링 포함
|
||||
- [ ] 집계 함수에 `company_code` 필터링 포함
|
||||
- [ ] `req.user.companyCode` 사용 (클라이언트 입력 사용 금지)
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리
|
||||
- [ ] 로그에 `companyCode` 정보 포함
|
||||
- [ ] 권한 없음 시 404 또는 403 반환
|
||||
|
||||
### 10.2 프론트엔드 체크리스트
|
||||
|
||||
- [ ] 모든 테이블 데이터 API 호출 시 `autoFilter` 전달
|
||||
- [ ] `company_code`를 직접 전달하지 않음 (백엔드에서 자동 처리)
|
||||
- [ ] 에러 발생 시 적절한 메시지 표시
|
||||
|
||||
### 10.3 데이터베이스 체크리스트
|
||||
|
||||
- [ ] 모든 비즈니스 테이블에 `company_code` 컬럼 존재
|
||||
- [ ] `company_code`에 NOT NULL 제약조건 적용
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 외래키 제약조건으로 `company_mng` 참조
|
||||
- [ ] 복합 유니크 제약조건에 `company_code` 포함
|
||||
|
||||
---
|
||||
|
||||
## 11. 일반적인 실수와 해결방법
|
||||
|
||||
### 실수 1: 서브쿼리에서 company_code 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 실수 2: COUNT/SUM 집계 함수
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법 - 모든 회사의 총합
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 실수 3: autoFilter 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
}
|
||||
);
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 참고 자료
|
||||
|
||||
### 완료된 구현 예시
|
||||
|
||||
- **테이블 데이터 API**: `backend-node/src/controllers/tableManagementController.ts` (getTableData)
|
||||
- **Entity 조인 API**: `backend-node/src/controllers/entityJoinController.ts` (getTableDataWithJoins)
|
||||
- **카테고리 값 API**: `backend-node/src/services/tableCategoryValueService.ts` (getCategoryValues)
|
||||
- **프론트엔드 API**: `frontend/lib/api/screen.ts` (getTableData)
|
||||
- **프론트엔드 Entity 조인**: `frontend/lib/api/entityJoin.ts` (getTableDataWithJoins)
|
||||
|
||||
### 마이그레이션 스크립트
|
||||
|
||||
- `db/migrations/044_simple_version.sql` - table_type_columns에 company_code 추가
|
||||
- `db/migrations/045_add_company_code_to_category_values.sql` - 카테고리 값 테이블 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
## 요약: 절대 잊지 말아야 할 핵심 규칙
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
1. **모든 테이블에 `company_code` 필수** (`company_mng` 제외)
|
||||
2. **인덱스와 외래키 필수**
|
||||
3. **복합 유니크 제약조건에 `company_code` 포함**
|
||||
|
||||
### 백엔드 API
|
||||
|
||||
1. **모든 SELECT 쿼리**: `WHERE company_code = $1` (최고 관리자 제외)
|
||||
2. **모든 INSERT 쿼리**: `company_code` 컬럼 포함
|
||||
3. **모든 UPDATE/DELETE 쿼리**: WHERE 절에 `company_code` 조건 포함
|
||||
4. **JOIN/서브쿼리/집계**: 모두 `company_code` 필터링 필수
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
1. **모든 테이블 데이터 API 호출**: `autoFilter` 전달 필수
|
||||
2. **`company_code`를 직접 전달 금지**: 백엔드에서 자동 처리
|
||||
|
||||
---
|
||||
|
||||
**🚨 멀티테넌시는 보안의 핵심입니다. 예외 없이 모든 규칙을 준수하세요!**
|
||||
|
||||
**⚠️ company_code = "\*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||
|
||||
**✅ 모든 테이블에 company_code 필수! (company_mng 제외)**
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI 에이전트 필수 체크리스트
|
||||
|
||||
**모든 코드 작성/수정 완료 후 반드시 다음을 확인하세요:**
|
||||
|
||||
### 데이터베이스 마이그레이션을 작성했다면:
|
||||
|
||||
- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가했는가?
|
||||
- [ ] `company_code`에 인덱스를 생성했는가?
|
||||
- [ ] `company_mng` 테이블에 대한 외래키를 추가했는가?
|
||||
- [ ] 복합 유니크 제약조건에 `company_code`를 포함했는가?
|
||||
- [ ] 기존 데이터를 모든 회사별로 복제했는가?
|
||||
|
||||
### 백엔드 API를 작성/수정했다면:
|
||||
|
||||
- [ ] SELECT 쿼리에 `WHERE company_code = $1` 조건이 있는가? (최고 관리자 제외)
|
||||
- [ ] INSERT 쿼리에 `company_code` 컬럼이 포함되어 있는가?
|
||||
- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건이 있는가?
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건이 있는가?
|
||||
- [ ] 서브쿼리에 `company_code` 필터링이 있는가?
|
||||
- [ ] 집계 함수(COUNT, SUM 등)에 `company_code` 필터링이 있는가?
|
||||
- [ ] `req.user.companyCode`를 사용하고 있는가? (클라이언트 입력 사용 금지)
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리를 했는가?
|
||||
- [ ] 로그에 `companyCode` 정보를 포함했는가?
|
||||
- [ ] 권한 없음 시 적절한 HTTP 상태 코드(404/403)를 반환하는가?
|
||||
|
||||
### 프론트엔드 API 호출을 작성/수정했다면:
|
||||
|
||||
- [ ] `autoFilter` 옵션을 전달하고 있는가?
|
||||
- [ ] `autoFilter.enabled = true`로 설정했는가?
|
||||
- [ ] `autoFilter.filterColumn = "company_code"`로 설정했는가?
|
||||
- [ ] `autoFilter.userField = "companyCode"`로 설정했는가?
|
||||
- [ ] `company_code`를 직접 전달하지 않았는가? (백엔드 자동 처리)
|
||||
|
||||
### 테스트를 수행했다면:
|
||||
|
||||
- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인했는가?
|
||||
- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인했는가?
|
||||
- [ ] 회사 A가 회사 B 데이터에 접근할 수 없는지 확인했는가?
|
||||
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가?
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!**
|
||||
|
||||
**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!**
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
# 다국어 지원 컴포넌트 개발 가이드
|
||||
|
||||
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
|
||||
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 타입 정의 시 다국어 필드 추가
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
|
||||
|
||||
### 단일 텍스트 속성
|
||||
|
||||
```typescript
|
||||
interface MyComponentConfig {
|
||||
// 기본 텍스트
|
||||
title?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
|
||||
// 라벨
|
||||
label?: string;
|
||||
labelLangKeyId?: number;
|
||||
labelLangKey?: string;
|
||||
|
||||
// 플레이스홀더
|
||||
placeholder?: string;
|
||||
placeholderLangKeyId?: number;
|
||||
placeholderLangKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 배열/목록 속성 (컬럼, 탭 등)
|
||||
|
||||
```typescript
|
||||
interface ColumnConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 기타 속성
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 탭 제목도 별도로
|
||||
title?: string;
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
}
|
||||
|
||||
interface MyComponentConfig {
|
||||
columns?: ColumnConfig[];
|
||||
tabs?: TabConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
### 버튼 컴포넌트
|
||||
|
||||
```typescript
|
||||
interface ButtonComponentConfig {
|
||||
text?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 실제 예시: 분할 패널
|
||||
|
||||
```typescript
|
||||
interface SplitPanelLayoutConfig {
|
||||
leftPanel?: {
|
||||
title?: string;
|
||||
langKeyId?: number; // 좌측 패널 제목 다국어
|
||||
langKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number; // 각 컬럼 다국어
|
||||
langKey?: string;
|
||||
}>;
|
||||
};
|
||||
rightPanel?: {
|
||||
title?: string;
|
||||
langKeyId?: number; // 우측 패널 제목 다국어
|
||||
langKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}>;
|
||||
additionalTabs?: Array<{
|
||||
label: string;
|
||||
langKeyId?: number; // 탭 라벨 다국어
|
||||
langKey?: string;
|
||||
title?: string;
|
||||
titleLangKeyId?: number; // 탭 제목 다국어
|
||||
titleLangKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 라벨 추출 로직 등록
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `extractMultilangLabels` 함수에 추가
|
||||
|
||||
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 타입 체크
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 1. 제목 추출
|
||||
if (config?.title) {
|
||||
addLabel({
|
||||
id: `${comp.id}_title`,
|
||||
componentId: `${comp.id}_title`,-
|
||||
label: config.title,
|
||||
type: "title",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title,
|
||||
langKeyId: config.titleLangKeyId,
|
||||
langKey: config.titleLangKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 컬럼 추출
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
config.columns.forEach((col, index) => {
|
||||
const colLabel = col.label || col.name;
|
||||
addLabel({
|
||||
id: `${comp.id}_col_${index}`,
|
||||
componentId: `${comp.id}_col_${index}`,
|
||||
label: colLabel,
|
||||
type: "column",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title || "새 컴포넌트",
|
||||
langKeyId: col.langKeyId,
|
||||
langKey: col.langKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
|
||||
if (config?.text) {
|
||||
addLabel({
|
||||
id: `${comp.id}_button`,
|
||||
componentId: `${comp.id}_button`,
|
||||
label: config.text,
|
||||
type: "button",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.text,
|
||||
langKeyId: config.langKeyId,
|
||||
langKey: config.langKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 추출해야 할 라벨 타입
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
| ------------- | ------------------ | ------------------------ |
|
||||
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
|
||||
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
|
||||
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
|
||||
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
|
||||
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
|
||||
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
|
||||
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
|
||||
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 매핑 적용 로직 등록
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `applyMultilangMappings` 함수에 추가
|
||||
|
||||
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 매핑 적용
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 1. 제목 매핑
|
||||
const titleMapping = mappingMap.get(`${comp.id}_title`);
|
||||
if (titleMapping) {
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
titleLangKeyId: titleMapping.keyId,
|
||||
titleLangKey: titleMapping.langKey,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 컬럼 매핑
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
const updatedColumns = config.columns.map((col, index) => {
|
||||
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
|
||||
if (colMapping) {
|
||||
return {
|
||||
...col,
|
||||
langKeyId: colMapping.keyId,
|
||||
langKey: colMapping.langKey,
|
||||
};
|
||||
}
|
||||
return col;
|
||||
});
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
columns: updatedColumns,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
|
||||
const buttonMapping = mappingMap.get(`${comp.id}_button`);
|
||||
if (buttonMapping) {
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
langKeyId: buttonMapping.keyId,
|
||||
langKey: buttonMapping.langKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
|
||||
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
|
||||
|
||||
```typescript
|
||||
// 잘못된 방법 - 이전 업데이트 덮어쓰기
|
||||
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
|
||||
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
|
||||
|
||||
// 올바른 방법 - 이전 업데이트 유지
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
langKeyId: mapping.keyId,
|
||||
}; // ✅
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
columns: updatedColumns,
|
||||
}; // ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 번역 표시 로직 구현
|
||||
|
||||
### 파일 위치
|
||||
|
||||
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
|
||||
|
||||
### Context 사용
|
||||
|
||||
```typescript
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
const MyComponent = ({ component }: Props) => {
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
const config = component.componentConfig;
|
||||
|
||||
// 제목 번역
|
||||
const displayTitle = config?.titleLangKey
|
||||
? getTranslatedText(config.titleLangKey, config.title || "")
|
||||
: config?.title || "";
|
||||
|
||||
// 컬럼 헤더 번역
|
||||
const translatedColumns = config?.columns?.map((col) => ({
|
||||
...col,
|
||||
displayLabel: col.langKey
|
||||
? getTranslatedText(col.langKey, col.label)
|
||||
: col.label,
|
||||
}));
|
||||
|
||||
// 버튼 텍스트 번역
|
||||
const buttonText = config?.langKey
|
||||
? getTranslatedText(config.langKey, config.text || "")
|
||||
: config?.text || "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{displayTitle}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{translatedColumns?.map((col, idx) => (
|
||||
<th key={idx}>{col.displayLabel}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<button>{buttonText}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### getTranslatedText 함수
|
||||
|
||||
```typescript
|
||||
// 첫 번째 인자: langKey (다국어 키)
|
||||
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
|
||||
const text = getTranslatedText(
|
||||
"screen.company_1.Sales.OrderList.품목명",
|
||||
"품목명"
|
||||
);
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- `langKey`가 없으면 원본 텍스트를 표시합니다.
|
||||
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
|
||||
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. ScreenMultiLangContext에 키 수집 로직 추가
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/contexts/ScreenMultiLangContext.tsx`
|
||||
|
||||
### `collectLangKeys` 함수에 추가
|
||||
|
||||
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
|
||||
|
||||
```typescript
|
||||
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
|
||||
const keys = new Set<string>();
|
||||
|
||||
const processComponent = (comp: ComponentData) => {
|
||||
const config = comp.componentConfig;
|
||||
|
||||
// 새 컴포넌트의 langKey 수집
|
||||
if (comp.componentType === "my-new-component") {
|
||||
// 제목
|
||||
if (config?.titleLangKey) {
|
||||
keys.add(config.titleLangKey);
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
config.columns.forEach((col: any) => {
|
||||
if (col.langKey) {
|
||||
keys.add(col.langKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 버튼
|
||||
if (config?.langKey) {
|
||||
keys.add(config.langKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 처리
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
comp.children.forEach(processComponent);
|
||||
}
|
||||
};
|
||||
|
||||
comps.forEach(processComponent);
|
||||
return keys;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. MultilangSettingsModal에 표시 로직 추가
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
|
||||
|
||||
### `extractLabelsFromComponents` 함수에 추가
|
||||
|
||||
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 라벨 추출
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 제목
|
||||
if (config?.title) {
|
||||
addLabel({
|
||||
id: `${comp.id}_title`,
|
||||
componentId: `${comp.id}_title`,
|
||||
label: config.title,
|
||||
type: "title",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title,
|
||||
langKeyId: config.titleLangKeyId,
|
||||
langKey: config.titleLangKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (config?.columns) {
|
||||
config.columns.forEach((col, index) => {
|
||||
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
|
||||
const tableName = config.tableName;
|
||||
const displayLabel =
|
||||
tableName && columnLabelMap[tableName]?.[col.name]
|
||||
? columnLabelMap[tableName][col.name]
|
||||
: col.label || col.name;
|
||||
|
||||
addLabel({
|
||||
id: `${comp.id}_col_${index}`,
|
||||
componentId: `${comp.id}_col_${index}`,
|
||||
label: displayLabel,
|
||||
type: "column",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title || "새 컴포넌트",
|
||||
langKeyId: col.langKeyId,
|
||||
langKey: col.langKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `extractTableNames` 함수에 추가
|
||||
|
||||
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
|
||||
|
||||
```typescript
|
||||
const extractTableNames = (comps: ComponentData[]): Set<string> => {
|
||||
const tableNames = new Set<string>();
|
||||
|
||||
const processComponent = (comp: ComponentData) => {
|
||||
const config = comp.componentConfig;
|
||||
|
||||
// 새 컴포넌트의 테이블명 추출
|
||||
if (comp.componentType === "my-new-component") {
|
||||
if (config?.tableName) {
|
||||
tableNames.add(config.tableName);
|
||||
}
|
||||
if (config?.selectedTable) {
|
||||
tableNames.add(config.selectedTable);
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 처리
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
comp.children.forEach(processComponent);
|
||||
}
|
||||
};
|
||||
|
||||
comps.forEach(processComponent);
|
||||
return tableNames;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
새 컴포넌트 개발 시 다음 항목을 확인하세요:
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
|
||||
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
|
||||
|
||||
### 라벨 추출 (multilangLabelExtractor.ts)
|
||||
|
||||
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
|
||||
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
|
||||
|
||||
### 매핑 적용 (multilangLabelExtractor.ts)
|
||||
|
||||
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
|
||||
|
||||
### 번역 표시 (컴포넌트 파일)
|
||||
|
||||
- [ ] `useScreenMultiLang` 훅 사용
|
||||
- [ ] `getTranslatedText`로 텍스트 번역 적용
|
||||
|
||||
### 키 수집 (ScreenMultiLangContext.tsx)
|
||||
|
||||
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
|
||||
|
||||
### 설정 모달 (MultilangSettingsModal.tsx)
|
||||
|
||||
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 파일 목록
|
||||
|
||||
| 파일 | 역할 |
|
||||
| -------------------------------------------------------------- | ----------------------- |
|
||||
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
|
||||
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
|
||||
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
|
||||
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
|
||||
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
|
||||
|
||||
- 제목: `${comp.id}_title`
|
||||
- 컬럼: `${comp.id}_col_${index}`
|
||||
- 버튼: `${comp.id}_button`
|
||||
|
||||
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
|
||||
|
||||
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
|
||||
|
||||
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
|
||||
|
||||
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
|
||||
|
||||
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
---
|
||||
description: 스크롤 문제 디버깅 및 해결 가이드 - Flexbox 레이아웃에서 스크롤이 작동하지 않을 때 체계적인 진단과 해결 방법
|
||||
---
|
||||
|
||||
# 스크롤 문제 디버깅 및 해결 가이드
|
||||
|
||||
React/Next.js 프로젝트에서 Flexbox 레이아웃의 스크롤이 작동하지 않을 때 사용하는 체계적인 디버깅 및 해결 방법입니다.
|
||||
|
||||
## 1. 스크롤 문제의 일반적인 원인
|
||||
|
||||
### 근본 원인: Flexbox의 높이 계산 실패
|
||||
|
||||
Flexbox 레이아웃에서 스크롤이 작동하지 않는 이유:
|
||||
|
||||
1. **부모 컨테이너의 높이가 확정되지 않음**: `h-full`은 부모가 명시적인 높이를 가져야만 작동
|
||||
2. **`minHeight: auto` 기본값**: Flex item은 콘텐츠 크기만큼 늘어나려고 함
|
||||
3. **`overflow` 속성 누락**: 부모가 `overflow: hidden`이 없으면 자식이 부모를 밀어냄
|
||||
4. **`display: flex` 누락**: Flex container가 명시적으로 선언되지 않음
|
||||
|
||||
## 2. 디버깅 프로세스
|
||||
|
||||
### 단계 1: 시각적 디버깅 (컬러 테두리)
|
||||
|
||||
문제가 발생한 컴포넌트에 **컬러 테두리**를 추가하여 각 레이어의 실제 크기를 확인:
|
||||
|
||||
```tsx
|
||||
// 최상위 컨테이너 (빨간색)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
border: "3px solid red", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (파란색) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
border: "3px solid blue", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 (초록색) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
border: "3px solid green", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
콘텐츠
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**브라우저에서 확인할 사항:**
|
||||
|
||||
- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가?
|
||||
- 🔵 파란색 테두리가 고정된 높이를 유지하는가?
|
||||
- 🟢 초록색 테두리가 남은 공간을 차지하는가?
|
||||
|
||||
### 단계 2: 부모 체인 추적
|
||||
|
||||
스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**:
|
||||
|
||||
```tsx
|
||||
// ❌ 문제 예시
|
||||
<div className="flex flex-col"> {/* 높이가 확정되지 않음 */}
|
||||
<div className="flex-1"> {/* flex-1이 작동하지 않음 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 실패 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 해결
|
||||
<div className="flex flex-col h-screen"> {/* 높이 확정 */}
|
||||
<div className="flex-1 overflow-hidden"> {/* overflow 제한 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 성공 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 단계 3: 개발자 도구로 Computed Style 확인
|
||||
|
||||
브라우저 개발자 도구에서 확인:
|
||||
|
||||
1. **Height**: `auto`가 아닌 구체적인 px 값이 있는가?
|
||||
2. **Display**: `flex`가 제대로 적용되었는가?
|
||||
3. **Overflow**: `overflow-y: auto` 또는 `scroll`이 적용되었는가?
|
||||
4. **Min-height**: `minHeight: 0`이 적용되었는가? (Flex item의 경우)
|
||||
|
||||
## 3. 해결 패턴
|
||||
|
||||
### 패턴 A: 최상위 Fixed/Absolute 컨테이너
|
||||
|
||||
```tsx
|
||||
// 페이지 레벨 (예: dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 (고정) */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 (flex-1) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{" "}
|
||||
{/* ⚠️ overflow-hidden 필수! */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `fixed inset-0`: 뷰포트 전체 차지
|
||||
- `flex h-full flex-col`: Flex column 레이아웃
|
||||
- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한
|
||||
|
||||
### 패턴 B: 중첩된 Flex 컨테이너
|
||||
|
||||
```tsx
|
||||
// 컴포넌트 레벨 (예: FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: "100%", overflow: "hidden" }} // ⚠️ 인라인 스타일로 강제
|
||||
>
|
||||
{/* 좌측 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r bg-white">사이드바</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1">캔버스</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex", // ⚠️ Flex 컨테이너 명시
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음
|
||||
- `display: "flex"`: Flex 컨테이너 명시
|
||||
- `overflow: 'hidden'`: 자식 크기 제한
|
||||
|
||||
### 패턴 C: 스크롤 가능 영역
|
||||
|
||||
```tsx
|
||||
// 스크롤 영역 (예: PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden", // ⚠️ 최상위는 overflow hidden
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (고정) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0, // ⚠️ 축소 방지
|
||||
height: "64px", // ⚠️ 명시적 높이
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1, // ⚠️ 남은 공간 차지
|
||||
minHeight: 0, // ⚠️ 핵심! Flex item 축소 허용
|
||||
overflowY: "auto", // ⚠️ 세로 스크롤
|
||||
overflowX: "hidden", // ⚠️ 가로 스크롤 방지
|
||||
}}
|
||||
>
|
||||
{/* 실제 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `flexShrink: 0`: 헤더가 축소되지 않도록 고정
|
||||
- `minHeight: 0`: **가장 중요!** Flex item이 축소되도록 허용
|
||||
- `flex: 1`: 남은 공간 모두 차지
|
||||
- `overflowY: 'auto'`: 콘텐츠가 넘치면 스크롤 생성
|
||||
|
||||
## 4. 왜 `minHeight: 0`이 필요한가?
|
||||
|
||||
### Flexbox의 기본 동작
|
||||
|
||||
```css
|
||||
/* Flexbox의 기본값 */
|
||||
.flex-item {
|
||||
min-height: auto; /* 콘텐츠 크기만큼 늘어남 */
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Flex item은 **콘텐츠 크기만큼 늘어나려고 함**
|
||||
- `flex: 1`만으로는 **스크롤이 생기지 않고 부모를 밀어냄**
|
||||
- 결과: 스크롤 영역이 화면 밖으로 넘어감
|
||||
|
||||
**해결:**
|
||||
|
||||
```css
|
||||
.flex-item {
|
||||
flex: 1;
|
||||
min-height: 0; /* 축소 허용 → 스크롤 발생 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tailwind vs 인라인 스타일
|
||||
|
||||
### 언제 인라인 스타일을 사용하는가?
|
||||
|
||||
**Tailwind가 작동하지 않을 때:**
|
||||
|
||||
```tsx
|
||||
// ❌ Tailwind가 작동하지 않음
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
// ✅ 인라인 스타일로 강제
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
```
|
||||
|
||||
**이유:**
|
||||
|
||||
1. **CSS 특이성**: 인라인 스타일이 가장 높은 우선순위
|
||||
2. **동적 계산**: 브라우저가 직접 해석
|
||||
3. **디버깅 쉬움**: 개발자 도구에서 바로 확인 가능
|
||||
|
||||
## 6. 체크리스트
|
||||
|
||||
스크롤 문제 발생 시 확인할 사항:
|
||||
|
||||
### 레이아웃 체크
|
||||
|
||||
- [ ] 최상위 컨테이너: `fixed` 또는 `absolute`로 높이 확정
|
||||
- [ ] 부모: `flex flex-col h-full`
|
||||
- [ ] 중간 컨테이너: `flex-1 overflow-hidden`
|
||||
- [ ] 스크롤 컨테이너 부모: `display: flex, flexDirection: column, height: 100%`
|
||||
|
||||
### 스크롤 영역 체크
|
||||
|
||||
- [ ] 헤더: `flexShrink: 0` + 명시적 높이
|
||||
- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto`
|
||||
- [ ] 콘텐츠: 자연스러운 높이 (height 제약 없음)
|
||||
|
||||
### 디버깅 체크
|
||||
|
||||
- [ ] 컬러 테두리로 각 레이어의 크기 확인
|
||||
- [ ] 개발자 도구로 Computed Style 확인
|
||||
- [ ] 부모 체인을 역순으로 추적
|
||||
- [ ] `minHeight: 0` 적용 확인
|
||||
|
||||
## 7. 일반적인 실수
|
||||
|
||||
### 실수 1: 부모의 높이 미확정
|
||||
|
||||
```tsx
|
||||
// ❌ 부모의 높이가 auto
|
||||
<div className="flex flex-col">
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 작동 안 함 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 부모의 높이 확정
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 작동 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 2: overflow-hidden 누락
|
||||
|
||||
```tsx
|
||||
// ❌ overflow-hidden 없음
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 부모를 밀어냄 */}
|
||||
</div>
|
||||
|
||||
// ✅ overflow-hidden 추가
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 제한됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 3: minHeight: 0 누락
|
||||
|
||||
```tsx
|
||||
// ❌ minHeight: 0 없음
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* 스크롤 안 됨 */}
|
||||
</div>
|
||||
|
||||
// ✅ minHeight: 0 추가
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{/* 스크롤 됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 4: display: flex 누락
|
||||
|
||||
```tsx
|
||||
// ❌ Flex 컨테이너 미지정
|
||||
<div style={{ height: '100%', width: '350px' }}>
|
||||
<PropertiesPanel /> {/* flex-1이 작동 안 함 */}
|
||||
</div>
|
||||
|
||||
// ✅ Flex 컨테이너 명시
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '350px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<PropertiesPanel /> {/* 작동 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 8. 완전한 예시
|
||||
|
||||
### 전체 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 페이지 (dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 에디터 (FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{/* 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r">
|
||||
사이드바
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<div className="relative flex-1">
|
||||
캔버스
|
||||
</div>
|
||||
|
||||
{/* 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 속성 패널 (PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 9. 요약
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **높이 확정**: 부모 체인의 모든 요소가 명시적인 높이를 가져야 함
|
||||
2. **overflow 제어**: 중간 컨테이너는 `overflow-hidden`으로 자식 제한
|
||||
3. **Flex 명시**: `display: flex` + `flexDirection: column` 명시
|
||||
4. **minHeight: 0**: 스크롤 영역의 Flex item은 반드시 `minHeight: 0` 적용
|
||||
5. **인라인 스타일**: Tailwind가 작동하지 않으면 인라인 스타일 사용
|
||||
|
||||
### 디버깅 순서
|
||||
|
||||
1. 🎨 **컬러 테두리** 추가로 시각적 확인
|
||||
2. 🔍 **개발자 도구**로 Computed Style 확인
|
||||
3. 🔗 **부모 체인** 역순으로 추적
|
||||
4. ✅ **체크리스트** 항목 확인
|
||||
5. 🔧 **패턴 적용** 및 테스트
|
||||
|
||||
### 최종 구조
|
||||
|
||||
```
|
||||
페이지 (fixed inset-0)
|
||||
└─ flex flex-col h-full
|
||||
├─ 헤더 (고정)
|
||||
└─ 컨테이너 (flex-1 overflow-hidden)
|
||||
└─ 에디터 (height: 100%, overflow: hidden)
|
||||
└─ flex row
|
||||
└─ 패널 (display: flex, flexDirection: column)
|
||||
└─ 패널 내부 (height: 100%)
|
||||
├─ 헤더 (flexShrink: 0, height: 64px)
|
||||
└─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto)
|
||||
```
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
이 가이드는 다음 파일을 기반으로 작성되었습니다:
|
||||
|
||||
- [dataflow/page.tsx](<mdc:frontend/app/(main)/admin/dataflow/page.tsx>)
|
||||
- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx)
|
||||
- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx)
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
# TableListComponent 개발 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
|
||||
|
||||
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 핵심 기능 목록
|
||||
|
||||
### 1. 인라인 편집 (Inline Editing)
|
||||
|
||||
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
|
||||
- 직접 타이핑으로도 편집 모드 진입 가능
|
||||
- Enter로 저장, Escape로 취소
|
||||
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
|
||||
|
||||
```typescript
|
||||
// ColumnConfig에서 editable 속성 사용
|
||||
interface ColumnConfig {
|
||||
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
|
||||
}
|
||||
```
|
||||
|
||||
**편집 불가 컬럼 체크 필수 위치**:
|
||||
1. `handleCellDoubleClick` - 더블클릭 편집
|
||||
2. `onKeyDown` F2 케이스 - 키보드 편집
|
||||
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
|
||||
4. 컨텍스트 메뉴 "셀 편집" 옵션
|
||||
|
||||
### 2. 배치 편집 (Batch Editing)
|
||||
|
||||
- 여러 셀 수정 후 일괄 저장/취소
|
||||
- `pendingChanges` Map으로 변경사항 추적
|
||||
- 저장 전 유효성 검증
|
||||
|
||||
### 3. 데이터 유효성 검증 (Validation)
|
||||
|
||||
```typescript
|
||||
type ValidationRule = {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
customMessage?: string;
|
||||
validate?: (value: any, row: any) => string | null;
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 컬럼 헤더 필터 (Header Filter)
|
||||
|
||||
- 각 컬럼 헤더에 필터 아이콘
|
||||
- 고유값 목록에서 다중 선택 필터링
|
||||
- `headerFilters` Map으로 필터 상태 관리
|
||||
|
||||
### 5. 필터 빌더 (Filter Builder)
|
||||
|
||||
```typescript
|
||||
interface FilterCondition {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "equals" | "notEquals" | "contains" | "notContains" |
|
||||
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
|
||||
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterGroup {
|
||||
id: string;
|
||||
logic: "AND" | "OR";
|
||||
conditions: FilterCondition[];
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 검색 패널 (Search Panel)
|
||||
|
||||
- 전체 데이터 검색
|
||||
- 검색어 하이라이팅
|
||||
- `searchHighlights` Map으로 하이라이트 위치 관리
|
||||
|
||||
### 7. 엑셀 내보내기 (Excel Export)
|
||||
|
||||
- `xlsx` 라이브러리 사용
|
||||
- 현재 표시 데이터 또는 전체 데이터 내보내기
|
||||
|
||||
```typescript
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// 사용 예시
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
|
||||
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
|
||||
```
|
||||
|
||||
### 8. 클립보드 복사 (Copy to Clipboard)
|
||||
|
||||
- 선택된 행 또는 전체 데이터 복사
|
||||
- 탭 구분자로 엑셀 붙여넣기 호환
|
||||
|
||||
### 9. 컨텍스트 메뉴 (Context Menu)
|
||||
|
||||
- 우클릭으로 메뉴 표시
|
||||
- 셀 편집, 행 복사, 행 삭제 등 옵션
|
||||
- 편집 불가 컬럼은 "(잠김)" 표시
|
||||
|
||||
### 10. 키보드 네비게이션
|
||||
|
||||
| 키 | 동작 |
|
||||
|---|---|
|
||||
| Arrow Keys | 셀 이동 |
|
||||
| Tab | 다음 셀 |
|
||||
| Shift+Tab | 이전 셀 |
|
||||
| F2 | 편집 모드 |
|
||||
| Enter | 저장 후 아래로 이동 |
|
||||
| Escape | 편집 취소 |
|
||||
| Ctrl+C | 복사 |
|
||||
| Delete | 셀 값 삭제 |
|
||||
|
||||
### 11. 컬럼 리사이징
|
||||
|
||||
- 컬럼 헤더 경계 드래그로 너비 조절
|
||||
- `columnWidths` 상태로 관리
|
||||
- localStorage에 저장
|
||||
|
||||
### 12. 컬럼 순서 변경
|
||||
|
||||
- 드래그 앤 드롭으로 컬럼 순서 변경
|
||||
- `columnOrder` 상태로 관리
|
||||
- localStorage에 저장
|
||||
|
||||
### 13. 상태 영속성 (State Persistence)
|
||||
|
||||
```typescript
|
||||
// localStorage 키 패턴
|
||||
const stateKey = `tableState_${tableName}_${userId}`;
|
||||
|
||||
// 저장되는 상태
|
||||
interface TableState {
|
||||
columnWidths: Record<string, number>;
|
||||
columnOrder: string[];
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
frozenColumns: string[];
|
||||
columnVisibility: Record<string, boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 14. 그룹화 및 그룹 소계
|
||||
|
||||
```typescript
|
||||
interface GroupedData {
|
||||
groupKey: string;
|
||||
groupValues: Record<string, any>;
|
||||
items: any[];
|
||||
count: number;
|
||||
summary?: Record<string, { sum: number; avg: number; count: number }>;
|
||||
}
|
||||
```
|
||||
|
||||
### 15. 총계 요약 (Total Summary)
|
||||
|
||||
- 숫자 컬럼의 합계, 평균, 개수 표시
|
||||
- 테이블 하단에 요약 행 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 캐싱 전략
|
||||
|
||||
```typescript
|
||||
// 테이블 컬럼 캐시
|
||||
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
|
||||
// API 호출 디바운싱
|
||||
const debouncedApiCall = <T extends any[], R>(
|
||||
key: string,
|
||||
fn: (...args: T) => Promise<R>,
|
||||
delay: number = 300
|
||||
) => { ... };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필수 Import
|
||||
|
||||
```typescript
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { TableListConfig, ColumnConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { codeCache } from "@/lib/caching/codeCache";
|
||||
import * as XLSX from "xlsx";
|
||||
import { toast } from "sonner";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 상태 (State)
|
||||
|
||||
```typescript
|
||||
// 데이터 관련
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 편집 관련
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
columnName: string;
|
||||
originalValue: any;
|
||||
} | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<string>("");
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
|
||||
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
|
||||
|
||||
// 필터 관련
|
||||
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||
const [globalSearchText, setGlobalSearchText] = useState("");
|
||||
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
|
||||
|
||||
// 컬럼 관련
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
|
||||
// 선택 관련
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||
|
||||
// 정렬 관련
|
||||
const [sortBy, setSortBy] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 편집 불가 컬럼 구현 체크리스트
|
||||
|
||||
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
|
||||
|
||||
- [ ] `column.editable === false` 체크 추가
|
||||
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
|
||||
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
|
||||
|
||||
```typescript
|
||||
// 표준 편집 불가 체크 패턴
|
||||
const column = visibleColumns.find((col) => col.columnName === columnName);
|
||||
if (column?.editable === false) {
|
||||
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 표시
|
||||
|
||||
### 편집 불가 컬럼 표시
|
||||
|
||||
```tsx
|
||||
// 헤더에 잠금 아이콘
|
||||
{column.editable === false && (
|
||||
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
// 셀 배경색
|
||||
className={cn(
|
||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
|
||||
2. **useCallback 사용**: 이벤트 핸들러 함수들
|
||||
3. **디바운싱**: API 호출, 검색, 필터링
|
||||
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
|
||||
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
|
||||
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
|
||||
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
|
||||
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
|
||||
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
|
||||
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블
|
||||
|
|
@ -1,592 +0,0 @@
|
|||
# 테이블 타입 관리 SQL 작성 가이드
|
||||
|
||||
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
|
||||
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
|
||||
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
|
||||
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
|
||||
|
||||
---
|
||||
|
||||
## 1. 테이블 생성 DDL 템플릿
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE "테이블명" (
|
||||
-- 시스템 기본 컬럼 (자동 포함)
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
|
||||
"컬럼1" varchar(500),
|
||||
"컬럼2" varchar(500),
|
||||
"컬럼3" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
### 예시: 고객 테이블 생성
|
||||
|
||||
```sql
|
||||
CREATE TABLE "customer_info" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
"customer_name" varchar(500),
|
||||
"customer_code" varchar(500),
|
||||
"phone" varchar(500),
|
||||
"email" varchar(500),
|
||||
"address" varchar(500),
|
||||
"status" varchar(500),
|
||||
"registration_date" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 메타데이터 테이블 등록
|
||||
|
||||
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
|
||||
|
||||
### 2.1 table_labels (테이블 메타데이터)
|
||||
|
||||
```sql
|
||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
|
||||
ON CONFLICT (table_name)
|
||||
DO UPDATE SET
|
||||
table_label = EXCLUDED.table_label,
|
||||
description = EXCLUDED.description,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### 2.2 table_type_columns (컬럼 타입 정보)
|
||||
|
||||
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, company_code, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
display_order = EXCLUDED.display_order,
|
||||
updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, company_code, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
|
||||
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
display_order = EXCLUDED.display_order,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### 2.3 column_labels (레거시 호환용 - 필수)
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼 등록
|
||||
INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
|
||||
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
column_label = EXCLUDED.column_label,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
description = EXCLUDED.description,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼 등록
|
||||
INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, created_date, updated_date
|
||||
) VALUES
|
||||
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
|
||||
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
column_label = EXCLUDED.column_label,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
description = EXCLUDED.description,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Type 정의
|
||||
|
||||
### 지원되는 Input Type 목록
|
||||
|
||||
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
|
||||
| ---------- | ------------- | ------------ | -------------------- |
|
||||
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
|
||||
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
|
||||
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
|
||||
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
|
||||
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
|
||||
| `select` | 선택 목록 | VARCHAR(500) | Select |
|
||||
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
|
||||
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
|
||||
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
|
||||
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
|
||||
|
||||
### WebType → InputType 변환 규칙
|
||||
|
||||
```
|
||||
text, textarea, email, tel, url, password → text
|
||||
number, decimal → number
|
||||
date, datetime, time → date
|
||||
select, dropdown → select
|
||||
checkbox, boolean → checkbox
|
||||
radio → radio
|
||||
code → code
|
||||
entity → entity
|
||||
file → text
|
||||
button → text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Detail Settings 설정
|
||||
|
||||
### 4.1 Code 타입 (공통코드 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"codeCategory": "코드_카테고리_ID"
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
|
||||
```
|
||||
|
||||
### 4.2 Entity 타입 (테이블 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"referenceTable": "참조_테이블명",
|
||||
"referenceColumn": "참조_컬럼명(보통 id)",
|
||||
"displayColumn": "표시할_컬럼명"
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
||||
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
|
||||
```
|
||||
|
||||
### 4.3 Select 타입 (정적 옵션)
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "label": "옵션1", "value": "value1" },
|
||||
{ "label": "옵션2", "value": "value2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 전체 예시: 주문 테이블 생성
|
||||
|
||||
### Step 1: DDL 실행
|
||||
|
||||
```sql
|
||||
CREATE TABLE "order_info" (
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500) DEFAULT NULL,
|
||||
"company_code" varchar(500),
|
||||
|
||||
"order_no" varchar(500),
|
||||
"order_date" varchar(500),
|
||||
"customer_id" varchar(500),
|
||||
"total_amount" varchar(500),
|
||||
"status" varchar(500),
|
||||
"notes" varchar(500)
|
||||
);
|
||||
```
|
||||
|
||||
### Step 2: table_labels 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
|
||||
ON CONFLICT (table_name)
|
||||
DO UPDATE SET
|
||||
table_label = EXCLUDED.table_label,
|
||||
description = EXCLUDED.description,
|
||||
updated_date = now();
|
||||
```
|
||||
|
||||
### Step 3: table_type_columns 등록
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
||||
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
||||
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
||||
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
||||
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
|
||||
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
|
||||
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
|
||||
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
|
||||
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
|
||||
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
```
|
||||
|
||||
### Step 4: column_labels 등록 (레거시 호환)
|
||||
|
||||
```sql
|
||||
-- 기본 컬럼
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
|
||||
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
||||
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
||||
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
||||
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||
|
||||
-- 사용자 정의 컬럼
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES
|
||||
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
|
||||
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
|
||||
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
|
||||
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
|
||||
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
|
||||
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 컬럼 추가 시
|
||||
|
||||
### DDL
|
||||
|
||||
```sql
|
||||
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
|
||||
```
|
||||
|
||||
### 메타데이터 등록
|
||||
|
||||
```sql
|
||||
-- table_type_columns
|
||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
||||
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
||||
|
||||
-- column_labels
|
||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
||||
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 로그 테이블 생성 (선택사항)
|
||||
|
||||
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
|
||||
|
||||
### 7.1 로그 테이블 DDL 템플릿
|
||||
|
||||
```sql
|
||||
-- 로그 테이블 생성
|
||||
CREATE TABLE 테이블명_log (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
|
||||
original_id VARCHAR(100), -- 원본 테이블 PK 값
|
||||
changed_column VARCHAR(100), -- 변경된 컬럼명
|
||||
old_value TEXT, -- 변경 전 값
|
||||
new_value TEXT, -- 변경 후 값
|
||||
changed_by VARCHAR(50), -- 변경자 ID
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
|
||||
ip_address VARCHAR(50), -- 변경 요청 IP
|
||||
user_agent TEXT, -- User Agent
|
||||
full_row_before JSONB, -- 변경 전 전체 행
|
||||
full_row_after JSONB -- 변경 후 전체 행
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
|
||||
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
|
||||
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
|
||||
```
|
||||
|
||||
### 7.2 트리거 함수 DDL 템플릿
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_column_name TEXT;
|
||||
v_old_value TEXT;
|
||||
v_new_value TEXT;
|
||||
v_user_id VARCHAR(50);
|
||||
v_ip_address VARCHAR(50);
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.user_id', TRUE);
|
||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
FOR v_column_name IN
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
AND table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||
INTO v_old_value, v_new_value
|
||||
USING OLD, NEW;
|
||||
|
||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||
INSERT INTO 테이블명_log (
|
||||
operation_type, original_id, changed_column, old_value, new_value,
|
||||
changed_by, ip_address, full_row_before, full_row_after
|
||||
)
|
||||
VALUES (
|
||||
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
|
||||
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### 7.3 트리거 DDL 템플릿
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER 테이블명_audit_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
|
||||
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
|
||||
```
|
||||
|
||||
### 7.4 로그 설정 등록
|
||||
|
||||
```sql
|
||||
INSERT INTO table_log_config (
|
||||
original_table_name, log_table_name, trigger_name,
|
||||
trigger_function_name, is_active, created_by, created_at
|
||||
) VALUES (
|
||||
'테이블명', '테이블명_log', '테이블명_audit_trigger',
|
||||
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.5 table_labels에 use_log_table 플래그 설정
|
||||
|
||||
```sql
|
||||
UPDATE table_labels
|
||||
SET use_log_table = 'Y', updated_date = now()
|
||||
WHERE table_name = '테이블명';
|
||||
```
|
||||
|
||||
### 7.6 전체 예시: order_info 로그 테이블 생성
|
||||
|
||||
```sql
|
||||
-- Step 1: 로그 테이블 생성
|
||||
CREATE TABLE order_info_log (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(10) NOT NULL,
|
||||
original_id VARCHAR(100),
|
||||
changed_column VARCHAR(100),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by VARCHAR(50),
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
full_row_before JSONB,
|
||||
full_row_after JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
|
||||
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
|
||||
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
|
||||
|
||||
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
|
||||
|
||||
-- Step 2: 트리거 함수 생성
|
||||
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_column_name TEXT;
|
||||
v_old_value TEXT;
|
||||
v_new_value TEXT;
|
||||
v_user_id VARCHAR(50);
|
||||
v_ip_address VARCHAR(50);
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.user_id', TRUE);
|
||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
FOR v_column_name IN
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'order_info' AND table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||
INTO v_old_value, v_new_value USING OLD, NEW;
|
||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
||||
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Step 3: 트리거 생성
|
||||
CREATE TRIGGER order_info_audit_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON order_info
|
||||
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
|
||||
|
||||
-- Step 4: 로그 설정 등록
|
||||
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
|
||||
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
|
||||
|
||||
-- Step 5: table_labels 플래그 업데이트
|
||||
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
|
||||
```
|
||||
|
||||
### 7.7 로그 테이블 삭제
|
||||
|
||||
```sql
|
||||
-- 트리거 삭제
|
||||
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
|
||||
|
||||
-- 트리거 함수 삭제
|
||||
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
|
||||
|
||||
-- 로그 테이블 삭제
|
||||
DROP TABLE IF EXISTS 테이블명_log;
|
||||
|
||||
-- 로그 설정 삭제
|
||||
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
|
||||
|
||||
-- table_labels 플래그 업데이트
|
||||
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
### 테이블 생성/수정 시 반드시 확인할 사항:
|
||||
|
||||
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
|
||||
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
|
||||
- [ ] `table_labels`에 테이블 메타데이터 등록
|
||||
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
|
||||
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
|
||||
- [ ] 기본 컬럼 display_order: -5 ~ -1
|
||||
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
|
||||
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
|
||||
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
|
||||
|
||||
### 로그 테이블 생성 시 확인할 사항 (선택):
|
||||
|
||||
- [ ] 로그 테이블 생성 (`테이블명_log`)
|
||||
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
|
||||
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
|
||||
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
|
||||
- [ ] `table_log_config`에 로그 설정 등록
|
||||
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 9. 금지 사항
|
||||
|
||||
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
|
||||
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
|
||||
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
|
||||
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
|
||||
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
|
||||
|
||||
---
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
|
||||
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
|
||||
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
|
||||
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
|
||||
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
# 고정 헤더 테이블 표준 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
|
||||
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
|
||||
|
||||
## 필수 구조
|
||||
|
||||
### 1. 기본 HTML 구조
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 1
|
||||
</TableHead>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 2
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{/* 데이터 행들 */}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. 필수 클래스 설명
|
||||
|
||||
#### 스크롤 컨테이너 (외부 div)
|
||||
|
||||
```tsx
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
```
|
||||
|
||||
**필수 요소:**
|
||||
|
||||
- `relative`: sticky positioning의 기준점
|
||||
- `overflow-auto`: 스크롤 활성화
|
||||
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
|
||||
|
||||
#### Table 컴포넌트
|
||||
|
||||
```tsx
|
||||
<Table noWrapper>
|
||||
```
|
||||
|
||||
**필수 props:**
|
||||
|
||||
- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!)
|
||||
- 이것이 없으면 sticky header가 작동하지 않음
|
||||
|
||||
#### TableHead (헤더 셀)
|
||||
|
||||
```tsx
|
||||
className =
|
||||
"bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
**필수 클래스:**
|
||||
|
||||
- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록)
|
||||
- `sticky top-0`: 상단 고정
|
||||
- `z-10`: 다른 요소 위에 표시
|
||||
- `border-b`: 하단 테두리
|
||||
- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분)
|
||||
|
||||
### 3. 왼쪽 열 고정 (체크박스 등)
|
||||
|
||||
첫 번째 열도 고정하려면:
|
||||
|
||||
```tsx
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox />
|
||||
</TableHead>
|
||||
```
|
||||
|
||||
**z-index 규칙:**
|
||||
|
||||
- 왼쪽+상단 고정: `z-20`
|
||||
- 상단만 고정: `z-10`
|
||||
- 왼쪽만 고정: `z-10`
|
||||
- 일반 셀: z-index 없음
|
||||
|
||||
### 4. 완전한 예제 (체크박스 포함)
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 왼쪽 고정 체크박스 열 */}
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
|
||||
{/* 일반 헤더 열들 */}
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col}
|
||||
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{/* 왼쪽 고정 체크박스 */}
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRow(index)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 데이터 셀들 */}
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col} className="border-b px-3 py-2">
|
||||
{row[col]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 반응형 대응
|
||||
|
||||
### 모바일: 카드 뷰
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모바일: 카드 뷰 */
|
||||
}
|
||||
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
|
||||
<div className="space-y-2 p-3">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="bg-card rounded-md border p-3">
|
||||
{/* 카드 내용 */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 데스크톱: 테이블 뷰 */
|
||||
}
|
||||
<div
|
||||
className="relative hidden overflow-auto sm:block"
|
||||
style={{ height: "450px" }}
|
||||
>
|
||||
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 자주하는 실수
|
||||
|
||||
### ❌ 잘못된 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 1. noWrapper 없음 - sticky 작동 안함 */
|
||||
}
|
||||
<Table>
|
||||
<TableHeader>...</TableHeader>
|
||||
</Table>;
|
||||
|
||||
{
|
||||
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
|
||||
}
|
||||
<TableHead className="sticky top-0">헤더</TableHead>;
|
||||
|
||||
{
|
||||
/* 3. relative 없음 - sticky 기준점 없음 */
|
||||
}
|
||||
<div className="overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
|
||||
}
|
||||
<div className="relative overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
### ✅ 올바른 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모든 필수 요소 포함 */
|
||||
}
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>...</TableBody>
|
||||
</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 높이 설정 가이드
|
||||
|
||||
### 권장 높이값
|
||||
|
||||
- **소형 리스트**: `300px` ~ `400px`
|
||||
- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준)
|
||||
- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준)
|
||||
|
||||
### 동적 높이 계산
|
||||
|
||||
```tsx
|
||||
// 화면 높이의 60%
|
||||
style={{ height: "60vh" }}
|
||||
|
||||
// 화면 높이 - 헤더/푸터 제외
|
||||
style={{ height: "calc(100vh - 250px)" }}
|
||||
|
||||
// 부모 요소 기준
|
||||
className="h-full overflow-auto"
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 1. 가상 스크롤 (대량 데이터)
|
||||
|
||||
데이터가 1000건 이상인 경우 `react-virtual` 사용 권장:
|
||||
|
||||
```tsx
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 50, // 행 높이
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 페이지네이션
|
||||
|
||||
대량 데이터는 페이지 단위로 렌더링:
|
||||
|
||||
```tsx
|
||||
const paginatedData = data.slice((page - 1) * pageSize, page * pageSize);
|
||||
```
|
||||
|
||||
## 접근성
|
||||
|
||||
### ARIA 레이블
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
role="region"
|
||||
aria-label="스크롤 가능한 데이터 테이블"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Table noWrapper aria-label="데이터 목록">
|
||||
{/* 테이블 내용 */}
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 키보드 네비게이션
|
||||
|
||||
```tsx
|
||||
<TableRow
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 행 내용 */}
|
||||
</TableRow>
|
||||
```
|
||||
|
||||
## 다크 모드 대응
|
||||
|
||||
### 배경색
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 라이트/다크 모드 모두 대응 */
|
||||
}
|
||||
className = "bg-background"; // ✅ 권장
|
||||
|
||||
{
|
||||
/* 고정 색상 - 다크 모드 문제 */
|
||||
}
|
||||
className = "bg-white"; // ❌ 비권장
|
||||
```
|
||||
|
||||
### 그림자
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 다크 모드에서도 보이는 그림자 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_hsl(var(--border))]";
|
||||
|
||||
{
|
||||
/* 또는 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820)
|
||||
- **Table 컴포넌트**: `frontend/components/ui/table.tsx`
|
||||
|
||||
## 체크리스트
|
||||
|
||||
테이블 구현 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 외부 div에 `relative overflow-auto` 적용
|
||||
- [ ] 외부 div에 고정 높이 설정
|
||||
- [ ] `<Table noWrapper>` 사용
|
||||
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
|
||||
- [ ] TableHead에 `border-b shadow-[...]` 적용
|
||||
- [ ] 왼쪽 고정 열은 `z-20` 사용
|
||||
- [ ] 모바일 반응형 대응 (카드 뷰)
|
||||
- [ ] 다크 모드 호환 색상 사용
|
||||
1469
.cursorrules
1469
.cursorrules
File diff suppressed because it is too large
Load Diff
|
|
@ -150,6 +150,9 @@ jspm_packages/
|
|||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
|
@ -270,6 +273,8 @@ out/
|
|||
.settings/
|
||||
bin/
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# 업로드된 파일들 제외
|
||||
backend-node/uploads/
|
||||
uploads/
|
||||
|
|
@ -285,5 +290,3 @@ uploads/
|
|||
*.pptx
|
||||
*.hwp
|
||||
*.hwpx
|
||||
|
||||
claude.md
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -0,0 +1,312 @@
|
|||
# 카드 컴포넌트 기능 확장 계획
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
테이블 리스트 컴포넌트의 고급 기능들(Entity 조인, 필터, 검색, 페이지네이션)을 카드 컴포넌트에도 적용하여 일관된 사용자 경험을 제공합니다.
|
||||
|
||||
## 🔍 현재 상태 분석
|
||||
|
||||
### ✅ 기존 기능
|
||||
|
||||
- 테이블 데이터를 카드 형태로 표시
|
||||
- 기본적인 컬럼 매핑 (제목, 부제목, 설명, 이미지)
|
||||
- 카드 레이아웃 설정 (행당 카드 수, 간격)
|
||||
- 설정 패널 존재
|
||||
|
||||
### ❌ 부족한 기능
|
||||
|
||||
- Entity 조인 기능
|
||||
- 필터 및 검색 기능
|
||||
- 페이지네이션
|
||||
- 코드 변환 기능
|
||||
- 정렬 기능
|
||||
|
||||
## 🎯 개발 단계
|
||||
|
||||
### Phase 1: 타입 및 인터페이스 확장 ⚡
|
||||
|
||||
#### 1.1 새로운 타입 정의 추가
|
||||
|
||||
```typescript
|
||||
// CardDisplayConfig 확장
|
||||
interface CardFilterConfig {
|
||||
enabled: boolean;
|
||||
quickSearch: boolean;
|
||||
showColumnSelector?: boolean;
|
||||
advancedFilter: boolean;
|
||||
filterableColumns: string[];
|
||||
}
|
||||
|
||||
interface CardPaginationConfig {
|
||||
enabled: boolean;
|
||||
pageSize: number;
|
||||
showSizeSelector: boolean;
|
||||
showPageInfo: boolean;
|
||||
pageSizeOptions: number[];
|
||||
}
|
||||
|
||||
interface CardSortConfig {
|
||||
enabled: boolean;
|
||||
defaultSort?: {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
sortableColumns: string[];
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 CardDisplayConfig 확장
|
||||
|
||||
- filter, pagination, sort 설정 추가
|
||||
- Entity 조인 관련 설정 추가
|
||||
- 코드 변환 관련 설정 추가
|
||||
|
||||
### Phase 2: 핵심 기능 구현 🚀
|
||||
|
||||
#### 2.1 Entity 조인 기능
|
||||
|
||||
- `useEntityJoinOptimization` 훅 적용
|
||||
- 조인된 컬럼 데이터 매핑
|
||||
- 코드 변환 기능 (`optimizedConvertCode`)
|
||||
- 컬럼 메타정보 관리
|
||||
|
||||
#### 2.2 데이터 관리 로직
|
||||
|
||||
- 검색/필터/정렬이 적용된 데이터 로딩
|
||||
- 페이지네이션 처리
|
||||
- 실시간 검색 기능
|
||||
- 캐시 최적화
|
||||
|
||||
#### 2.3 상태 관리
|
||||
|
||||
```typescript
|
||||
// 새로운 상태 추가
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedSearchColumn, setSelectedSearchColumn] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
```
|
||||
|
||||
### Phase 3: UI 컴포넌트 구현 🎨
|
||||
|
||||
#### 3.1 헤더 영역
|
||||
|
||||
```jsx
|
||||
<div className="card-header">
|
||||
<h3>{tableConfig.title || tableLabel}</h3>
|
||||
<div className="search-controls">
|
||||
{/* 검색바 */}
|
||||
<Input placeholder="검색..." />
|
||||
{/* 검색 컬럼 선택기 */}
|
||||
<select>...</select>
|
||||
{/* 새로고침 버튼 */}
|
||||
<Button>↻</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.2 카드 그리드 영역
|
||||
|
||||
```jsx
|
||||
<div
|
||||
className="card-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cardsPerRow}, 1fr)`,
|
||||
gap: `${cardSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{displayData.map((item, index) => (
|
||||
<Card key={index}>{/* 카드 내용 렌더링 */}</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.3 페이지네이션 영역
|
||||
|
||||
```jsx
|
||||
<div className="card-pagination">
|
||||
<div>
|
||||
전체 {totalItems}건 중 {startItem}-{endItem} 표시
|
||||
</div>
|
||||
<div>
|
||||
<select>페이지 크기</select>
|
||||
<Button>◀◀</Button>
|
||||
<Button>◀</Button>
|
||||
<span>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button>▶</Button>
|
||||
<Button>▶▶</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Phase 4: 설정 패널 확장 ⚙️
|
||||
|
||||
#### 4.1 새 탭 추가
|
||||
|
||||
- **필터 탭**: 검색 및 필터 설정
|
||||
- **페이지네이션 탭**: 페이지 관련 설정
|
||||
- **정렬 탭**: 정렬 기본값 설정
|
||||
|
||||
#### 4.2 설정 옵션
|
||||
|
||||
```jsx
|
||||
// 필터 탭
|
||||
<TabsContent value="filter">
|
||||
<Checkbox>필터 기능 사용</Checkbox>
|
||||
<Checkbox>빠른 검색</Checkbox>
|
||||
<Checkbox>검색 컬럼 선택기 표시</Checkbox>
|
||||
<Checkbox>고급 필터</Checkbox>
|
||||
</TabsContent>
|
||||
|
||||
// 페이지네이션 탭
|
||||
<TabsContent value="pagination">
|
||||
<Checkbox>페이지네이션 사용</Checkbox>
|
||||
<Input label="페이지 크기" />
|
||||
<Checkbox>페이지 크기 선택기 표시</Checkbox>
|
||||
<Checkbox>페이지 정보 표시</Checkbox>
|
||||
</TabsContent>
|
||||
```
|
||||
|
||||
## 🛠️ 구현 우선순위
|
||||
|
||||
### 🟢 High Priority (1-2주)
|
||||
|
||||
1. **Entity 조인 기능**: 테이블 리스트의 로직 재사용
|
||||
2. **기본 검색 기능**: 검색바 및 실시간 검색
|
||||
3. **페이지네이션**: 카드 개수 제한 및 페이지 이동
|
||||
|
||||
### 🟡 Medium Priority (2-3주)
|
||||
|
||||
4. **고급 필터**: 컬럼별 필터 옵션
|
||||
5. **정렬 기능**: 컬럼별 정렬 및 상태 표시
|
||||
6. **검색 컬럼 선택기**: 특정 컬럼 검색 기능
|
||||
|
||||
### 🔵 Low Priority (3-4주)
|
||||
|
||||
7. **카드 뷰 옵션**: 그리드/리스트 전환
|
||||
8. **카드 크기 조절**: 동적 크기 조정
|
||||
9. **즐겨찾기 필터**: 자주 사용하는 필터 저장
|
||||
|
||||
## 📝 기술적 고려사항
|
||||
|
||||
### 재사용 가능한 코드
|
||||
|
||||
- `useEntityJoinOptimization` 훅
|
||||
- 필터 및 검색 로직
|
||||
- 페이지네이션 컴포넌트
|
||||
- 코드 캐시 시스템
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- 가상화 스크롤 (대량 데이터)
|
||||
- 이미지 지연 로딩
|
||||
- 메모리 효율적인 렌더링
|
||||
- 디바운스된 검색
|
||||
|
||||
### 일관성 유지
|
||||
|
||||
- 테이블 리스트와 동일한 API
|
||||
- 동일한 설정 구조
|
||||
- 일관된 스타일링
|
||||
- 동일한 이벤트 핸들링
|
||||
|
||||
## 🗂️ 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/card-display/
|
||||
├── CardDisplayComponent.tsx # 메인 컴포넌트 (수정)
|
||||
├── CardDisplayConfigPanel.tsx # 설정 패널 (수정)
|
||||
├── types.ts # 타입 정의 (수정)
|
||||
├── index.ts # 기본 설정 (수정)
|
||||
├── hooks/
|
||||
│ └── useCardDataManagement.ts # 데이터 관리 훅 (신규)
|
||||
├── components/
|
||||
│ ├── CardHeader.tsx # 헤더 컴포넌트 (신규)
|
||||
│ ├── CardGrid.tsx # 그리드 컴포넌트 (신규)
|
||||
│ ├── CardPagination.tsx # 페이지네이션 (신규)
|
||||
│ └── CardFilter.tsx # 필터 컴포넌트 (신규)
|
||||
└── utils/
|
||||
└── cardHelpers.ts # 유틸리티 함수 (신규)
|
||||
```
|
||||
|
||||
## ✅ 완료된 단계
|
||||
|
||||
### Phase 1: 타입 및 인터페이스 확장 ✅
|
||||
|
||||
- ✅ `CardFilterConfig`, `CardPaginationConfig`, `CardSortConfig` 타입 정의
|
||||
- ✅ `CardColumnConfig` 인터페이스 추가 (Entity 조인 지원)
|
||||
- ✅ `CardDisplayConfig` 확장 (새로운 기능들 포함)
|
||||
- ✅ 기본 설정 업데이트 (filter, pagination, sort 기본값)
|
||||
|
||||
### Phase 2: Entity 조인 기능 구현 ✅
|
||||
|
||||
- ✅ `useEntityJoinOptimization` 훅 적용
|
||||
- ✅ 컬럼 메타정보 관리 (`columnMeta` 상태)
|
||||
- ✅ 코드 변환 기능 (`optimizedConvertCode`)
|
||||
- ✅ Entity 조인을 고려한 데이터 로딩 로직
|
||||
|
||||
### Phase 3: 새로운 UI 구조 구현 ✅
|
||||
|
||||
- ✅ 헤더 영역 (제목, 검색바, 컬럼 선택기, 새로고침)
|
||||
- ✅ 카드 그리드 영역 (반응형 그리드, 로딩/오류 상태)
|
||||
- ✅ 개별 카드 렌더링 (제목, 부제목, 설명, 추가 필드)
|
||||
- ✅ 푸터/페이지네이션 영역 (페이지 정보, 크기 선택, 네비게이션)
|
||||
- ✅ 검색 기능 (디바운스, 컬럼 선택)
|
||||
- ✅ 코드 값 포맷팅 (`formatCellValue`)
|
||||
|
||||
### Phase 4: 설정 패널 확장 ✅
|
||||
|
||||
- ✅ **탭 기반 UI 구조** - 5개 탭으로 체계적 분류
|
||||
- ✅ **일반 탭** - 기본 설정, 카드 레이아웃, 스타일 옵션
|
||||
- ✅ **매핑 탭** - 컬럼 매핑, 동적 표시 컬럼 관리
|
||||
- ✅ **필터 탭** - 검색 및 필터 설정 옵션
|
||||
- ✅ **페이징 탭** - 페이지 관련 설정 및 크기 옵션
|
||||
- ✅ **정렬 탭** - 정렬 기본값 설정
|
||||
- ✅ **Shadcn/ui 컴포넌트 적용** - 일관된 UI/UX
|
||||
|
||||
## 🎉 프로젝트 완료!
|
||||
|
||||
### 📊 최종 달성 결과
|
||||
|
||||
**🚀 100% 완료** - 모든 계획된 기능이 성공적으로 구현되었습니다!
|
||||
|
||||
#### ✅ 구현된 주요 기능들
|
||||
|
||||
1. **완전한 데이터 관리**: 테이블 리스트와 동일한 수준의 데이터 로딩, 검색, 필터링, 페이지네이션
|
||||
2. **Entity 조인 지원**: 관계형 데이터 조인 및 코드 변환 자동화
|
||||
3. **고급 검색**: 실시간 검색, 컬럼별 검색, 자동 컬럼 선택
|
||||
4. **완전한 설정 UI**: 5개 탭으로 분류된 직관적인 설정 패널
|
||||
5. **반응형 카드 그리드**: 설정 가능한 레이아웃과 스타일
|
||||
|
||||
#### 🎯 성능 및 사용성
|
||||
|
||||
- **성능 최적화**: 디바운스 검색, 배치 코드 로딩, 캐시 활용
|
||||
- **사용자 경험**: 로딩 상태, 오류 처리, 직관적인 UI
|
||||
- **일관성**: 테이블 리스트와 완전히 동일한 API 및 기능
|
||||
|
||||
#### 📁 완성된 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/card-display/
|
||||
├── CardDisplayComponent.tsx ✅ 완전 재구현 (Entity 조인, 검색, 페이징)
|
||||
├── CardDisplayConfigPanel.tsx ✅ 5개 탭 기반 설정 패널
|
||||
├── types.ts ✅ 확장된 타입 시스템
|
||||
└── index.ts ✅ 업데이트된 기본 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🏆 최종 상태**: **완료** (100%)
|
||||
**🎯 목표 달성**: 테이블 리스트와 동일한 수준의 강력한 카드 컴포넌트 완성
|
||||
**⚡ 개발 기간**: 계획 대비 빠른 완료 (예상 3-4주 → 실제 1일)
|
||||
**✨ 품질**: 테이블 리스트 대비 동등하거나 우수한 기능 수준
|
||||
|
||||
### 🔥 주요 성과
|
||||
|
||||
이제 사용자들은 **테이블 리스트**와 **카드 디스플레이** 중에서 자유롭게 선택하여 동일한 데이터를 서로 다른 형태로 시각화할 수 있습니다. 두 컴포넌트 모두 완전히 동일한 고급 기능을 제공합니다!
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
# vexplor 프로젝트 NCP Kubernetes 배포 가이드
|
||||
|
||||
## 배포 환경
|
||||
- **Kubernetes 클러스터**: NCP Kubernetes
|
||||
- **네임스페이스**: apps
|
||||
- **GitOps 도구**: Argo CD (https://argocd.kpslp.kr)
|
||||
- **CI/CD**: Jenkins (Kaniko 빌드)
|
||||
- **컨테이너 레지스트리**: registry.kpslp.kr
|
||||
|
||||
## 전제 조건
|
||||
|
||||
### 1. GitLab 레포지토리
|
||||
- [x] 프로젝트 코드 레포: 이미 생성됨 (현재 레포)
|
||||
- [ ] Helm Charts 레포: `https://gitlab.kpslp.kr/root/helm-charts` 접근 권한 필요
|
||||
|
||||
### 2. 필요한 권한
|
||||
- [ ] GitLab 계정 및 레포지토리 접근 권한
|
||||
- [ ] Jenkins 프로젝트 생성 권한 또는 담당자 요청
|
||||
- [ ] Argo CD 접속 계정
|
||||
- [ ] Container Registry 푸시 권한
|
||||
|
||||
---
|
||||
|
||||
## 배포 단계
|
||||
|
||||
### Step 1: Helm Charts 레포지토리 설정
|
||||
|
||||
김욱동 책임님께 다음 사항을 요청하세요:
|
||||
|
||||
```
|
||||
안녕하세요.
|
||||
|
||||
vexplor 프로젝트 배포를 위해 다음 작업이 필요합니다:
|
||||
|
||||
1. helm-charts 레포지토리 접근 권한 부여
|
||||
- 레포지토리: https://gitlab.kpslp.kr/root/helm-charts
|
||||
- 현재 404 오류로 접근 불가
|
||||
- 계정: [본인 GitLab 사용자명]
|
||||
|
||||
2. values 파일 업로드
|
||||
- 첨부된 values_vexplor.yaml 파일을
|
||||
- kpslp/values_vexplor.yaml 경로에 업로드해주시거나
|
||||
- 업로드 방법을 안내해주세요
|
||||
|
||||
3. Jenkins 프로젝트 생성
|
||||
- 프로젝트명: vexplor
|
||||
- Git 레포지토리: [현재 프로젝트 GitLab URL]
|
||||
- Jenkinsfile: 프로젝트 루트에 이미 준비됨
|
||||
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
**첨부 파일**: `values_vexplor.yaml` (프로젝트 루트에 생성됨)
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Jenkins 프로젝트 등록
|
||||
|
||||
Jenkins에서 새 파이프라인 프로젝트를 생성합니다:
|
||||
|
||||
1. **Jenkins 접속** (URL은 담당자에게 문의)
|
||||
2. **New Item** 클릭
|
||||
3. **프로젝트명**: `vexplor`
|
||||
4. **Pipeline** 선택
|
||||
5. **Pipeline 설정**:
|
||||
- Definition: `Pipeline script from SCM`
|
||||
- SCM: `Git`
|
||||
- Repository URL: `[현재 프로젝트 GitLab URL]`
|
||||
- Credentials: `gitlab_userpass_root` (또는 담당자가 안내한 credential)
|
||||
- Branch: `*/main`
|
||||
- Script Path: `Jenkinsfile`
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Argo CD 애플리케이션 등록
|
||||
|
||||
1. **Argo CD 접속**: https://argocd.kpslp.kr
|
||||
|
||||
2. **New App 생성**:
|
||||
- **Application Name**: `vexplor`
|
||||
- **Project**: `default`
|
||||
- **Sync Policy**: `Automatic` (자동 배포) 또는 `Manual` (수동 배포)
|
||||
- **Auto-Create Namespace**: ✓ (체크)
|
||||
|
||||
3. **Source 설정**:
|
||||
- **Repository URL**: `https://gitlab.kpslp.kr/root/helm-charts`
|
||||
- **Revision**: `HEAD` 또는 `main`
|
||||
- **Path**: `kpslp`
|
||||
- **Helm Values**: `values_vexplor.yaml`
|
||||
|
||||
4. **Destination 설정**:
|
||||
- **Cluster URL**: `https://kubernetes.default.svc` (기본값)
|
||||
- **Namespace**: `apps`
|
||||
|
||||
5. **Create** 클릭
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 첫 배포 실행
|
||||
|
||||
#### 4-1. Git Push로 Jenkins 빌드 트리거
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: NCP Kubernetes 배포 설정 완료"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
#### 4-2. Jenkins 빌드 모니터링
|
||||
1. Jenkins에서 `vexplor` 프로젝트 열기
|
||||
2. 빌드 시작 확인 (자동 트리거 또는 수동 빌드)
|
||||
3. 로그 확인:
|
||||
- **Checkout**: Git 소스 다운로드
|
||||
- **Build**: Docker 이미지 빌드 (`registry.kpslp.kr/slp/vexplor:xxxxx`)
|
||||
- **Update Image Tag**: helm-charts 레포의 values 파일 업데이트
|
||||
|
||||
#### 4-3. Argo CD 배포 확인
|
||||
1. Argo CD 대시보드에서 `vexplor` 앱 열기
|
||||
2. **Sync Status**: `OutOfSync` → `Synced` 변경 확인
|
||||
3. **Health Status**: `Progressing` → `Healthy` 변경 확인
|
||||
4. Pod 상태 확인 (Running 상태여야 함)
|
||||
|
||||
---
|
||||
|
||||
## 배포 후 확인사항
|
||||
|
||||
### 1. Pod 상태 확인
|
||||
```bash
|
||||
kubectl get pods -n apps | grep vexplor
|
||||
```
|
||||
**예상 출력**:
|
||||
```
|
||||
vexplor-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
|
||||
```
|
||||
|
||||
### 2. 서비스 확인
|
||||
```bash
|
||||
kubectl get svc -n apps | grep vexplor
|
||||
```
|
||||
|
||||
### 3. Ingress 확인
|
||||
```bash
|
||||
kubectl get ingress -n apps | grep vexplor
|
||||
```
|
||||
|
||||
### 4. 로그 확인
|
||||
```bash
|
||||
# 전체 로그
|
||||
kubectl logs -n apps -l app=vexplor
|
||||
|
||||
# 최근 50줄
|
||||
kubectl logs -n apps -l app=vexplor --tail=50
|
||||
|
||||
# 실시간 로그 (스트리밍)
|
||||
kubectl logs -n apps -l app=vexplor -f
|
||||
```
|
||||
|
||||
### 5. 애플리케이션 접속
|
||||
- **URL**: `https://vexplor.kpslp.kr` (values 파일에 설정한 도메인)
|
||||
- **헬스체크**: `https://vexplor.kpslp.kr/api/health`
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 문제 1: Jenkins 빌드 실패
|
||||
**증상**: Build 단계에서 에러 발생
|
||||
|
||||
**확인사항**:
|
||||
- Docker 이미지 빌드 로그 확인
|
||||
- `Dockerfile`이 프로젝트 루트에 있는지 확인
|
||||
- 빌드 컨텍스트에 필요한 파일들이 있는지 확인
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 로컬에서 Docker 빌드 테스트
|
||||
docker build -f Dockerfile -t vexplor:test .
|
||||
```
|
||||
|
||||
### 문제 2: helm-charts 레포 푸시 실패
|
||||
**증상**: Update Image Tag 단계에서 실패
|
||||
|
||||
**원인**: `gitlab_userpass_root` credential 문제 또는 권한 부족
|
||||
|
||||
**해결**: 김욱동 책임님께 credential 확인 요청
|
||||
|
||||
### 문제 3: Argo CD Sync 실패
|
||||
**증상**: `OutOfSync` 상태에서 변경 없음
|
||||
|
||||
**확인사항**:
|
||||
- values 파일이 올바른 경로에 있는지 (`kpslp/values_vexplor.yaml`)
|
||||
- Argo CD가 helm-charts 레포를 읽을 수 있는지
|
||||
|
||||
**해결**: Argo CD에서 수동 Sync 시도 또는 담당자에게 문의
|
||||
|
||||
### 문제 4: Pod가 CrashLoopBackOff 상태
|
||||
**증상**: Pod가 계속 재시작됨
|
||||
|
||||
**확인**:
|
||||
```bash
|
||||
kubectl describe pod -n apps [pod-name]
|
||||
kubectl logs -n apps [pod-name] --previous
|
||||
```
|
||||
|
||||
**일반적인 원인**:
|
||||
- 환경 변수 누락 (DATABASE_HOST 등)
|
||||
- 데이터베이스 연결 실패
|
||||
- 포트 바인딩 문제
|
||||
|
||||
**해결**:
|
||||
1. `values_vexplor.yaml`의 `env` 섹션 확인
|
||||
2. 데이터베이스 서비스명 확인 (`postgres-service.apps.svc.cluster.local`)
|
||||
3. Secret 설정 확인 (DB 비밀번호 등)
|
||||
|
||||
---
|
||||
|
||||
## 업데이트 배포 프로세스
|
||||
|
||||
코드 수정 후 배포 절차:
|
||||
|
||||
```bash
|
||||
# 1. 코드 수정
|
||||
git add .
|
||||
git commit -m "feat: 새로운 기능 추가"
|
||||
git push origin main
|
||||
|
||||
# 2. Jenkins 자동 빌드 (자동 트리거)
|
||||
# - Git push 감지
|
||||
# - Docker 이미지 빌드
|
||||
# - 새 이미지 태그로 values 파일 업데이트
|
||||
|
||||
# 3. Argo CD 자동 배포 (Sync Policy가 Automatic인 경우)
|
||||
# - helm-charts 레포 변경 감지
|
||||
# - Kubernetes에 새 이미지 배포
|
||||
# - Rolling Update 수행
|
||||
```
|
||||
|
||||
**수동 배포**: Argo CD 대시보드에서 `Sync` 버튼 클릭
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
배포 전 확인사항:
|
||||
|
||||
- [ ] Jenkinsfile 수정 완료 (단일 이미지 빌드)
|
||||
- [ ] Dockerfile 확인 (멀티스테이지 빌드)
|
||||
- [ ] values_vexplor.yaml 작성 및 업로드
|
||||
- [ ] Jenkins 프로젝트 생성
|
||||
- [ ] Argo CD 애플리케이션 등록
|
||||
- [ ] 환경 변수 설정 (DATABASE_HOST 등)
|
||||
- [ ] Secret 생성 (DB 비밀번호 등)
|
||||
- [ ] Ingress 도메인 설정
|
||||
- [ ] 헬스체크 엔드포인트 확인 (`/api/health`)
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **Kaniko**: 컨테이너 내에서 Docker 이미지를 빌드하는 도구
|
||||
- **GitOps**: Git을 Single Source of Truth로 사용하는 배포 방식
|
||||
- **Argo CD**: GitOps를 위한 Kubernetes CD 도구
|
||||
- **Helm**: Kubernetes 패키지 매니저
|
||||
|
||||
---
|
||||
|
||||
## 담당자 연락처
|
||||
|
||||
- **NCP 클러스터 관리**: 김욱동 책임 (엘에스티라유텍)
|
||||
- **Bastion 서버**: 223.130.135.25:22 (Docker 직접 배포용 아님)
|
||||
- **Argo CD**: https://argocd.kpslp.kr
|
||||
- **Kubernetes 네임스페이스**: apps
|
||||
|
||||
---
|
||||
|
||||
## 추가 설정 (선택사항)
|
||||
|
||||
### PostgreSQL 데이터베이스 설정
|
||||
클러스터 내부에 PostgreSQL이 없다면:
|
||||
|
||||
```yaml
|
||||
# values_vexplor.yaml 에 추가
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
username: vexplor
|
||||
password: changeme123 # Secret으로 관리 권장
|
||||
database: vexplor
|
||||
primary:
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 10Gi
|
||||
```
|
||||
|
||||
### Secret 생성 (민감 정보)
|
||||
```bash
|
||||
kubectl create secret generic vexplor-secrets \
|
||||
--from-literal=db-password='your-secure-password' \
|
||||
--from-literal=jwt-secret='your-jwt-secret' \
|
||||
-n apps
|
||||
```
|
||||
|
||||
### 모니터링 (Prometheus + Grafana)
|
||||
담당자에게 메트릭 수집 설정 요청
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
12
DOCKER.md
12
DOCKER.md
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
**기술 스택:**
|
||||
|
||||
- **백엔드**: Node.js + TypeScript + PostgreSQL (Raw Query)
|
||||
- **백엔드**: Node.js + TypeScript + Prisma + PostgreSQL
|
||||
- **프론트엔드**: Next.js + TypeScript + Tailwind CSS
|
||||
- **컨테이너**: Docker + Docker Compose
|
||||
|
||||
|
|
@ -98,12 +98,12 @@ npm install / npm uninstall # 패키지 설치/제거
|
|||
package-lock.json 변경 # 의존성 잠금 파일
|
||||
```
|
||||
|
||||
**데이터베이스 관련:**
|
||||
**Prisma 관련:**
|
||||
|
||||
```bash
|
||||
db/ilshin.pgsql # DB 스키마 파일 변경
|
||||
db/00-create-roles.sh # DB 초기화 스크립트 변경
|
||||
# SQL 마이그레이션은 직접 실행
|
||||
backend-node/prisma/schema.prisma # DB 스키마 변경
|
||||
npx prisma migrate # 마이그레이션 실행
|
||||
npx prisma generate # 클라이언트 재생성
|
||||
```
|
||||
|
||||
**설정 파일:**
|
||||
|
|
@ -207,7 +207,7 @@ ERP-node/
|
|||
│ ├── backend-node/
|
||||
│ │ ├── Dockerfile # 프로덕션용
|
||||
│ │ └── Dockerfile.dev # 개발용
|
||||
│ └── src/, database/, package.json...
|
||||
│ └── src/, prisma/, package.json...
|
||||
│
|
||||
├── 📁 프론트엔드
|
||||
│ ├── frontend/
|
||||
|
|
|
|||
106
Dockerfile
106
Dockerfile
|
|
@ -1,106 +0,0 @@
|
|||
# ==========================
|
||||
# 멀티 스테이지 Dockerfile
|
||||
# - 백엔드: Node.js + Express + TypeScript
|
||||
# - 프론트엔드: Next.js (프로덕션 빌드)
|
||||
# ==========================
|
||||
|
||||
# ------------------------------
|
||||
# Stage 1: 백엔드 빌드
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# 백엔드 의존성 설치
|
||||
COPY backend-node/package*.json ./
|
||||
RUN npm ci --only=production && \
|
||||
npm cache clean --force
|
||||
|
||||
# 백엔드 소스 복사 및 빌드
|
||||
COPY backend-node/tsconfig.json ./
|
||||
COPY backend-node/src ./src
|
||||
RUN npm install -D typescript @types/node && \
|
||||
npm run build && \
|
||||
npm prune --production
|
||||
|
||||
# ------------------------------
|
||||
# Stage 2: 프론트엔드 빌드
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# 프론트엔드 의존성 설치
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci && \
|
||||
npm cache clean --force
|
||||
|
||||
# 프론트엔드 소스 복사
|
||||
COPY frontend/ ./
|
||||
|
||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build:no-lint
|
||||
|
||||
# ------------------------------
|
||||
# Stage 3: 최종 런타임 이미지
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS runtime
|
||||
|
||||
# 보안 강화: 비특권 사용자 생성
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 백엔드 런타임 파일 복사
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/dist ./backend/dist
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/node_modules ./backend/node_modules
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/package.json ./backend/package.json
|
||||
|
||||
# 프론트엔드 런타임 파일 복사
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/.next ./frontend/.next
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/node_modules ./frontend/node_modules
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./frontend/package.json
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
||||
|
||||
# 업로드 디렉토리 생성 (백엔드용)
|
||||
RUN mkdir -p /app/backend/uploads && \
|
||||
chown -R nodejs:nodejs /app/backend/uploads
|
||||
|
||||
# 시작 스크립트 생성
|
||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||
echo 'set -e' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/backend' >> /app/start.sh && \
|
||||
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
|
||||
echo 'node dist/app.js &' >> /app/start.sh && \
|
||||
echo 'BACKEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||
echo 'npm start &' >> /app/start.sh && \
|
||||
echo 'FRONTEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프로세스 모니터링' >> /app/start.sh && \
|
||||
echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
|
||||
chmod +x /app/start.sh && \
|
||||
chown nodejs:nodejs /app/start.sh
|
||||
|
||||
# 비특권 사용자로 전환
|
||||
USER nodejs
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000 8080
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# 컨테이너 시작
|
||||
CMD ["/app/start.sh"]
|
||||
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
pipeline {
|
||||
agent {
|
||||
label "kaniko"
|
||||
}
|
||||
stages {
|
||||
stage("Checkout") {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
||||
env.GIT_AUTHOR_NAME = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true)
|
||||
env.GIT_AUTHOR_EMAIL = sh(script: "git log -1 --pretty=format:'%ae'", returnStdout: true)
|
||||
env.GIT_COMMIT_MESSAGE = sh (script: 'git log -1 --pretty=%B ${GIT_COMMIT}', returnStdout: true).trim()
|
||||
env.GIT_PROJECT_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-2]
|
||||
env.GIT_REPO_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Build") {
|
||||
steps {
|
||||
container("kaniko") {
|
||||
script {
|
||||
sh "/kaniko/executor --context . --destination registry.kpslp.kr/${GIT_PROJECT_NAME}/${GIT_REPO_NAME}:${GIT_COMMIT_SHORT}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Update Image Tag") {
|
||||
steps {
|
||||
deleteDir()
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: '*/main']],
|
||||
extensions: [],
|
||||
userRemoteConfigs: [[credentialsId: 'gitlab_userpass_root', url: "https://gitlab.kpslp.kr/root/helm-charts"]]
|
||||
])
|
||||
script {
|
||||
def valuesYaml = "kpslp/values_${GIT_REPO_NAME}.yaml"
|
||||
def values = readYaml file: "${valuesYaml}"
|
||||
values.image.tag = env.GIT_COMMIT_SHORT
|
||||
writeYaml file: "${valuesYaml}", data: values, overwrite: true
|
||||
|
||||
sh "git config user.name '${GIT_AUTHOR_NAME}'"
|
||||
sh "git config user.email '${GIT_AUTHOR_EMAIL}'"
|
||||
withCredentials([usernameColonPassword(credentialsId: 'gitlab_userpass_root', variable: 'USERPASS')]) {
|
||||
sh '''
|
||||
git add . && \
|
||||
git commit -m "${GIT_REPO_NAME}: ${GIT_COMMIT_MESSAGE}" && \
|
||||
git push https://${USERPASS}@gitlab.kpslp.kr/root/helm-charts HEAD:main || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
PLAN.MD
104
PLAN.MD
|
|
@ -1,104 +0,0 @@
|
|||
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
||||
|
||||
## 개요
|
||||
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
### 1. 단일 화면 복제
|
||||
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
|
||||
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
|
||||
- [x] 연결된 모달 화면 함께 복제
|
||||
- [x] 대상 그룹 선택 가능
|
||||
- [x] 복제 후 목록 자동 새로고침
|
||||
|
||||
### 2. 그룹(폴더) 전체 복제
|
||||
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
|
||||
- [x] 정렬 순서(display_order) 유지
|
||||
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
|
||||
- [x] 정렬 순서 입력 필드 추가
|
||||
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
|
||||
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
|
||||
|
||||
### 3. 고급 옵션: 이름 일괄 변경
|
||||
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
|
||||
- [x] 미리보기 기능
|
||||
|
||||
### 4. 삭제 기능
|
||||
- [x] 단일 화면 삭제 (휴지통으로 이동)
|
||||
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
|
||||
- [x] 삭제 시 로딩 프로그레스 바 표시
|
||||
|
||||
### 5. 화면 수정 기능
|
||||
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
|
||||
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
|
||||
|
||||
### 6. 테이블 설정 기능 (TableSettingModal)
|
||||
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
|
||||
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
|
||||
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
|
||||
- 코드→다른 타입: codeCategory, codeValue 초기화
|
||||
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
|
||||
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
|
||||
|
||||
### 7. 회사 코드 지원 (최고 관리자)
|
||||
- [x] 대상 회사 선택 가능
|
||||
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
|
||||
|
||||
## 관련 파일
|
||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
|
||||
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
|
||||
- `frontend/lib/api/screen.ts` - 화면 API
|
||||
- `frontend/lib/api/screenGroup.ts` - 그룹 API
|
||||
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
|
||||
|
||||
## 진행 상태
|
||||
- [완료] 단일 화면 복제 + 새로고침
|
||||
- [완료] 그룹 전체 복제 (재귀적)
|
||||
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
|
||||
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
|
||||
- [완료] 화면 수정 (이름/그룹/역할/순서)
|
||||
- [완료] 테이블 설정 탭 추가
|
||||
- [완료] 입력 타입 변경 시 관련 필드 초기화
|
||||
- [완료] 그룹 복제 모달 스크롤 문제 수정
|
||||
|
||||
---
|
||||
|
||||
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||
2. **백엔드 로직 개선**:
|
||||
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
|
||||
3. **프론트엔드 UI 개선**:
|
||||
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
|
||||
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
|
||||
|
||||
## 테스트 계획
|
||||
### 1단계: 기본 기능 및 DB 마이그레이션
|
||||
- [x] DB 마이그레이션 스크립트 작성 및 실행
|
||||
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
|
||||
|
||||
### 2단계: 백엔드 로직 구현
|
||||
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
|
||||
- [x] 커넥션 상세 조회 API 확인
|
||||
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
|
||||
|
||||
### 3단계: 프론트엔드 구현
|
||||
- [x] 커넥션 관리 리스트/모달 UI 수정
|
||||
- [x] 연결 테스트 UI 수정 및 기능 확인
|
||||
|
||||
## 에러 처리 계획
|
||||
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||
|
||||
## 진행 상태
|
||||
- [완료] 모든 단계 구현 완료
|
||||
680
PLAN_RENEWAL.md
680
PLAN_RENEWAL.md
|
|
@ -1,680 +0,0 @@
|
|||
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
|
||||
|
||||
## 1. 개요
|
||||
|
||||
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
|
||||
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
|
||||
|
||||
### 현재 컴포넌트 현황 (AS-IS)
|
||||
|
||||
| 카테고리 | 파일 수 | 주요 파일들 |
|
||||
| :------------- | :-----: | :------------------------------------------------------------------ |
|
||||
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
|
||||
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
|
||||
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
|
||||
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 통합 전략: 9 Core Widgets
|
||||
|
||||
### A. 입력 위젯 (Input Widgets) - 5종
|
||||
|
||||
단순 데이터 입력 필드를 통합합니다.
|
||||
|
||||
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
|
||||
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
|
||||
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
|
||||
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
|
||||
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
|
||||
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
|
||||
|
||||
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
|
||||
|
||||
레이아웃 배치와 데이터 시각화를 담당합니다.
|
||||
|
||||
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
|
||||
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
|
||||
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
|
||||
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
|
||||
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
|
||||
|
||||
### C. Config Panel 통합 전략 (핵심)
|
||||
|
||||
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
|
||||
|
||||
| AS-IS | TO-BE | 방식 |
|
||||
| :-------------------- | :--------------------- | :------------------------------- |
|
||||
| TextConfigPanel.tsx | | |
|
||||
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
|
||||
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
|
||||
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
|
||||
| ... 24개 더 | | |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 시나리오 (속성 기반 변신)
|
||||
|
||||
### Case 1: "테이블을 카드 리스트로 변경"
|
||||
|
||||
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
|
||||
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영.
|
||||
|
||||
### Case 2: "단일 선택을 라디오 버튼으로 변경"
|
||||
|
||||
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
|
||||
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경.
|
||||
|
||||
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
|
||||
|
||||
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 로드맵 (Action Plan)
|
||||
|
||||
### Phase 0: 준비 단계 (1주)
|
||||
|
||||
통합 작업 전 필수 분석 및 설계를 진행합니다.
|
||||
|
||||
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
|
||||
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
|
||||
- [ ] `sys_input_type` 테이블 JSON Schema 설계
|
||||
- [ ] DynamicConfigPanel 프로토타입 설계
|
||||
|
||||
### Phase 1: 입력 위젯 통합 (2주)
|
||||
|
||||
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
|
||||
|
||||
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
|
||||
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
|
||||
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
|
||||
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
|
||||
|
||||
### Phase 2: Config Panel 통합 (2주)
|
||||
|
||||
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
|
||||
|
||||
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
|
||||
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
|
||||
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
|
||||
|
||||
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
|
||||
|
||||
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
|
||||
|
||||
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
|
||||
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
|
||||
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
|
||||
|
||||
### Phase 4: 안정화 및 마이그레이션 (2주)
|
||||
|
||||
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
|
||||
|
||||
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
|
||||
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
|
||||
- [ ] 마이그레이션 테스트 (스테이징 환경)
|
||||
- [ ] 문서화 및 개발 가이드 작성
|
||||
|
||||
### Phase 5: 레거시 정리 (추후 결정)
|
||||
|
||||
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
|
||||
|
||||
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
|
||||
- [ ] 미전환 화면 목록 정리
|
||||
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 마이그레이션 전략
|
||||
|
||||
### 5.1 위젯 타입 매핑 테이블
|
||||
|
||||
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
|
||||
|
||||
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
|
||||
| :-------------- | :------------ | :------------------------------ |
|
||||
| `text` | UnifiedInput | `type: "text"` |
|
||||
| `number` | UnifiedInput | `type: "number"` |
|
||||
| `email` | UnifiedInput | `type: "text", format: "email"` |
|
||||
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
|
||||
| `select` | UnifiedSelect | `mode: "dropdown"` |
|
||||
| `radio` | UnifiedSelect | `mode: "radio"` |
|
||||
| `checkbox` | UnifiedSelect | `mode: "check"` |
|
||||
| `date` | UnifiedDate | `type: "date"` |
|
||||
| `datetime` | UnifiedDate | `type: "datetime"` |
|
||||
| `textarea` | UnifiedText | `mode: "simple"` |
|
||||
| `file` | UnifiedMedia | `type: "file"` |
|
||||
| `image` | UnifiedMedia | `type: "image"` |
|
||||
|
||||
### 5.2 마이그레이션 원칙
|
||||
|
||||
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
|
||||
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
|
||||
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
|
||||
|
||||
---
|
||||
|
||||
## 6. 기대 효과
|
||||
|
||||
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
|
||||
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
|
||||
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
|
||||
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 및 대응 방안
|
||||
|
||||
| 리스크 | 영향도 | 대응 방안 |
|
||||
| :----------------------- | :----: | :-------------------------------- |
|
||||
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
|
||||
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
|
||||
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
|
||||
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 현재 컴포넌트 매핑 분석
|
||||
|
||||
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
|
||||
|
||||
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
|
||||
|
||||
#### UnifiedInput으로 통합 (4개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------- | :--------------- | :------------- |
|
||||
| text-input | `type: "text"` | |
|
||||
| number-input | `type: "number"` | |
|
||||
| slider-basic | `type: "slider"` | 속성 추가 필요 |
|
||||
| button-primary | `type: "button"` | 별도 검토 |
|
||||
|
||||
#### UnifiedSelect로 통합 (8개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------------ | :----------------------------------- | :------------- |
|
||||
| select-basic | `mode: "dropdown"` | |
|
||||
| checkbox-basic | `mode: "check"` | |
|
||||
| radio-basic | `mode: "radio"` | |
|
||||
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
|
||||
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
|
||||
| entity-search-input | `source: "entity"` | |
|
||||
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
|
||||
| location-swap-selector | `mode: "swap"` | 특수 UI |
|
||||
|
||||
#### UnifiedDate로 통합 (1개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------ | :------------- | :--- |
|
||||
| date-input | `type: "date"` | |
|
||||
|
||||
#### UnifiedText로 통합 (1개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------- | :--------------- | :--- |
|
||||
| textarea-basic | `mode: "simple"` | |
|
||||
|
||||
#### UnifiedMedia로 통합 (3개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------ | :------------------------------ | :--- |
|
||||
| file-upload | `type: "file"` | |
|
||||
| image-widget | `type: "image"` | |
|
||||
| image-display | `type: "image", readonly: true` | |
|
||||
|
||||
#### UnifiedList로 통합 (8개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :-------------------- | :------------------------------------ | :------------ |
|
||||
| table-list | `viewMode: "table"` | |
|
||||
| card-display | `viewMode: "card"` | |
|
||||
| repeater-field-group | `editable: true` | |
|
||||
| modal-repeater-table | `viewMode: "table", modal: true` | |
|
||||
| simple-repeater-table | `viewMode: "table", simple: true` | |
|
||||
| repeat-screen-modal | `viewMode: "card", modal: true` | |
|
||||
| table-search-widget | `viewMode: "table", searchable: true` | |
|
||||
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
|
||||
|
||||
#### UnifiedLayout으로 통합 (4개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------ | :-------------------------- | :------------- |
|
||||
| split-panel-layout | `type: "split"` | |
|
||||
| split-panel-layout2 | `type: "split", version: 2` | |
|
||||
| divider-line | `type: "divider"` | 속성 추가 필요 |
|
||||
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
|
||||
|
||||
#### UnifiedGroup으로 통합 (5개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------- | :--------------------- | :------------ |
|
||||
| accordion-basic | `type: "accordion"` | |
|
||||
| tabs | `type: "tabs"` | |
|
||||
| section-paper | `type: "section"` | |
|
||||
| section-card | `type: "card-section"` | |
|
||||
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
|
||||
|
||||
#### UnifiedBiz로 통합 (7개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :-------------------- | :------------------------ | :--------------- |
|
||||
| flow-widget | `type: "flow"` | 플로우 관리 |
|
||||
| rack-structure | `type: "rack"` | 창고 렉 구조 |
|
||||
| map | `type: "map"` | 지도 |
|
||||
| numbering-rule | `type: "numbering"` | 채번 규칙 |
|
||||
| category-manager | `type: "category"` | 카테고리 관리 |
|
||||
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
|
||||
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
|
||||
|
||||
#### 별도 검토 필요 (3개)
|
||||
|
||||
| 현재 컴포넌트 | 문제점 | 제안 |
|
||||
| :-------------------------- | :------------------- | :------------------------------ |
|
||||
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
|
||||
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
|
||||
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
|
||||
|
||||
### 8.2 매핑 분석 결과
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 전체 44개 컴포넌트 분석 결과 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ✅ 즉시 통합 가능 : 36개 (82%) │
|
||||
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
|
||||
│ 🔄 별도 검토 필요 : 3개 (7%) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.3 속성 확장 필요 사항
|
||||
|
||||
#### UnifiedInput 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
type: "text" | "number" | "password";
|
||||
|
||||
// 확장
|
||||
type: "text" | "number" | "password" | "slider" | "color" | "button";
|
||||
```
|
||||
|
||||
#### UnifiedSelect 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
mode: "dropdown" | "radio" | "check" | "tag";
|
||||
|
||||
// 확장
|
||||
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||
```
|
||||
|
||||
#### UnifiedLayout 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
type: "grid" | "split" | "flex";
|
||||
|
||||
// 확장
|
||||
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
|
||||
```
|
||||
|
||||
### 8.4 조건부 렌더링 공통화
|
||||
|
||||
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
|
||||
|
||||
```typescript
|
||||
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
|
||||
interface BaseUnifiedProps {
|
||||
// ... 기존 속성
|
||||
|
||||
/** 조건부 렌더링 설정 */
|
||||
conditional?: {
|
||||
enabled: boolean;
|
||||
field: string; // 참조할 필드명
|
||||
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
|
||||
value: any; // 비교 값
|
||||
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
|
||||
|
||||
### 9.1 현재 계층 구조 지원 현황
|
||||
|
||||
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
| :----------------- | :---------------------- | :--------------- |
|
||||
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
|
||||
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
|
||||
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
|
||||
| **TREE** | 일반 트리 | 카테고리 |
|
||||
|
||||
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
|
||||
|
||||
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
|
||||
|
||||
```typescript
|
||||
interface UnifiedHierarchyProps {
|
||||
/** 계층 유형 */
|
||||
type: "tree" | "org" | "bom" | "cascading";
|
||||
|
||||
/** 표시 방식 */
|
||||
viewMode: "tree" | "table" | "indent" | "dropdown";
|
||||
|
||||
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
|
||||
source: string;
|
||||
|
||||
/** 편집 가능 여부 */
|
||||
editable?: boolean;
|
||||
|
||||
/** 드래그 정렬 가능 */
|
||||
draggable?: boolean;
|
||||
|
||||
/** BOM 수량 표시 (BOM 타입 전용) */
|
||||
showQty?: boolean;
|
||||
|
||||
/** 최대 레벨 제한 */
|
||||
maxLevel?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 활용 예시
|
||||
|
||||
| 설정 | 결과 |
|
||||
| :---------------------------------------- | :------------------------- |
|
||||
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
|
||||
| `type: "org", viewMode: "tree"` | 조직도 |
|
||||
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
|
||||
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 최종 통합 컴포넌트 목록 (10개)
|
||||
|
||||
| # | 컴포넌트 | 역할 | 커버 범위 |
|
||||
| :-: | :------------------- | :------------- | :----------------------------------- |
|
||||
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
|
||||
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
|
||||
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
|
||||
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
|
||||
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
|
||||
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
|
||||
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
|
||||
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
|
||||
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
|
||||
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
|
||||
|
||||
---
|
||||
|
||||
## 11. 연쇄관계 관리 메뉴 통합 전략
|
||||
|
||||
### 11.1 현재 연쇄관계 관리 현황
|
||||
|
||||
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
|
||||
|
||||
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
|
||||
| :--------------- | :--------------------------------------- | :---------: | :----: |
|
||||
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
|
||||
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
|
||||
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
|
||||
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
|
||||
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
|
||||
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
|
||||
|
||||
### 11.2 통합 방향: 속성 기반 vs 공통 정의
|
||||
|
||||
#### 판단 기준
|
||||
|
||||
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
|
||||
| :--------------- | :---------: | :---------: | :----------------------- |
|
||||
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
|
||||
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
|
||||
|
||||
### 11.3 속성 통합 설계
|
||||
|
||||
#### 2단계 연쇄 → UnifiedSelect 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
|
||||
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedSelect
|
||||
source="db"
|
||||
table="warehouse_location"
|
||||
valueColumn="location_code"
|
||||
labelColumn="location_name"
|
||||
cascading={{
|
||||
parentField: "warehouse_code", // 같은 화면 내 부모 필드
|
||||
filterColumn: "warehouse_code", // 필터링할 컬럼
|
||||
clearOnChange: true // 부모 변경 시 초기화
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 조건부 필터 → 공통 conditional 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: 별도 관리 메뉴에서 조건 정의
|
||||
// cascading_condition 테이블에 저장
|
||||
|
||||
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
|
||||
<UnifiedInput
|
||||
conditional={{
|
||||
enabled: true,
|
||||
field: "order_type", // 참조할 필드
|
||||
operator: "=", // 비교 연산자
|
||||
value: "EXPORT", // 비교 값
|
||||
action: "show", // show | hide | disable | enable
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 자동 입력 → autoFill 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: cascading_auto_fill_group 테이블에 정의
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedInput
|
||||
autoFill={{
|
||||
enabled: true,
|
||||
sourceTable: "company_mng", // 조회할 테이블
|
||||
filterColumn: "company_code", // 필터링 컬럼
|
||||
userField: "companyCode", // 사용자 정보 필드
|
||||
displayColumn: "company_name", // 표시할 컬럼
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 상호 배제 → mutualExclusion 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: cascading_mutual_exclusion 테이블에 정의
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedSelect
|
||||
mutualExclusion={{
|
||||
enabled: true,
|
||||
targetField: "sub_category", // 상호 배제 대상 필드
|
||||
type: "exclusive", // exclusive | inclusive
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 11.4 관리 메뉴 정리 계획
|
||||
|
||||
| 현재 메뉴 | TO-BE | 비고 |
|
||||
| :-------------------------- | :----------------------- | :-------------------- |
|
||||
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
|
||||
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
|
||||
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
|
||||
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
|
||||
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
|
||||
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
|
||||
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
|
||||
|
||||
### 11.5 DB 테이블 정리 (Phase 5)
|
||||
|
||||
| 테이블 | 조치 | 시점 |
|
||||
| :--------------------------- | :----------------------- | :------ |
|
||||
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
|
||||
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_hierarchy_*` | **유지** | - |
|
||||
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
|
||||
|
||||
### 11.6 마이그레이션 스크립트 필요 항목
|
||||
|
||||
```sql
|
||||
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
|
||||
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
|
||||
-- 해당 컴포넌트의 cascading 속성으로 변환
|
||||
|
||||
-- 예시: WAREHOUSE_LOCATION 연쇄관계
|
||||
-- 이 관계를 사용하는 화면의 컴포넌트에
|
||||
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
|
||||
-- 속성 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 최종 아키텍처 요약
|
||||
|
||||
### 12.1 통합 컴포넌트 (10개)
|
||||
|
||||
| # | 컴포넌트 | 역할 |
|
||||
| :-: | :------------------- | :--------------------------------------- |
|
||||
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
|
||||
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
|
||||
| 3 | **UnifiedDate** | 날짜/시간 입력 |
|
||||
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
|
||||
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
|
||||
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
|
||||
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
|
||||
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
|
||||
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
|
||||
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
|
||||
|
||||
### 12.2 공통 속성 (모든 컴포넌트에 적용)
|
||||
|
||||
```typescript
|
||||
interface BaseUnifiedProps {
|
||||
// 기본 속성
|
||||
id: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// 스타일
|
||||
style?: ComponentStyle;
|
||||
className?: string;
|
||||
|
||||
// 조건부 렌더링 (conditional-container 대체)
|
||||
conditional?: {
|
||||
enabled: boolean;
|
||||
field: string;
|
||||
operator:
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| "<"
|
||||
| "in"
|
||||
| "notIn"
|
||||
| "isEmpty"
|
||||
| "isNotEmpty";
|
||||
value: any;
|
||||
action: "show" | "hide" | "disable" | "enable";
|
||||
};
|
||||
|
||||
// 자동 입력 (autoFill 대체)
|
||||
autoFill?: {
|
||||
enabled: boolean;
|
||||
sourceTable: string;
|
||||
filterColumn: string;
|
||||
userField: "companyCode" | "userId" | "deptCode";
|
||||
displayColumn: string;
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
validation?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 UnifiedSelect 전용 속성
|
||||
|
||||
```typescript
|
||||
interface UnifiedSelectProps extends BaseUnifiedProps {
|
||||
// 표시 모드
|
||||
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||
|
||||
// 데이터 소스
|
||||
source: "static" | "code" | "db" | "api" | "entity";
|
||||
|
||||
// static 소스
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
|
||||
// db 소스
|
||||
table?: string;
|
||||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
|
||||
// code 소스
|
||||
codeGroup?: string;
|
||||
|
||||
// 연쇄 관계 (cascading_relation 대체)
|
||||
cascading?: {
|
||||
parentField: string; // 부모 필드명
|
||||
filterColumn: string; // 필터링할 컬럼
|
||||
clearOnChange?: boolean; // 부모 변경 시 초기화
|
||||
};
|
||||
|
||||
// 상호 배제 (mutual_exclusion 대체)
|
||||
mutualExclusion?: {
|
||||
enabled: boolean;
|
||||
targetField: string; // 상호 배제 대상
|
||||
type: "exclusive" | "inclusive";
|
||||
};
|
||||
|
||||
// 다중 선택
|
||||
multiple?: boolean;
|
||||
maxSelect?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 12.4 관리 메뉴 정리 결과
|
||||
|
||||
| AS-IS | TO-BE |
|
||||
| :---------------------------- | :----------------------------------- |
|
||||
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
|
||||
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
|
||||
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
|
||||
| - 조건부 필터 | → 공통 conditional 속성 |
|
||||
| - 자동 입력 | → 공통 autoFill 속성 |
|
||||
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
|
||||
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 주의사항
|
||||
|
||||
> **기존 컴포넌트 삭제 금지**
|
||||
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
|
||||
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
|
||||
|
||||
> **연쇄관계 마이그레이션 필수**
|
||||
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
|
||||
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# 프로젝트 진행 상황 (2025-11-20)
|
||||
|
||||
## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조)
|
||||
|
||||
### 1. 핵심 변경 사항
|
||||
기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다.
|
||||
|
||||
### 2. 완료된 작업
|
||||
|
||||
#### 데이터베이스
|
||||
- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
- **스키마 변경**:
|
||||
- `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가
|
||||
- `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가
|
||||
- 기존 하드코딩된 테이블 매핑 컬럼 제거
|
||||
|
||||
#### 백엔드 (Node.js)
|
||||
- **API 추가/수정**:
|
||||
- `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회
|
||||
- `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회
|
||||
- 기존 레거시 API (`getWarehouses` 등) 호환성 유지
|
||||
- **컨트롤러 수정**:
|
||||
- `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현
|
||||
- `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리
|
||||
|
||||
#### 프론트엔드 (React)
|
||||
- **신규 컴포넌트**: `HierarchyConfigPanel.tsx`
|
||||
- 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI
|
||||
- **유틸리티**: `spatialContainment.ts`
|
||||
- `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB)
|
||||
- `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동)
|
||||
- **에디터 통합 (`DigitalTwinEditor.tsx`)**:
|
||||
- `HierarchyConfigPanel` 적용
|
||||
- 동적 데이터 로드 로직 구현
|
||||
- 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용
|
||||
- 객체 이동 시 그룹 이동 적용
|
||||
|
||||
### 3. 현재 상태
|
||||
- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨)
|
||||
- **DB**: 마이그레이션 스크립트 실행 완료
|
||||
|
||||
### 4. 다음 단계 (테스트 필요)
|
||||
새로운 세션에서 다음 시나리오를 테스트해야 합니다:
|
||||
1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장
|
||||
2. **배치 검증**:
|
||||
- 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함)
|
||||
- 위치를 구역 **외부**에 배치 (실패해야 함)
|
||||
3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인
|
||||
|
||||
### 5. 관련 파일
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx`
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx`
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts`
|
||||
- `backend-node/src/controllers/digitalTwinDataController.ts`
|
||||
- `backend-node/src/routes/digitalTwinRoutes.ts`
|
||||
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
# 🎨 제어관리 - 데이터 연결 설정 UI 재설계 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
- 기존 모달 기반 필드 매핑을 메인 화면으로 통합
|
||||
- 중복된 테이블 선택 과정 제거
|
||||
- 시각적 필드 연결 매핑 구현
|
||||
- 좌우 분할 레이아웃으로 정보 가시성 향상
|
||||
|
||||
### 현재 문제점
|
||||
|
||||
- ❌ **이중 작업**: 테이블을 3번 선택해야 함 (더블클릭 → 모달 → 재선택)
|
||||
- ❌ **혼란스러운 UX**: 사전 선택의 의미가 없어짐
|
||||
- ❌ **불필요한 모달**: 연결 설정이 메인 기능인데 숨겨져 있음
|
||||
- ❌ **시각적 피드백 부족**: 필드 매핑 관계가 명확하지 않음
|
||||
|
||||
## 🎯 새로운 UI 구조
|
||||
|
||||
### 레이아웃 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 제어관리 - 데이터 연결 설정 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 좌측 패널 (30%) │ 우측 패널 (70%) │
|
||||
│ - 연결 타입 선택 │ - 단계별 설정 UI │
|
||||
│ - 매핑 정보 모니터링 │ - 시각적 필드 매핑 │
|
||||
│ - 상세 설정 목록 │ - 실시간 연결선 표시 │
|
||||
│ - 액션 버튼 │ - 드래그 앤 드롭 지원 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 기본 구조 구축
|
||||
|
||||
- [ ] 좌우 분할 레이아웃 컴포넌트 생성
|
||||
- [ ] 기존 모달 컴포넌트들을 메인 화면용으로 리팩토링
|
||||
- [ ] 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
### Phase 2: 좌측 패널 구현
|
||||
|
||||
- [ ] 연결 타입 선택 (데이터 저장 / 외부 호출)
|
||||
- [ ] 실시간 매핑 정보 표시
|
||||
- [ ] 매핑 상세 목록 컴포넌트
|
||||
- [ ] 고급 설정 패널
|
||||
|
||||
### Phase 3: 우측 패널 구현
|
||||
|
||||
- [ ] 단계별 진행 UI (연결 → 테이블 → 매핑)
|
||||
- [ ] 시각적 필드 매핑 영역
|
||||
- [ ] SVG 기반 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑 기능
|
||||
|
||||
### Phase 4: 고급 기능
|
||||
|
||||
- [ ] 실시간 검증 및 피드백
|
||||
- [ ] 매핑 미리보기 기능
|
||||
- [ ] 설정 저장/불러오기
|
||||
- [ ] 테스트 실행 기능
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
### 새로 생성할 컴포넌트
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/redesigned/
|
||||
├── DataConnectionDesigner.tsx # 메인 컨테이너
|
||||
├── LeftPanel/
|
||||
│ ├── ConnectionTypeSelector.tsx # 연결 타입 선택
|
||||
│ ├── MappingInfoPanel.tsx # 매핑 정보 표시
|
||||
│ ├── MappingDetailList.tsx # 매핑 상세 목록
|
||||
│ ├── AdvancedSettings.tsx # 고급 설정
|
||||
│ └── ActionButtons.tsx # 액션 버튼들
|
||||
├── RightPanel/
|
||||
│ ├── StepProgress.tsx # 단계 진행 표시
|
||||
│ ├── ConnectionStep.tsx # 1단계: 연결 선택
|
||||
│ ├── TableStep.tsx # 2단계: 테이블 선택
|
||||
│ ├── FieldMappingStep.tsx # 3단계: 필드 매핑
|
||||
│ └── VisualMapping/
|
||||
│ ├── FieldMappingCanvas.tsx # 시각적 매핑 캔버스
|
||||
│ ├── FieldColumn.tsx # 필드 컬럼 컴포넌트
|
||||
│ ├── ConnectionLine.tsx # SVG 연결선
|
||||
│ └── MappingControls.tsx # 매핑 제어 도구
|
||||
└── types/
|
||||
└── redesigned.ts # 타입 정의
|
||||
```
|
||||
|
||||
### 수정할 기존 파일
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/
|
||||
├── DataSaveSettings.tsx # 새 UI로 교체
|
||||
├── ConnectionSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
├── TableSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
└── ActionFieldMappings.tsx # 레거시 처리
|
||||
```
|
||||
|
||||
## 🎨 UI 컴포넌트 상세
|
||||
|
||||
### 1. 연결 타입 선택 (ConnectionTypeSelector)
|
||||
|
||||
```typescript
|
||||
interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe />,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 2. 시각적 필드 매핑 (FieldMappingCanvas)
|
||||
|
||||
```typescript
|
||||
interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
toField: ColumnInfo;
|
||||
transformRule?: string;
|
||||
isValid: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 매핑 정보 패널 (MappingInfoPanel)
|
||||
|
||||
```typescript
|
||||
interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
invalidMappings: number;
|
||||
missingRequiredFields: number;
|
||||
estimatedRows: number;
|
||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 데이터 플로우
|
||||
|
||||
### 상태 관리
|
||||
|
||||
```typescript
|
||||
interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
|
||||
// 매핑 정보
|
||||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
```
|
||||
|
||||
### 이벤트 핸들링
|
||||
|
||||
```typescript
|
||||
interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 사용자 경험 (UX) 개선점
|
||||
|
||||
### Before (기존)
|
||||
|
||||
1. 테이블 더블클릭 → 화면에 표시
|
||||
2. 모달 열기 → 다시 테이블 선택
|
||||
3. 외부 커넥션 설정 → 또 다시 테이블 선택
|
||||
4. 필드 매핑 → 텍스트 기반 매핑
|
||||
|
||||
### After (개선)
|
||||
|
||||
1. **연결 타입 선택** → 목적 명확화
|
||||
2. **연결 선택** → 한 번에 FROM/TO 설정
|
||||
3. **테이블 선택** → 즉시 필드 정보 로드
|
||||
4. **시각적 매핑** → 드래그 앤 드롭으로 직관적 연결
|
||||
|
||||
## 🚀 구현 우선순위
|
||||
|
||||
### 🔥 High Priority
|
||||
|
||||
1. **기본 레이아웃** - 좌우 분할 구조
|
||||
2. **연결 타입 선택** - 데이터 저장/외부 호출
|
||||
3. **단계별 진행** - 연결 → 테이블 → 매핑
|
||||
4. **기본 필드 매핑** - 드래그 앤 드롭 없이 클릭 기반
|
||||
|
||||
### 🔶 Medium Priority
|
||||
|
||||
1. **시각적 연결선** - SVG 기반 라인 표시
|
||||
2. **실시간 검증** - 타입 호환성 체크
|
||||
3. **매핑 정보 패널** - 통계 및 상태 표시
|
||||
4. **드래그 앤 드롭** - 고급 매핑 기능
|
||||
|
||||
### 🔵 Low Priority
|
||||
|
||||
1. **고급 설정** - 트랜잭션, 배치 설정
|
||||
2. **미리보기 기능** - 데이터 변환 미리보기
|
||||
3. **설정 템플릿** - 자주 사용하는 매핑 저장
|
||||
4. **성능 최적화** - 대용량 테이블 처리
|
||||
|
||||
## 📅 개발 일정
|
||||
|
||||
### Week 1: 기본 구조
|
||||
|
||||
- [ ] 레이아웃 컴포넌트 생성
|
||||
- [ ] 연결 타입 선택 구현
|
||||
- [ ] 기존 컴포넌트 리팩토링
|
||||
|
||||
### Week 2: 핵심 기능
|
||||
|
||||
- [ ] 단계별 진행 UI
|
||||
- [ ] 연결/테이블 선택 통합
|
||||
- [ ] 기본 필드 매핑 구현
|
||||
|
||||
### Week 3: 시각적 개선
|
||||
|
||||
- [ ] SVG 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑
|
||||
- [ ] 실시간 검증 기능
|
||||
|
||||
### Week 4: 완성 및 테스트
|
||||
|
||||
- [ ] 고급 기능 구현
|
||||
- [ ] 통합 테스트
|
||||
- [ ] 사용자 테스트 및 피드백 반영
|
||||
|
||||
## 🔍 기술적 고려사항
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- **가상화**: 대용량 필드 목록 처리
|
||||
- **메모이제이션**: 불필요한 리렌더링 방지
|
||||
- **지연 로딩**: 필요한 시점에만 데이터 로드
|
||||
|
||||
### 접근성
|
||||
|
||||
- **키보드 네비게이션**: 모든 기능을 키보드로 접근 가능
|
||||
- **스크린 리더**: 시각적 매핑의 대체 텍스트 제공
|
||||
- **색상 대비**: 연결선과 상태 표시의 명확한 구분
|
||||
|
||||
### 확장성
|
||||
|
||||
- **플러그인 구조**: 새로운 연결 타입 쉽게 추가
|
||||
- **커스텀 변환**: 사용자 정의 데이터 변환 규칙
|
||||
- **API 확장**: 외부 시스템과의 연동 지원
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
이 계획서를 바탕으로 **Phase 1부터 순차적으로 구현**을 시작하겠습니다.
|
||||
|
||||
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
구현을 시작하시겠어요? 🚀
|
||||
800
UI_개선사항_문서.md
800
UI_개선사항_문서.md
|
|
@ -1,800 +0,0 @@
|
|||
# ERP 시스템 UI/UX 디자인 가이드
|
||||
|
||||
## 📋 문서 목적
|
||||
이 문서는 ERP 시스템의 새로운 페이지나 컴포넌트를 개발할 때 참고할 수 있는 **디자인 시스템 기준안**입니다.
|
||||
일관된 사용자 경험을 위해 모든 개발자는 이 가이드를 따라 개발해주세요.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 시스템 개요
|
||||
|
||||
### 디자인 철학
|
||||
- **일관성**: 모든 페이지에서 동일한 패턴 사용
|
||||
- **명확성**: 직관적이고 이해하기 쉬운 UI
|
||||
- **접근성**: 모든 사용자가 쉽게 사용할 수 있도록
|
||||
- **반응성**: 다양한 화면 크기에 대응
|
||||
|
||||
### 기술 스택
|
||||
- **CSS Framework**: Tailwind CSS
|
||||
- **UI Library**: shadcn/ui
|
||||
- **Icons**: Lucide React
|
||||
|
||||
---
|
||||
|
||||
## 📐 페이지 기본 구조
|
||||
|
||||
### 1. 표준 페이지 레이아웃
|
||||
|
||||
```tsx
|
||||
export default function YourPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">페이지 제목</h1>
|
||||
<p className="mt-2 text-gray-600">페이지 설명</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
{/* 내용 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 구조 설명
|
||||
|
||||
#### 최상위 래퍼
|
||||
```tsx
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
```
|
||||
- `min-h-screen`: 최소 높이를 화면 전체로
|
||||
- `bg-gray-50`: 연한 회색 배경 (전체 페이지 기본 배경)
|
||||
|
||||
#### 컨테이너
|
||||
```tsx
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
```
|
||||
- `w-full max-w-none`: 전체 너비 사용
|
||||
- `px-4`: 좌우 패딩 1rem (16px)
|
||||
- `py-8`: 상하 패딩 2rem (32px)
|
||||
- `space-y-8`: 하위 요소 간 수직 간격 2rem
|
||||
|
||||
#### 헤더 카드
|
||||
```tsx
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">제목</h1>
|
||||
<p className="mt-2 text-gray-600">설명</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 컴포넌트 디자인 기준
|
||||
|
||||
### 1. 버튼
|
||||
|
||||
#### 주요 버튼 (Primary)
|
||||
```tsx
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
버튼 텍스트
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### 보조 버튼 (Secondary)
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### 위험 버튼 (Danger)
|
||||
```tsx
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
삭제
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. 카드 (Card)
|
||||
|
||||
#### 기본 카드
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>카드 제목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{/* 내용 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
#### 강조 카드
|
||||
```tsx
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Icon className="w-5 h-5 mr-2 text-orange-500" />
|
||||
제목
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700">내용</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 3. 테이블
|
||||
|
||||
#### 기본 테이블 구조
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
컬럼명
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60">
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
데이터
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 4. 폼 (Form)
|
||||
|
||||
#### 입력 필드
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
라벨
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 셀렉트
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">옵션 1</SelectItem>
|
||||
<SelectItem value="2">옵션 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
### 5. 빈 상태 (Empty State)
|
||||
|
||||
```tsx
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Icon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">데이터가 없습니다</p>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
추가하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 6. 로딩 상태
|
||||
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 색상 시스템
|
||||
|
||||
### 주 색상 (Primary)
|
||||
```css
|
||||
orange-50 #fff7ed /* 매우 연한 배경 */
|
||||
orange-100 #ffedd5 /* 연한 배경 */
|
||||
orange-500 #f97316 /* 주요 버튼, 강조 */
|
||||
orange-600 #ea580c /* 버튼 호버 */
|
||||
```
|
||||
|
||||
### 회색 (Gray)
|
||||
```css
|
||||
gray-50 #f9fafb /* 페이지 배경 */
|
||||
gray-100 #f3f4f6 /* 카드 내부 구분 */
|
||||
gray-200 #e5e7eb /* 테두리 */
|
||||
gray-300 #d1d5db /* 입력 필드 테두리 */
|
||||
gray-500 #6b7280 /* 보조 텍스트 */
|
||||
gray-600 #4b5563 /* 일반 텍스트 */
|
||||
gray-700 #374151 /* 라벨, 헤더 */
|
||||
gray-800 #1f2937 /* 제목 */
|
||||
gray-900 #111827 /* 주요 제목 */
|
||||
```
|
||||
|
||||
### 상태 색상
|
||||
```css
|
||||
/* 성공 */
|
||||
green-100 #dcfce7
|
||||
green-500 #22c55e
|
||||
green-700 #15803d
|
||||
|
||||
/* 경고 */
|
||||
red-100 #fee2e2
|
||||
red-500 #ef4444
|
||||
red-600 #dc2626
|
||||
|
||||
/* 정보 */
|
||||
blue-50 #eff6ff
|
||||
blue-100 #dbeafe
|
||||
blue-500 #3b82f6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 간격 시스템
|
||||
|
||||
### Spacing Scale
|
||||
```css
|
||||
space-y-2 0.5rem (8px) /* 폼 요소 간 간격 */
|
||||
space-y-4 1rem (16px) /* 섹션 내부 간격 */
|
||||
space-y-6 1.5rem (24px) /* 카드 내부 큰 간격 */
|
||||
space-y-8 2rem (32px) /* 페이지 주요 섹션 간격 */
|
||||
|
||||
gap-2 0.5rem (8px) /* 버튼 그룹 간격 */
|
||||
gap-4 1rem (16px) /* 카드 그리드 간격 */
|
||||
gap-6 1.5rem (24px) /* 큰 카드 그리드 간격 */
|
||||
```
|
||||
|
||||
### Padding
|
||||
```css
|
||||
p-2 0.5rem (8px) /* 작은 요소 */
|
||||
p-4 1rem (16px) /* 일반 요소 */
|
||||
p-6 1.5rem (24px) /* 카드, 헤더 */
|
||||
p-8 2rem (32px) /* 큰 영역 */
|
||||
|
||||
px-3 좌우 0.75rem /* 입력 필드 */
|
||||
px-4 좌우 1rem /* 버튼 */
|
||||
px-6 좌우 1.5rem /* 테이블 셀 */
|
||||
|
||||
py-2 상하 0.5rem /* 버튼 */
|
||||
py-4 상하 1rem /* 입력 필드 */
|
||||
py-8 상하 2rem /* 페이지 컨테이너 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 타이포그래피
|
||||
|
||||
### 제목 (Headings)
|
||||
```css
|
||||
/* 페이지 제목 */
|
||||
text-3xl font-bold text-gray-900
|
||||
/* 예: 30px, Bold, #111827 */
|
||||
|
||||
/* 섹션 제목 */
|
||||
text-2xl font-bold text-gray-900
|
||||
/* 예: 24px, Bold */
|
||||
|
||||
/* 카드 제목 */
|
||||
text-lg font-semibold text-gray-800
|
||||
/* 예: 18px, Semi-bold */
|
||||
|
||||
/* 작은 제목 */
|
||||
text-base font-medium text-gray-700
|
||||
/* 예: 16px, Medium */
|
||||
```
|
||||
|
||||
### 본문 (Body Text)
|
||||
```css
|
||||
/* 일반 텍스트 */
|
||||
text-sm text-gray-600
|
||||
/* 14px, #4b5563 */
|
||||
|
||||
/* 보조 설명 */
|
||||
text-sm text-gray-500
|
||||
/* 14px, #6b7280 */
|
||||
|
||||
/* 라벨 */
|
||||
text-sm font-medium text-gray-700
|
||||
/* 14px, Medium */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎭 인터랙션 패턴
|
||||
|
||||
### 호버 효과
|
||||
```css
|
||||
/* 버튼 호버 */
|
||||
hover:bg-orange-600
|
||||
hover:shadow-md
|
||||
|
||||
/* 카드 호버 */
|
||||
hover:shadow-lg transition-shadow
|
||||
|
||||
/* 테이블 행 호버 */
|
||||
hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60
|
||||
```
|
||||
|
||||
### 포커스 효과
|
||||
```css
|
||||
/* 입력 필드 포커스 */
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-orange-500
|
||||
focus:border-orange-500
|
||||
```
|
||||
|
||||
### 전환 효과
|
||||
```css
|
||||
/* 일반 전환 */
|
||||
transition-all duration-200
|
||||
|
||||
/* 그림자 전환 */
|
||||
transition-shadow
|
||||
|
||||
/* 색상 전환 */
|
||||
transition-colors duration-200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔲 그리드 시스템
|
||||
|
||||
### 반응형 그리드
|
||||
```tsx
|
||||
{/* 1열 → 2열 → 3열 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 카드들 */}
|
||||
</div>
|
||||
|
||||
{/* 1열 → 2열 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 항목들 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 브레이크포인트
|
||||
```css
|
||||
sm: 640px @media (min-width: 640px)
|
||||
md: 768px @media (min-width: 768px)
|
||||
lg: 1024px @media (min-width: 1024px)
|
||||
xl: 1280px @media (min-width: 1280px)
|
||||
2xl: 1536px @media (min-width: 1536px)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 실전 예제
|
||||
|
||||
### 예제 1: 관리 페이지 (데이터 있음)
|
||||
|
||||
```tsx
|
||||
export default function ManagementPage() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">데이터 관리</h1>
|
||||
<p className="mt-2 text-gray-600">시스템 데이터를 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadData}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새로 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">총 개수</p>
|
||||
<p className="text-2xl font-bold text-gray-900">156</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 p-3 rounded-lg">
|
||||
<Database className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 나머지 통계 카드들... */}
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
이름
|
||||
</th>
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
상태
|
||||
</th>
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60"
|
||||
>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-700">
|
||||
활성
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
수정
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-red-500">
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 예제 2: 빈 상태 페이지
|
||||
|
||||
```tsx
|
||||
export default function EmptyStatePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">데이터 관리</h1>
|
||||
<p className="mt-2 text-gray-600">시스템 데이터를 관리합니다</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새로 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Database className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">아직 등록된 데이터가 없습니다</p>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
첫 데이터 추가하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Info className="w-5 h-5 mr-2 text-orange-500" />
|
||||
데이터 관리 안내
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 데이터를 추가하여 시스템을 사용해보세요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>기능 설명 1</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>기능 설명 2</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### 새 페이지 만들 때
|
||||
- [ ] `min-h-screen bg-gray-50` 래퍼 사용
|
||||
- [ ] 헤더 카드 (`bg-white rounded-lg shadow-sm border p-6`) 포함
|
||||
- [ ] 제목은 `text-3xl font-bold text-gray-900`
|
||||
- [ ] 설명은 `mt-2 text-gray-600`
|
||||
- [ ] 주요 버튼은 `bg-orange-500 hover:bg-orange-600`
|
||||
- [ ] 카드는 `shadow-sm` 클래스 포함
|
||||
- [ ] 간격은 `space-y-8` 사용
|
||||
|
||||
### 새 컴포넌트 만들 때
|
||||
- [ ] 일관된 패딩 사용 (`p-4`, `p-6`)
|
||||
- [ ] 호버 효과 추가
|
||||
- [ ] 전환 애니메이션 적용 (`transition-all duration-200`)
|
||||
- [ ] 적절한 아이콘 사용 (Lucide React)
|
||||
- [ ] 반응형 디자인 고려 (`md:`, `lg:`)
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
### Tailwind CSS 공식 문서
|
||||
- https://tailwindcss.com/docs
|
||||
|
||||
### shadcn/ui 컴포넌트
|
||||
- https://ui.shadcn.com/
|
||||
|
||||
### Lucide 아이콘
|
||||
- https://lucide.dev/icons/
|
||||
|
||||
---
|
||||
|
||||
## 📧 메일 관리 시스템 UI 개선사항
|
||||
|
||||
### 최근 업데이트 (2025-01-02)
|
||||
|
||||
#### 1. 메일 발송 페이지 헤더 개선
|
||||
**변경 전:**
|
||||
```tsx
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-lg">
|
||||
<Send className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">메일 발송</h1>
|
||||
<p className="text-sm text-gray-500">설명</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**변경 후 (표준 헤더 카드 적용):**
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">메일 발송</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**개선 사항:**
|
||||
- ✅ 불필요한 아이콘 제거 (종이비행기)
|
||||
- ✅ 표준 Card 컴포넌트 사용으로 통일감 향상
|
||||
- ✅ 다른 페이지와 동일한 헤더 스타일 적용
|
||||
|
||||
#### 2. 메일 내용 입력 개선
|
||||
**변경 전:**
|
||||
```tsx
|
||||
<Textarea placeholder="메일 내용을 html로 작성하세요" />
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```tsx
|
||||
<Textarea
|
||||
placeholder="메일 내용을 입력하세요
|
||||
|
||||
줄바꿈은 자동으로 처리됩니다."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
||||
</p>
|
||||
```
|
||||
|
||||
**개선 사항:**
|
||||
- ✅ HTML 지식 없이도 사용 가능
|
||||
- ✅ 일반 텍스트 입력 후 자동 HTML 변환
|
||||
- ✅ 사용자 친화적인 안내 메시지
|
||||
|
||||
#### 3. CC/BCC 기능 추가
|
||||
**구현 내용:**
|
||||
```tsx
|
||||
{/* To 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={to}
|
||||
onTagsChange={setTo}
|
||||
placeholder="받는 사람 이메일"
|
||||
/>
|
||||
|
||||
{/* CC 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={cc}
|
||||
onTagsChange={setCc}
|
||||
placeholder="참조 (선택사항)"
|
||||
/>
|
||||
|
||||
{/* BCC 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={bcc}
|
||||
onTagsChange={setBcc}
|
||||
placeholder="숨은참조 (선택사항)"
|
||||
/>
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- ✅ 이메일 주소를 태그 형태로 시각화
|
||||
- ✅ 쉼표로 구분하여 입력 가능
|
||||
- ✅ 개별 삭제 가능
|
||||
|
||||
#### 4. 파일 첨부 기능 (Phase 1 완료)
|
||||
|
||||
**백엔드 구현:**
|
||||
```typescript
|
||||
// multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 발송 API
|
||||
router.post(
|
||||
'/simple',
|
||||
uploadMailAttachment.array('attachments', 5),
|
||||
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
**보안 기능:**
|
||||
- ✅ 위험한 파일 확장자 차단 (.exe, .bat, .cmd, .sh 등)
|
||||
- ✅ 파일 크기 제한 (10MB)
|
||||
- ✅ 파일 개수 제한 (최대 5개)
|
||||
- ✅ 안전한 파일명 생성
|
||||
|
||||
**프론트엔드 구현 예정 (Phase 1-3):**
|
||||
- 드래그 앤 드롭 파일 업로드
|
||||
- 첨부된 파일 목록 표시
|
||||
- 파일 삭제 기능
|
||||
- 미리보기에 첨부파일 정보 표시
|
||||
|
||||
#### 5. 향후 작업 계획
|
||||
|
||||
**Phase 2: 보낸메일함 백엔드**
|
||||
- 발송 이력 자동 저장 (JSON 파일)
|
||||
- 발송 상태 관리 (성공/실패)
|
||||
- 발송 이력 조회 API
|
||||
|
||||
**Phase 3: 보낸메일함 프론트엔드**
|
||||
- `/admin/mail/sent` 페이지
|
||||
- 발송 목록 테이블
|
||||
- 상세보기 모달
|
||||
- 재전송 기능
|
||||
|
||||
**Phase 4: 대시보드 통합**
|
||||
- 대시보드에 "보낸메일함" 링크
|
||||
- 실제 발송 통계 연동
|
||||
- 최근 활동 목록
|
||||
|
||||
### 메일 시스템 UI 가이드
|
||||
|
||||
#### 이메일 태그 입력
|
||||
```tsx
|
||||
// 이메일 주소를 시각적으로 표시
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md text-sm"
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
{tag}
|
||||
<button onClick={() => removeTag(index)}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 파일 첨부 영역 (예정)
|
||||
```tsx
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-orange-400 transition-colors">
|
||||
<input type="file" multiple className="hidden" />
|
||||
<div className="text-center">
|
||||
<Upload className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
최대 5개, 각 10MB 이하
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 발송 성공 토스트
|
||||
```tsx
|
||||
<div className="fixed top-4 right-4 bg-white rounded-lg shadow-lg border border-green-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">메일이 발송되었습니다</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{to.length}명에게 전송 완료
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
|
||||
# ==================== 운영/작업 지원 위젯 데이터 소스 설정 ====================
|
||||
# 옵션: file | database | memory
|
||||
# - file: 파일 기반 (빠른 개발/테스트)
|
||||
# - database: PostgreSQL DB (실제 운영)
|
||||
# - memory: 메모리 목 데이터 (테스트)
|
||||
|
||||
TODO_DATA_SOURCE=file
|
||||
BOOKING_DATA_SOURCE=file
|
||||
MAINTENANCE_DATA_SOURCE=memory
|
||||
DOCUMENT_DATA_SOURCE=memory
|
||||
|
||||
|
||||
# OpenWeatherMap API 키 추가 (실시간 날씨)
|
||||
# https://openweathermap.org/api 에서 무료 가입 후 발급
|
||||
OPENWEATHER_API_KEY=your_openweathermap_api_key_here
|
||||
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 🔑 공유 API 키 (팀 전체 사용)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
||||
# 팀원들이 동일한 API 키를 사용합니다.
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# 한국은행 환율 API 키
|
||||
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
||||
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
|
||||
|
||||
# 기상청 API Hub 키
|
||||
# 발급: https://apihub.kma.go.kr/
|
||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
||||
|
||||
# ITS 국가교통정보센터 API 키
|
||||
# 발급: https://www.its.go.kr/
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
|
||||
# 한국도로공사 OpenOASIS API 키
|
||||
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
||||
EXWAY_API_KEY=7820214492
|
||||
|
||||
# ExchangeRate API 키 (백업용, 선택사항)
|
||||
# 발급: https://www.exchangerate-api.com/
|
||||
# EXCHANGERATE_API_KEY=your_exchangerate_api_key_here
|
||||
|
||||
# Kakao API 키 (Geocoding용, 선택사항)
|
||||
# 발급: https://developers.kakao.com/
|
||||
# KAKAO_API_KEY=your_kakao_api_key_here
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 📝 사용 방법
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# 1. 이 파일을 복사하여 .env 파일 생성:
|
||||
# $ cp .env.shared .env
|
||||
#
|
||||
# 2. 그대로 사용하면 됩니다!
|
||||
# (팀 전체가 동일한 키 사용)
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
|
||||
# 🔌 API 연동 가이드
|
||||
|
||||
## 📊 현재 상태
|
||||
|
||||
### ✅ 작동 중인 API
|
||||
|
||||
1. **기상청 특보 API** (완벽 작동!)
|
||||
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- 상태: ✅ 14건 실시간 특보 수신 중
|
||||
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
||||
|
||||
2. **한국은행 환율 API** (완벽 작동!)
|
||||
- API 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
- 상태: ✅ 환율 위젯 작동 중
|
||||
|
||||
### ⚠️ 더미 데이터 사용 중
|
||||
|
||||
3. **교통사고 정보**
|
||||
- 한국도로공사 API: ❌ 서버 호출 차단
|
||||
- 현재 상태: 더미 데이터 (2건)
|
||||
|
||||
4. **도로공사 정보**
|
||||
- 한국도로공사 API: ❌ 서버 호출 차단
|
||||
- 현재 상태: 더미 데이터 (2건)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실시간 교통정보 연동하기
|
||||
|
||||
### 📌 국토교통부 ITS API (추천!)
|
||||
|
||||
#### 1단계: API 신청
|
||||
1. https://www.data.go.kr/ 접속
|
||||
2. 검색: **"ITS 돌발정보"** 또는 **"실시간 교통정보"**
|
||||
3. **활용신청** 클릭
|
||||
4. **승인 대기 (1~2일)**
|
||||
|
||||
#### 2단계: API 키 추가
|
||||
승인 완료되면 `.env` 파일에 추가:
|
||||
|
||||
```env
|
||||
# 국토교통부 ITS API 키
|
||||
ITS_API_KEY=발급받은_API_키
|
||||
```
|
||||
|
||||
#### 3단계: 서버 재시작
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
#### 4단계: 확인
|
||||
- 로그에서 `✅ 국토교통부 ITS 교통사고 API 응답 수신 완료` 확인
|
||||
- 더미 데이터 대신 실제 데이터가 표시됨!
|
||||
|
||||
---
|
||||
|
||||
## 🔍 한국도로공사 API 문제
|
||||
|
||||
### 발급된 키
|
||||
```
|
||||
EXWAY_API_KEY=7820214492
|
||||
```
|
||||
|
||||
### 문제 상황
|
||||
- ❌ 서버/백엔드에서 호출 시: `Request Blocked` (400)
|
||||
- ❌ curl 명령어: `Request Blocked`
|
||||
- ❌ 모든 엔드포인트 차단됨
|
||||
|
||||
### 가능한 원인
|
||||
1. **브라우저에서만 접근 허용**
|
||||
- Referer 헤더 검증
|
||||
- User-Agent 검증
|
||||
|
||||
2. **IP 화이트리스트**
|
||||
- 특정 IP에서만 접근 가능
|
||||
- 서버 IP 등록 필요
|
||||
|
||||
3. **API 키 활성화 대기**
|
||||
- 발급 후 승인 대기 중
|
||||
- 몇 시간~1일 소요
|
||||
|
||||
### 해결 방법
|
||||
1. 한국도로공사 담당자 문의 (054-811-4533)
|
||||
2. 국토교통부 ITS API 사용 (더 안정적)
|
||||
|
||||
---
|
||||
|
||||
## 📝 코드 구조
|
||||
|
||||
### 다중 API 폴백 시스템
|
||||
```typescript
|
||||
// 1순위: 국토교통부 ITS API
|
||||
if (process.env.ITS_API_KEY) {
|
||||
try {
|
||||
// ITS API 호출
|
||||
return itsData;
|
||||
} catch {
|
||||
console.log('2순위 API로 전환');
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 한국도로공사 API
|
||||
try {
|
||||
// 한국도로공사 API 호출
|
||||
return exwayData;
|
||||
} catch {
|
||||
console.log('더미 데이터 사용');
|
||||
}
|
||||
|
||||
// 3순위: 더미 데이터
|
||||
return dummyData;
|
||||
```
|
||||
|
||||
### 파일 위치
|
||||
- 서비스: `backend-node/src/services/riskAlertService.ts`
|
||||
- 컨트롤러: `backend-node/src/controllers/riskAlertController.ts`
|
||||
- 라우트: `backend-node/src/routes/riskAlertRoutes.ts`
|
||||
|
||||
---
|
||||
|
||||
## 💡 현재 대시보드 위젯 데이터
|
||||
|
||||
### 리스크/알림 위젯
|
||||
```
|
||||
✅ 날씨특보: 14건 (실제 기상청 데이터)
|
||||
⚠️ 교통사고: 2건 (더미 데이터)
|
||||
⚠️ 도로공사: 2건 (더미 데이터)
|
||||
─────────────────────────
|
||||
총 18건의 알림
|
||||
```
|
||||
|
||||
### 개선 후 (ITS API 연동 시)
|
||||
```
|
||||
✅ 날씨특보: 14건 (실제 기상청 데이터)
|
||||
✅ 교통사고: N건 (실제 ITS 데이터)
|
||||
✅ 도로공사: N건 (실제 ITS 데이터)
|
||||
─────────────────────────
|
||||
총 N건의 알림 (모두 실시간!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
### 단기 (지금)
|
||||
- [x] 기상청 특보 API 연동 완료
|
||||
- [x] 한국은행 환율 API 연동 완료
|
||||
- [x] 다중 API 폴백 시스템 구축
|
||||
- [ ] 국토교통부 ITS API 신청
|
||||
|
||||
### 장기 (향후)
|
||||
- [ ] 서울시 TOPIS API 추가 (서울시 교통정보)
|
||||
- [ ] 경찰청 교통사고 정보 API (승인 필요)
|
||||
- [ ] 기상청 단기예보 API 추가
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의
|
||||
|
||||
### 한국도로공사
|
||||
- 전화: 054-811-4533 (컨텐츠 문의)
|
||||
- 전화: 070-8656-8771 (시스템 장애)
|
||||
|
||||
### 공공데이터포털
|
||||
- 웹사이트: https://www.data.go.kr/
|
||||
- 고객센터: 1661-0423
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-14
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 기상청 특보 작동 중, ITS API 연동 준비 완료
|
||||
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
|
||||
# 🔑 API 키 현황 및 연동 상태
|
||||
|
||||
## ✅ 완벽 작동 중
|
||||
|
||||
### 1. 기상청 API Hub
|
||||
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
- **상태**: ✅ 14건 실시간 특보 수신 중
|
||||
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
||||
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
||||
|
||||
### 2. 한국은행 환율 API
|
||||
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
|
||||
- **상태**: ✅ 환율 위젯 작동 중
|
||||
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 연동 대기 중
|
||||
|
||||
### 3. 한국도로공사 OpenOASIS API
|
||||
- **API 키**: `7820214492`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **문제**:
|
||||
- 발급 이메일에 사용법 없음
|
||||
- 매뉴얼에 상세 정보 없음
|
||||
- 테스트한 URL 모두 실패
|
||||
|
||||
**해결 방법**:
|
||||
```
|
||||
📞 한국도로공사 고객센터 문의
|
||||
|
||||
컨텐츠 문의: 054-811-4533
|
||||
시스템 장애: 070-8656-8771
|
||||
|
||||
문의 내용:
|
||||
"OpenOASIS API 인증키(7820214492)를 발급받았는데
|
||||
사용 방법과 엔드포인트 URL을 알려주세요.
|
||||
- 돌발상황정보 API
|
||||
- 교통사고 정보
|
||||
- 도로공사 정보"
|
||||
```
|
||||
|
||||
### 4. 국토교통부 ITS API
|
||||
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
|
||||
- **상태**: ❌ 엔드포인트 URL 불명
|
||||
- **승인 API**:
|
||||
- 교통소통정보
|
||||
- 돌발상황정보
|
||||
- CCTV 화상자료
|
||||
- 교통예측정보
|
||||
- 차량검지정보
|
||||
- 도로전광표지(VMS)
|
||||
- 주의운전구간
|
||||
- 가변형 속도제한표지(VSL)
|
||||
- 위험물질 운송차량 사고정보
|
||||
|
||||
**해결 방법**:
|
||||
```
|
||||
📞 ITS 국가교통정보센터 문의
|
||||
|
||||
전화: 1577-6782
|
||||
이메일: its@ex.co.kr
|
||||
|
||||
문의 내용:
|
||||
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
|
||||
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
||||
돌발상황정보 API의 정확한 URL과 파라미터를
|
||||
알려주세요."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 백엔드 연동 준비 완료
|
||||
|
||||
### 파일 위치
|
||||
- **서비스**: `backend-node/src/services/riskAlertService.ts`
|
||||
- **컨트롤러**: `backend-node/src/controllers/riskAlertController.ts`
|
||||
- **라우트**: `backend-node/src/routes/riskAlertRoutes.ts`
|
||||
|
||||
### 다중 API 폴백 시스템
|
||||
```typescript
|
||||
1순위: 국토교통부 ITS API (process.env.ITS_API_KEY)
|
||||
2순위: 한국도로공사 API (process.env.EXWAY_API_KEY)
|
||||
3순위: 더미 데이터 (현실적인 예시)
|
||||
```
|
||||
|
||||
### 연동 방법
|
||||
```bash
|
||||
# .env 파일에 추가
|
||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
||||
EXWAY_API_KEY=7820214492
|
||||
|
||||
# 백엔드 재시작
|
||||
docker restart pms-backend-mac
|
||||
|
||||
# 로그 확인
|
||||
docker logs pms-backend-mac --tail 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 현재 리스크/알림 시스템
|
||||
|
||||
```
|
||||
✅ 기상특보: 14건 (실시간 기상청 데이터)
|
||||
⚠️ 교통사고: 2건 (더미 데이터)
|
||||
⚠️ 도로공사: 2건 (더미 데이터)
|
||||
────────────────────────────
|
||||
총 18건의 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### 단기 (지금)
|
||||
- [x] 기상청 특보 API 연동 완료
|
||||
- [x] 한국은행 환율 API 연동 완료
|
||||
- [x] ITS/한국도로공사 API 키 발급 완료
|
||||
- [x] 다중 API 폴백 시스템 구축
|
||||
- [ ] **API 엔드포인트 URL 확인 (고객센터 문의)**
|
||||
|
||||
### 중기 (API URL 확인 후)
|
||||
- [ ] ITS API 연동 (즉시 가능)
|
||||
- [ ] 한국도로공사 API 연동 (즉시 가능)
|
||||
- [ ] 실시간 교통사고 데이터 표시
|
||||
- [ ] 실시간 도로공사 데이터 표시
|
||||
|
||||
### 장기 (추가 기능)
|
||||
- [ ] 서울시 TOPIS API 추가
|
||||
- [ ] CCTV 화상 자료 연동
|
||||
- [ ] 도로전광표지(VMS) 정보
|
||||
- [ ] 교통예측정보
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-14
|
||||
**상태**: 기상청 특보 작동 중, 교통정보 API URL 확인 필요
|
||||
|
||||
|
|
@ -15,6 +15,9 @@ RUN npm ci
|
|||
# 소스 코드 복사
|
||||
COPY . .
|
||||
|
||||
# Prisma 클라이언트 생성
|
||||
RUN npx prisma generate
|
||||
|
||||
# 개발 환경 설정
|
||||
ENV NODE_ENV=development
|
||||
|
||||
|
|
|
|||
|
|
@ -1,418 +0,0 @@
|
|||
# Phase 1: Raw Query 기반 구조 사용 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
Phase 1에서 구현한 Raw Query 기반 데이터베이스 아키텍처 사용 방법입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 구현된 모듈
|
||||
|
||||
### 1. **DatabaseManager** (`src/database/db.ts`)
|
||||
|
||||
PostgreSQL 연결 풀 기반 핵심 모듈
|
||||
|
||||
**주요 함수:**
|
||||
- `query<T>(sql, params)` - 기본 쿼리 실행
|
||||
- `queryOne<T>(sql, params)` - 단일 행 조회
|
||||
- `transaction(callback)` - 트랜잭션 실행
|
||||
- `getPool()` - 연결 풀 가져오기
|
||||
- `getPoolStatus()` - 연결 풀 상태 확인
|
||||
|
||||
### 2. **QueryBuilder** (`src/utils/queryBuilder.ts`)
|
||||
|
||||
동적 쿼리 생성 유틸리티
|
||||
|
||||
**주요 메서드:**
|
||||
- `QueryBuilder.select(tableName, options)` - SELECT 쿼리
|
||||
- `QueryBuilder.insert(tableName, data, options)` - INSERT 쿼리
|
||||
- `QueryBuilder.update(tableName, data, where, options)` - UPDATE 쿼리
|
||||
- `QueryBuilder.delete(tableName, where, options)` - DELETE 쿼리
|
||||
- `QueryBuilder.count(tableName, where)` - COUNT 쿼리
|
||||
- `QueryBuilder.exists(tableName, where)` - EXISTS 쿼리
|
||||
|
||||
### 3. **DatabaseValidator** (`src/utils/databaseValidator.ts`)
|
||||
|
||||
SQL Injection 방지 및 입력 검증
|
||||
|
||||
**주요 메서드:**
|
||||
- `validateTableName(tableName)` - 테이블명 검증
|
||||
- `validateColumnName(columnName)` - 컬럼명 검증
|
||||
- `validateWhereClause(where)` - WHERE 조건 검증
|
||||
- `sanitizeInput(input)` - 입력 값 Sanitize
|
||||
|
||||
### 4. **타입 정의** (`src/types/database.ts`)
|
||||
|
||||
TypeScript 타입 안전성 보장
|
||||
|
||||
---
|
||||
|
||||
## 🚀 사용 예제
|
||||
|
||||
### 1. 기본 쿼리 실행
|
||||
|
||||
```typescript
|
||||
import { query, queryOne } from '../database/db';
|
||||
|
||||
// 여러 행 조회
|
||||
const users = await query<User>(
|
||||
'SELECT * FROM users WHERE status = $1',
|
||||
['active']
|
||||
);
|
||||
|
||||
// 단일 행 조회
|
||||
const user = await queryOne<User>(
|
||||
'SELECT * FROM users WHERE user_id = $1',
|
||||
['user123']
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('사용자를 찾을 수 없습니다.');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. QueryBuilder 사용
|
||||
|
||||
#### SELECT
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
// 기본 SELECT
|
||||
const { query: sql, params } = QueryBuilder.select('users', {
|
||||
where: { status: 'active' },
|
||||
orderBy: 'created_at DESC',
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const users = await query(sql, params);
|
||||
|
||||
// 복잡한 SELECT (JOIN, WHERE, ORDER BY)
|
||||
const { query: sql2, params: params2 } = QueryBuilder.select('users', {
|
||||
columns: ['users.user_id', 'users.username', 'departments.dept_name'],
|
||||
joins: [
|
||||
{
|
||||
type: 'LEFT',
|
||||
table: 'departments',
|
||||
on: 'users.dept_id = departments.dept_id',
|
||||
},
|
||||
],
|
||||
where: { 'users.status': 'active' },
|
||||
orderBy: ['users.created_at DESC', 'users.username ASC'],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const result = await query(sql2, params2);
|
||||
```
|
||||
|
||||
#### INSERT
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
// 기본 INSERT
|
||||
const { query: sql, params } = QueryBuilder.insert(
|
||||
'users',
|
||||
{
|
||||
user_id: 'new_user',
|
||||
username: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
returning: ['id', 'user_id'],
|
||||
}
|
||||
);
|
||||
|
||||
const [newUser] = await query(sql, params);
|
||||
console.log('생성된 사용자 ID:', newUser.id);
|
||||
|
||||
// UPSERT (INSERT ... ON CONFLICT)
|
||||
const { query: sql2, params: params2 } = QueryBuilder.insert(
|
||||
'users',
|
||||
{
|
||||
user_id: 'user123',
|
||||
username: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
{
|
||||
onConflict: {
|
||||
columns: ['user_id'],
|
||||
action: 'DO UPDATE',
|
||||
updateSet: ['username', 'email'],
|
||||
},
|
||||
returning: ['*'],
|
||||
}
|
||||
);
|
||||
|
||||
const [upsertedUser] = await query(sql2, params2);
|
||||
```
|
||||
|
||||
#### UPDATE
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
const { query: sql, params } = QueryBuilder.update(
|
||||
'users',
|
||||
{
|
||||
username: 'Updated Name',
|
||||
email: 'updated@example.com',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
user_id: 'user123',
|
||||
},
|
||||
{
|
||||
returning: ['*'],
|
||||
}
|
||||
);
|
||||
|
||||
const [updatedUser] = await query(sql, params);
|
||||
```
|
||||
|
||||
#### DELETE
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
const { query: sql, params } = QueryBuilder.delete(
|
||||
'users',
|
||||
{
|
||||
user_id: 'user_to_delete',
|
||||
},
|
||||
{
|
||||
returning: ['user_id', 'username'],
|
||||
}
|
||||
);
|
||||
|
||||
const [deletedUser] = await query(sql, params);
|
||||
console.log('삭제된 사용자:', deletedUser.username);
|
||||
```
|
||||
|
||||
### 3. 트랜잭션 사용
|
||||
|
||||
```typescript
|
||||
import { transaction } from '../database/db';
|
||||
|
||||
// 복잡한 트랜잭션 처리
|
||||
const result = await transaction(async (client) => {
|
||||
// 1. 사용자 생성
|
||||
const userResult = await client.query(
|
||||
'INSERT INTO users (user_id, username, email) VALUES ($1, $2, $3) RETURNING id',
|
||||
['new_user', 'John', 'john@example.com']
|
||||
);
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
// 2. 역할 할당
|
||||
await client.query(
|
||||
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)',
|
||||
[userId, 'admin']
|
||||
);
|
||||
|
||||
// 3. 로그 생성
|
||||
await client.query(
|
||||
'INSERT INTO audit_logs (action, user_id, details) VALUES ($1, $2, $3)',
|
||||
['USER_CREATED', userId, JSON.stringify({ username: 'John' })]
|
||||
);
|
||||
|
||||
return { success: true, userId };
|
||||
});
|
||||
|
||||
console.log('트랜잭션 완료:', result);
|
||||
```
|
||||
|
||||
### 4. JSON 필드 쿼리 (JSONB)
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { QueryBuilder } from '../utils/queryBuilder';
|
||||
|
||||
// JSON 필드 쿼리 (config->>'type' = 'form')
|
||||
const { query: sql, params } = QueryBuilder.select('screen_management', {
|
||||
columns: ['*'],
|
||||
where: {
|
||||
company_code: 'COMPANY_001',
|
||||
"config->>'type'": 'form',
|
||||
},
|
||||
});
|
||||
|
||||
const screens = await query(sql, params);
|
||||
```
|
||||
|
||||
### 5. 동적 테이블 쿼리
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||
|
||||
async function queryDynamicTable(tableName: string, filters: Record<string, any>) {
|
||||
// 테이블명 검증 (SQL Injection 방지)
|
||||
if (!DatabaseValidator.validateTableName(tableName)) {
|
||||
throw new Error('유효하지 않은 테이블명입니다.');
|
||||
}
|
||||
|
||||
// WHERE 조건 검증
|
||||
if (!DatabaseValidator.validateWhereClause(filters)) {
|
||||
throw new Error('유효하지 않은 WHERE 조건입니다.');
|
||||
}
|
||||
|
||||
const { query: sql, params } = QueryBuilder.select(tableName, {
|
||||
where: filters,
|
||||
});
|
||||
|
||||
return await query(sql, params);
|
||||
}
|
||||
|
||||
// 사용 예
|
||||
const data = await queryDynamicTable('company_data_001', {
|
||||
status: 'active',
|
||||
region: 'Seoul',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 보안 고려사항
|
||||
|
||||
### 1. **항상 Parameterized Query 사용**
|
||||
|
||||
```typescript
|
||||
// ❌ 위험: SQL Injection 취약
|
||||
const userId = req.params.userId;
|
||||
const sql = `SELECT * FROM users WHERE user_id = '${userId}'`;
|
||||
const users = await query(sql);
|
||||
|
||||
// ✅ 안전: Parameterized Query
|
||||
const userId = req.params.userId;
|
||||
const users = await query('SELECT * FROM users WHERE user_id = $1', [userId]);
|
||||
```
|
||||
|
||||
### 2. **식별자 검증**
|
||||
|
||||
```typescript
|
||||
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||
|
||||
// 테이블명/컬럼명 검증
|
||||
if (!DatabaseValidator.validateTableName(tableName)) {
|
||||
throw new Error('유효하지 않은 테이블명입니다.');
|
||||
}
|
||||
|
||||
if (!DatabaseValidator.validateColumnName(columnName)) {
|
||||
throw new Error('유효하지 않은 컬럼명입니다.');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **입력 값 Sanitize**
|
||||
|
||||
```typescript
|
||||
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||
|
||||
const sanitizedData = DatabaseValidator.sanitizeInput(userInput);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 성능 최적화 팁
|
||||
|
||||
### 1. **연결 풀 모니터링**
|
||||
|
||||
```typescript
|
||||
import { getPoolStatus } from '../database/db';
|
||||
|
||||
const status = getPoolStatus();
|
||||
console.log('연결 풀 상태:', {
|
||||
total: status.totalCount,
|
||||
idle: status.idleCount,
|
||||
waiting: status.waitingCount,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. **배치 INSERT**
|
||||
|
||||
```typescript
|
||||
import { transaction } from '../database/db';
|
||||
|
||||
// 대량 데이터 삽입 시 트랜잭션 사용
|
||||
await transaction(async (client) => {
|
||||
for (const item of largeDataset) {
|
||||
await client.query('INSERT INTO items (name, value) VALUES ($1, $2)', [
|
||||
item.name,
|
||||
item.value,
|
||||
]);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. **인덱스 활용 쿼리**
|
||||
|
||||
```typescript
|
||||
// WHERE 절에 인덱스 컬럼 사용
|
||||
const { query: sql, params } = QueryBuilder.select('users', {
|
||||
where: {
|
||||
user_id: 'user123', // 인덱스 컬럼
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 실행
|
||||
|
||||
```bash
|
||||
# 테스트 실행
|
||||
npm test -- database.test.ts
|
||||
|
||||
# 특정 테스트만 실행
|
||||
npm test -- database.test.ts -t "QueryBuilder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 에러 핸들링
|
||||
|
||||
```typescript
|
||||
import { query } from '../database/db';
|
||||
|
||||
try {
|
||||
const users = await query('SELECT * FROM users WHERE status = $1', ['active']);
|
||||
return users;
|
||||
} catch (error: any) {
|
||||
console.error('쿼리 실행 실패:', error.message);
|
||||
|
||||
// PostgreSQL 에러 코드 확인
|
||||
if (error.code === '23505') {
|
||||
throw new Error('중복된 값이 존재합니다.');
|
||||
}
|
||||
|
||||
if (error.code === '23503') {
|
||||
throw new Error('외래 키 제약 조건 위반입니다.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계 (Phase 2)
|
||||
|
||||
Phase 1 기반 구조가 완성되었으므로, Phase 2에서는:
|
||||
|
||||
1. **screenManagementService.ts** 전환 (46개 호출)
|
||||
2. **tableManagementService.ts** 전환 (35개 호출)
|
||||
3. **dataflowService.ts** 전환 (31개 호출)
|
||||
|
||||
등 핵심 서비스를 Raw Query로 전환합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**버전**: 1.0.0
|
||||
**담당**: Backend Development Team
|
||||
|
|
@ -7,7 +7,8 @@ Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백
|
|||
- **Runtime**: Node.js ^20.10.0
|
||||
- **Framework**: Express ^4.18.2
|
||||
- **Language**: TypeScript ^5.3.3
|
||||
- **Database**: PostgreSQL ^8.11.3 (Raw Query with `pg`)
|
||||
- **ORM**: Prisma ^5.7.1
|
||||
- **Database**: PostgreSQL ^8.11.3
|
||||
- **Authentication**: JWT + Passport
|
||||
- **Testing**: Jest + Supertest
|
||||
|
||||
|
|
@ -16,9 +17,9 @@ Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백
|
|||
```
|
||||
backend-node/
|
||||
├── src/
|
||||
│ ├── database/ # 데이터베이스 유틸리티
|
||||
│ │ ├── db.ts # PostgreSQL Raw Query 헬퍼
|
||||
│ │ └── ...
|
||||
│ ├── config/ # 설정 파일
|
||||
│ │ ├── environment.ts
|
||||
│ │ └── database.ts
|
||||
│ ├── controllers/ # HTTP 요청 처리
|
||||
│ ├── services/ # 비즈니스 로직
|
||||
│ ├── middleware/ # Express 미들웨어
|
||||
|
|
@ -29,6 +30,9 @@ backend-node/
|
|||
│ │ └── common.ts
|
||||
│ ├── validators/ # 입력 검증 스키마
|
||||
│ └── app.ts # 애플리케이션 진입점
|
||||
├── prisma/
|
||||
│ └── schema.prisma # 데이터베이스 스키마
|
||||
├── tests/ # 테스트 파일
|
||||
├── logs/ # 로그 파일
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
|
|
@ -55,7 +59,13 @@ PORT=8080
|
|||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### 3. 개발 서버 실행
|
||||
### 3. Prisma 클라이언트 생성
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### 4. 개발 서버 실행
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
|
@ -70,7 +80,7 @@ npm start
|
|||
|
||||
## 📊 데이터베이스 스키마
|
||||
|
||||
PostgreSQL 데이터베이스를 직접 Raw Query로 사용합니다.
|
||||
기존 PostgreSQL 데이터베이스 스키마를 참고하여 Prisma 스키마를 설계했습니다.
|
||||
|
||||
### 핵심 테이블
|
||||
|
||||
|
|
@ -136,6 +146,7 @@ npm run test:watch
|
|||
- `npm test` - 테스트 실행
|
||||
- `npm run lint` - ESLint 검사
|
||||
- `npm run format` - Prettier 포맷팅
|
||||
- `npx prisma studio` - Prisma Studio 실행
|
||||
|
||||
## 🔧 개발 가이드
|
||||
|
||||
|
|
@ -149,9 +160,9 @@ npm run test:watch
|
|||
|
||||
### 데이터베이스 스키마 변경
|
||||
|
||||
1. SQL 마이그레이션 파일 작성 (`db/` 디렉토리)
|
||||
2. PostgreSQL에서 직접 실행
|
||||
3. 필요 시 TypeScript 타입 정의 업데이트 (`src/types/`)
|
||||
1. `prisma/schema.prisma` 수정
|
||||
2. `npx prisma generate` 실행
|
||||
3. `npx prisma migrate dev` 실행
|
||||
|
||||
## 📋 마이그레이션 체크리스트
|
||||
|
||||
|
|
@ -159,7 +170,7 @@ npm run test:watch
|
|||
|
||||
- [x] Node.js + TypeScript 프로젝트 설정
|
||||
- [x] 기존 데이터베이스 스키마 분석
|
||||
- [x] PostgreSQL Raw Query 시스템 구축
|
||||
- [x] Prisma 스키마 설계 및 마이그레이션
|
||||
- [x] 기본 인증 시스템 구현
|
||||
- [x] 에러 처리 및 로깅 설정
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
# 🔑 API 키 설정 가이드
|
||||
|
||||
## 빠른 시작 (신규 팀원용)
|
||||
|
||||
### 1. API 키 파일 복사
|
||||
```bash
|
||||
cd backend-node
|
||||
cp .env.shared .env
|
||||
```
|
||||
|
||||
### 2. 끝!
|
||||
- `.env.shared` 파일에 **팀 공유 API 키**가 이미 들어있습니다
|
||||
- 그대로 복사해서 사용하면 됩니다
|
||||
- 추가 발급 필요 없음!
|
||||
|
||||
---
|
||||
|
||||
## 📋 포함된 API 키
|
||||
|
||||
### ✅ 한국은행 환율 API
|
||||
- 용도: 환율 정보 조회
|
||||
- 키: `OXIGPQXH68NUKVKL5KT9`
|
||||
|
||||
### ✅ 기상청 API Hub
|
||||
- 용도: 날씨특보, 기상정보
|
||||
- 키: `ogdXr2e9T4iHV69nvV-IwA`
|
||||
|
||||
### ✅ ITS 국가교통정보센터
|
||||
- 용도: 교통사고, 도로공사 정보
|
||||
- 키: `d6b9befec3114d648284674b8fddcc32`
|
||||
|
||||
### ✅ 한국도로공사 OpenOASIS
|
||||
- 용도: 고속도로 교통정보
|
||||
- 키: `7820214492`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### Git 관리
|
||||
```bash
|
||||
✅ .env.shared → Git에 커밋됨 (팀 공유용)
|
||||
❌ .env → Git에 커밋 안 됨 (개인 설정)
|
||||
```
|
||||
|
||||
### 보안
|
||||
- **팀 내부 프로젝트**이므로 키 공유가 안전합니다
|
||||
- 외부 공개 프로젝트라면 각자 발급받아야 합니다
|
||||
|
||||
---
|
||||
|
||||
## 🚀 서버 시작
|
||||
|
||||
```bash
|
||||
# 1. API 키 설정 (최초 1회만)
|
||||
cp .env.shared .env
|
||||
|
||||
# 2. 서버 시작
|
||||
npm run dev
|
||||
|
||||
# 또는 Docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 트러블슈팅
|
||||
|
||||
### `.env` 파일이 없다는 오류
|
||||
```bash
|
||||
# 해결: .env.shared를 복사
|
||||
cp .env.shared .env
|
||||
```
|
||||
|
||||
### API 호출이 실패함
|
||||
```bash
|
||||
# 1. .env 파일 확인
|
||||
cat .env
|
||||
|
||||
# 2. API 키가 제대로 복사되었는지 확인
|
||||
# 3. 서버 재시작
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**팀원 여러분, `.env.shared`를 복사해서 사용하세요!** 👍
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
const { Client } = require("pg");
|
||||
require("dotenv/config");
|
||||
|
||||
async function checkActualPassword() {
|
||||
const client = new Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("✅ 데이터베이스 연결 성공");
|
||||
|
||||
// 실제 저장된 비밀번호 확인 (암호화된 상태)
|
||||
const passwordResult = await client.query(`
|
||||
SELECT user_id, user_name, user_password, status
|
||||
FROM user_info
|
||||
WHERE user_id = 'kkh'
|
||||
`);
|
||||
console.log("🔐 사용자 비밀번호 정보:", passwordResult.rows);
|
||||
|
||||
// 다른 사용자도 확인
|
||||
const otherUsersResult = await client.query(`
|
||||
SELECT user_id, user_name, user_password, status
|
||||
FROM user_info
|
||||
WHERE user_password IS NOT NULL
|
||||
AND user_password != ''
|
||||
LIMIT 3
|
||||
`);
|
||||
console.log("👥 다른 사용자 비밀번호 정보:", otherUsersResult.rows);
|
||||
} catch (error) {
|
||||
console.error("❌ 오류 발생:", error);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkActualPassword();
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
const { Client } = require("pg");
|
||||
require("dotenv/config");
|
||||
|
||||
async function checkPasswordField() {
|
||||
const client = new Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("✅ 데이터베이스 연결 성공");
|
||||
|
||||
// user_info 테이블의 컬럼 정보 확인
|
||||
const columnsResult = await client.query(`
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user_info'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
console.log("📋 user_info 테이블 컬럼:", columnsResult.rows);
|
||||
|
||||
// 비밀번호 관련 컬럼 확인
|
||||
const passwordResult = await client.query(`
|
||||
SELECT user_id, user_name, user_password, password, status
|
||||
FROM user_info
|
||||
WHERE user_id = 'kkh'
|
||||
`);
|
||||
console.log("🔐 사용자 비밀번호 정보:", passwordResult.rows);
|
||||
} catch (error) {
|
||||
console.error("❌ 오류 발생:", error);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkPasswordField();
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function cleanScreenTables() {
|
||||
try {
|
||||
console.log("🧹 기존 화면관리 테이블들을 정리합니다...");
|
||||
|
||||
// 기존 테이블들을 순서대로 삭제 (외래키 제약조건 때문에 순서 중요)
|
||||
await prisma.$executeRaw`DROP VIEW IF EXISTS v_screen_definitions_with_auth CASCADE`;
|
||||
console.log("✅ 뷰 삭제 완료");
|
||||
|
||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_menu_assignments CASCADE`;
|
||||
console.log("✅ screen_menu_assignments 테이블 삭제 완료");
|
||||
|
||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_widgets CASCADE`;
|
||||
console.log("✅ screen_widgets 테이블 삭제 완료");
|
||||
|
||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_layouts CASCADE`;
|
||||
console.log("✅ screen_layouts 테이블 삭제 완료");
|
||||
|
||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_templates CASCADE`;
|
||||
console.log("✅ screen_templates 테이블 삭제 완료");
|
||||
|
||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_definitions CASCADE`;
|
||||
console.log("✅ screen_definitions 테이블 삭제 완료");
|
||||
|
||||
console.log("🎉 모든 화면관리 테이블 정리 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 정리 중 오류 발생:", error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
cleanScreenTables();
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
const { Client } = require("pg");
|
||||
require("dotenv/config");
|
||||
|
||||
async function createTestUser() {
|
||||
const client = new Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("✅ 데이터베이스 연결 성공");
|
||||
|
||||
// 테스트용 사용자 생성 (MD5 해시: admin123)
|
||||
const testUser = {
|
||||
user_id: "admin",
|
||||
user_name: "테스트 관리자",
|
||||
user_password: "f21b1ce8b08dc955bd4afff71b3db1fc", // admin123의 MD5 해시
|
||||
status: "active",
|
||||
company_code: "ILSHIN",
|
||||
data_type: "PLM",
|
||||
};
|
||||
|
||||
// 기존 사용자 확인
|
||||
const existingUser = await client.query(
|
||||
"SELECT user_id FROM user_info WHERE user_id = $1",
|
||||
[testUser.user_id]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
console.log("⚠️ 테스트 사용자가 이미 존재합니다:", testUser.user_id);
|
||||
|
||||
// 기존 사용자 정보 업데이트
|
||||
await client.query(
|
||||
`
|
||||
UPDATE user_info
|
||||
SET user_name = $1, user_password = $2, status = $3
|
||||
WHERE user_id = $4
|
||||
`,
|
||||
[
|
||||
testUser.user_name,
|
||||
testUser.user_password,
|
||||
testUser.status,
|
||||
testUser.user_id,
|
||||
]
|
||||
);
|
||||
|
||||
console.log("✅ 테스트 사용자 정보 업데이트 완료");
|
||||
} else {
|
||||
// 새 사용자 생성
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO user_info (user_id, user_name, user_password, status, company_code, data_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`,
|
||||
[
|
||||
testUser.user_id,
|
||||
testUser.user_name,
|
||||
testUser.user_password,
|
||||
testUser.status,
|
||||
testUser.company_code,
|
||||
testUser.data_type,
|
||||
]
|
||||
);
|
||||
|
||||
console.log("✅ 테스트 사용자 생성 완료");
|
||||
}
|
||||
|
||||
// 생성된 사용자 확인
|
||||
const createdUser = await client.query(
|
||||
"SELECT user_id, user_name, status FROM user_info WHERE user_id = $1",
|
||||
[testUser.user_id]
|
||||
);
|
||||
|
||||
console.log("👤 생성된 사용자:", createdUser.rows[0]);
|
||||
} catch (error) {
|
||||
console.error("❌ 오류 발생:", error);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
createTestUser();
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "773568c7-0fc8-403d-ace2-01a11fae7189",
|
||||
"customerName": "김철수",
|
||||
"customerPhone": "010-1234-5678",
|
||||
"pickupLocation": "서울시 강남구 역삼동 123",
|
||||
"dropoffLocation": "경기도 성남시 분당구 정자동 456",
|
||||
"scheduledTime": "2025-10-14T10:03:32.556Z",
|
||||
"vehicleType": "truck",
|
||||
"cargoType": "전자제품",
|
||||
"weight": 500,
|
||||
"status": "accepted",
|
||||
"priority": "urgent",
|
||||
"createdAt": "2025-10-14T08:03:32.556Z",
|
||||
"updatedAt": "2025-10-14T08:06:45.073Z",
|
||||
"estimatedCost": 150000,
|
||||
"acceptedAt": "2025-10-14T08:06:45.073Z"
|
||||
},
|
||||
{
|
||||
"id": "0751b297-18df-42c0-871c-85cded1f6dae",
|
||||
"customerName": "이영희",
|
||||
"customerPhone": "010-9876-5432",
|
||||
"pickupLocation": "서울시 송파구 잠실동 789",
|
||||
"dropoffLocation": "인천시 남동구 구월동 321",
|
||||
"scheduledTime": "2025-10-14T12:03:32.556Z",
|
||||
"vehicleType": "van",
|
||||
"cargoType": "가구",
|
||||
"weight": 300,
|
||||
"status": "pending",
|
||||
"priority": "normal",
|
||||
"createdAt": "2025-10-14T07:53:32.556Z",
|
||||
"updatedAt": "2025-10-14T07:53:32.556Z",
|
||||
"estimatedCost": 80000
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,80 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
|
||||
"title": "연동되어주려무니",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "in_progress",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-21T15:21",
|
||||
"createdAt": "2025-10-20T06:21:19.817Z",
|
||||
"updatedAt": "2025-10-20T09:00:26.948Z",
|
||||
"isUrgent": false,
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"id": "c8292b4d-bb45-487c-aa29-55b78580b837",
|
||||
"title": "오늘의 힐일",
|
||||
"description": "이거 데이터베이스랑 연결하기",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-23T14:04",
|
||||
"createdAt": "2025-10-23T05:04:50.249Z",
|
||||
"updatedAt": "2025-10-23T05:04:50.249Z",
|
||||
"isUrgent": false,
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"id": "2c7f90a3-947c-4693-8525-7a2a707172c0",
|
||||
"title": "테스트용 일정",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "low",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-16T18:16",
|
||||
"createdAt": "2025-10-23T05:13:14.076Z",
|
||||
"updatedAt": "2025-10-23T05:13:14.076Z",
|
||||
"isUrgent": false,
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"id": "499feff6-92c7-45a9-91fa-ca727edf90f2",
|
||||
"title": "ㅁSdf",
|
||||
"description": "asdfsdfs",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:15:38.430Z",
|
||||
"updatedAt": "2025-10-23T05:15:38.430Z",
|
||||
"isUrgent": false,
|
||||
"order": 6
|
||||
},
|
||||
{
|
||||
"id": "166c3910-9908-457f-8c72-8d0183f12e2f",
|
||||
"title": "ㅎㄹㅇㄴ",
|
||||
"description": "ㅎㄹㅇㄴ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:21:01.515Z",
|
||||
"updatedAt": "2025-10-23T05:21:01.515Z",
|
||||
"isUrgent": false,
|
||||
"order": 7
|
||||
},
|
||||
{
|
||||
"id": "bfa9d476-bb98-41d5-9d74-b016be011bba",
|
||||
"title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ",
|
||||
"description": "ㅁㄴㅇㄹㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"assignedTo": "",
|
||||
"dueDate": "",
|
||||
"createdAt": "2025-10-23T05:21:25.781Z",
|
||||
"updatedAt": "2025-10-23T05:21:25.781Z",
|
||||
"isUrgent": false,
|
||||
"order": 8
|
||||
}
|
||||
]
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts,json",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "node -r ts-node/register/transpile-only src/app.ts"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,78 +11,69 @@
|
|||
"test:watch": "jest --watch",
|
||||
"lint": "eslint src/ --ext .ts",
|
||||
"lint:fix": "eslint src/ --ext .ts --fix",
|
||||
"format": "prettier --write src/"
|
||||
"format": "prettier --write src/",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "prisma db seed"
|
||||
},
|
||||
"keywords": [
|
||||
"plm",
|
||||
"nodejs",
|
||||
"typescript",
|
||||
"express",
|
||||
"postgresql"
|
||||
"prisma"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.5",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"eslint": "^8.55.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "^3.1.0",
|
||||
"supertest": "^6.3.4",
|
||||
"prisma": "^6.16.2",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* 외부 DB 연결 정보 추가 스크립트
|
||||
* 비밀번호를 암호화하여 안전하게 저장
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function addExternalDbConnection() {
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
// 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용)
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
// 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력)
|
||||
const externalDbConnections = [
|
||||
{
|
||||
name: "운영_외부_PostgreSQL",
|
||||
description: "운영용 외부 PostgreSQL 데이터베이스",
|
||||
dbType: "postgresql",
|
||||
host: "39.117.244.52",
|
||||
port: 11132,
|
||||
databaseName: "plm",
|
||||
username: "postgres",
|
||||
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
},
|
||||
// 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가
|
||||
// {
|
||||
// name: "테스트_MySQL",
|
||||
// description: "테스트용 MySQL 데이터베이스",
|
||||
// dbType: "mysql",
|
||||
// host: "test-mysql.example.com",
|
||||
// port: 3306,
|
||||
// databaseName: "testdb",
|
||||
// username: "testuser",
|
||||
// password: "testpass",
|
||||
// sslEnabled: true,
|
||||
// isActive: true,
|
||||
// },
|
||||
];
|
||||
|
||||
for (const conn of externalDbConnections) {
|
||||
// 비밀번호 암호화
|
||||
const encryptedPassword = encryption.encrypt(conn.password);
|
||||
|
||||
// 중복 체크 (이름 기준)
|
||||
const existingResult = await pool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[conn.name]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
console.log(
|
||||
`⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})`
|
||||
);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await pool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
conn.description,
|
||||
conn.dbType,
|
||||
conn.host,
|
||||
conn.port,
|
||||
conn.databaseName,
|
||||
conn.username,
|
||||
encryptedPassword,
|
||||
conn.sslEnabled,
|
||||
conn.isActive,
|
||||
conn.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await pool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
conn.name,
|
||||
conn.description,
|
||||
conn.dbType,
|
||||
conn.host,
|
||||
conn.port,
|
||||
conn.databaseName,
|
||||
conn.username,
|
||||
encryptedPassword,
|
||||
conn.sslEnabled,
|
||||
conn.isActive,
|
||||
]
|
||||
);
|
||||
console.log(
|
||||
`✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})`
|
||||
);
|
||||
}
|
||||
|
||||
// 연결 테스트
|
||||
console.log(`🔍 연결 테스트 중: ${conn.name}...`);
|
||||
const testPool = new Pool({
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
database: conn.databaseName,
|
||||
user: conn.username,
|
||||
password: conn.password,
|
||||
ssl: conn.sslEnabled,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await testPool.connect();
|
||||
await client.query("SELECT 1");
|
||||
client.release();
|
||||
console.log(`✅ 연결 테스트 성공: ${conn.name}`);
|
||||
} catch (testError: any) {
|
||||
console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message);
|
||||
} finally {
|
||||
await testPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 외부 DB 연결 정보 추가 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
addExternalDbConnection()
|
||||
.then(() => {
|
||||
console.log("✅ 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/**
|
||||
* dashboards 테이블 구조 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkDashboardStructure() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboards'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboards 테이블 컬럼:\n');
|
||||
columns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
// 샘플 데이터 조회
|
||||
console.log('\n📊 샘플 데이터 (첫 1개):');
|
||||
const sample = await client.query(`
|
||||
SELECT * FROM dashboards LIMIT 1
|
||||
`);
|
||||
|
||||
if (sample.rows.length > 0) {
|
||||
console.log(JSON.stringify(sample.rows[0], null, 2));
|
||||
} else {
|
||||
console.log('❌ 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// dashboard_elements 테이블도 확인
|
||||
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
|
||||
|
||||
const elemColumns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboard_elements'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboard_elements 테이블 컬럼:\n');
|
||||
elemColumns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkDashboardStructure();
|
||||
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* 데이터베이스 테이블 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkTables() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 데이터베이스 테이블 확인 중...\n');
|
||||
|
||||
// 테이블 목록 조회
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
|
||||
result.rows.forEach((row, index) => {
|
||||
console.log(`${index + 1}. ${row.table_name}`);
|
||||
});
|
||||
|
||||
// dashboard 관련 테이블 검색
|
||||
console.log('\n🔎 dashboard 관련 테이블:');
|
||||
const dashboardTables = result.rows.filter(row =>
|
||||
row.table_name.toLowerCase().includes('dashboard')
|
||||
);
|
||||
|
||||
if (dashboardTables.length === 0) {
|
||||
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
|
||||
} else {
|
||||
dashboardTables.forEach(row => {
|
||||
console.log(`✅ ${row.table_name}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkTables();
|
||||
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/**
|
||||
* 비밀번호 암호화 유틸리티
|
||||
*/
|
||||
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
const password = process.argv[2] || "ph0909!!";
|
||||
|
||||
const encrypted = encryption.encrypt(password);
|
||||
console.log("\n원본 비밀번호:", password);
|
||||
console.log("암호화된 비밀번호:", encrypted);
|
||||
console.log("\n복호화 테스트:", encryption.decrypt(encrypted));
|
||||
console.log("✅ 암호화/복호화 성공\n");
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import { query } from "../src/database/db";
|
||||
import { logger } from "../src/utils/logger";
|
||||
|
||||
/**
|
||||
* input_type을 web_type으로 마이그레이션하는 스크립트
|
||||
*
|
||||
* 목적:
|
||||
* - column_labels 테이블의 input_type 값을 읽어서
|
||||
* - 해당하는 기본 web_type 값으로 변환
|
||||
* - web_type이 null인 경우에만 업데이트
|
||||
*/
|
||||
|
||||
// input_type → 기본 web_type 매핑
|
||||
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
|
||||
text: "text", // 일반 텍스트
|
||||
number: "number", // 정수
|
||||
date: "date", // 날짜
|
||||
code: "code", // 코드 선택박스
|
||||
entity: "entity", // 엔티티 참조
|
||||
select: "select", // 선택박스
|
||||
checkbox: "checkbox", // 체크박스
|
||||
radio: "radio", // 라디오버튼
|
||||
direct: "text", // direct는 text로 매핑
|
||||
};
|
||||
|
||||
async function migrateInputTypeToWebType() {
|
||||
try {
|
||||
logger.info("=".repeat(60));
|
||||
logger.info("input_type → web_type 마이그레이션 시작");
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 1. 현재 상태 확인
|
||||
const stats = await query<{
|
||||
total: string;
|
||||
has_input_type: string;
|
||||
has_web_type: string;
|
||||
needs_migration: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
|
||||
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const stat = stats[0];
|
||||
logger.info("\n📊 현재 상태:");
|
||||
logger.info(` - 전체 컬럼: ${stat.total}개`);
|
||||
logger.info(` - input_type 있음: ${stat.has_input_type}개`);
|
||||
logger.info(` - web_type 있음: ${stat.has_web_type}개`);
|
||||
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`);
|
||||
|
||||
if (parseInt(stat.needs_migration) === 0) {
|
||||
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. input_type별 분포 확인
|
||||
const distribution = await query<{
|
||||
input_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
input_type,
|
||||
COUNT(*) as count
|
||||
FROM column_labels
|
||||
WHERE input_type IS NOT NULL AND web_type IS NULL
|
||||
GROUP BY input_type
|
||||
ORDER BY input_type`
|
||||
);
|
||||
|
||||
logger.info("\n📋 input_type별 분포:");
|
||||
distribution.forEach((item) => {
|
||||
const webType =
|
||||
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
|
||||
logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`);
|
||||
});
|
||||
|
||||
// 3. 마이그레이션 실행
|
||||
logger.info("\n🔄 마이그레이션 실행 중...");
|
||||
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
|
||||
const result = await query(
|
||||
`UPDATE column_labels
|
||||
SET
|
||||
web_type = $1,
|
||||
updated_date = NOW()
|
||||
WHERE input_type = $2
|
||||
AND web_type IS NULL
|
||||
RETURNING id, table_name, column_name`,
|
||||
[webType, inputType]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.info(
|
||||
` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트`
|
||||
);
|
||||
totalUpdated += result.length;
|
||||
|
||||
// 처음 5개만 출력
|
||||
result.slice(0, 5).forEach((row: any) => {
|
||||
logger.info(` - ${row.table_name}.${row.column_name}`);
|
||||
});
|
||||
if (result.length > 5) {
|
||||
logger.info(` ... 외 ${result.length - 5}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 결과 확인
|
||||
const afterStats = await query<{
|
||||
total: string;
|
||||
has_web_type: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const afterStat = afterStats[0];
|
||||
|
||||
logger.info("\n" + "=".repeat(60));
|
||||
logger.info("✅ 마이그레이션 완료!");
|
||||
logger.info("=".repeat(60));
|
||||
logger.info(`📊 최종 통계:`);
|
||||
logger.info(` - 전체 컬럼: ${afterStat.total}개`);
|
||||
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`);
|
||||
logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`);
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 5. 샘플 데이터 출력
|
||||
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
|
||||
const samples = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
web_type: string;
|
||||
detail_settings: string;
|
||||
}>(
|
||||
`SELECT
|
||||
column_name,
|
||||
input_type,
|
||||
web_type,
|
||||
detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name = 'check_report_mng'
|
||||
ORDER BY column_name
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
samples.forEach((sample) => {
|
||||
logger.info(
|
||||
` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}`
|
||||
);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error("❌ 마이그레이션 실패:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
migrateInputTypeToWebType();
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* SQL 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-migration.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// DATABASE_URL에서 연결 정보 파싱
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
// 데이터베이스 연결 설정
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function runMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔄 마이그레이션 시작...\n');
|
||||
|
||||
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
|
||||
const sqlPath = '/tmp/migration.sql';
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('📄 SQL 파일 로드 완료');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
// SQL 실행
|
||||
await client.query(sql);
|
||||
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('❌ 마이그레이션 실패:');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error(error);
|
||||
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
runMigration();
|
||||
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
/**
|
||||
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
||||
* READ-ONLY: SELECT 쿼리만 실행
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import mysql from "mysql2/promise";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function testDigitalTwinDb() {
|
||||
// 내부 DB 연결 (연결 정보 저장용)
|
||||
const internalPool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
||||
|
||||
// 디지털 트윈 외부 DB 연결 정보
|
||||
const digitalTwinConnection = {
|
||||
name: "디지털트윈_DO_DY",
|
||||
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
||||
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
||||
host: "1.240.13.83",
|
||||
port: 4307,
|
||||
databaseName: "DO_DY",
|
||||
username: "root",
|
||||
password: "pohangms619!#",
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
console.log("📝 연결 정보:");
|
||||
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
||||
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
||||
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
||||
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
||||
|
||||
// 1. 외부 DB 직접 연결 테스트
|
||||
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
||||
|
||||
const externalConnection = await mysql.createConnection({
|
||||
host: digitalTwinConnection.host,
|
||||
port: digitalTwinConnection.port,
|
||||
database: digitalTwinConnection.databaseName,
|
||||
user: digitalTwinConnection.username,
|
||||
password: digitalTwinConnection.password,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log("✅ 외부 DB 연결 성공!\n");
|
||||
|
||||
// 2. SELECT 쿼리 실행
|
||||
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
SKUMKEY -- 제품번호
|
||||
, SKUDESC -- 자재명
|
||||
, SKUTHIC -- 두께
|
||||
, SKUWIDT -- 폭
|
||||
, SKULENG -- 길이
|
||||
, SKUWEIG -- 중량
|
||||
, STOTQTY -- 수량
|
||||
, SUOMKEY -- 단위
|
||||
FROM DO_DY.WSTKKY
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const [rows] = await externalConnection.execute(query);
|
||||
|
||||
console.log("✅ 쿼리 실행 성공!\n");
|
||||
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
||||
rows.slice(0, 3).forEach((row: any, index: number) => {
|
||||
console.log(`[${index + 1}]`);
|
||||
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
||||
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
||||
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
||||
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
||||
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
||||
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
||||
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
||||
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
||||
});
|
||||
|
||||
// 전체 데이터 JSON 출력
|
||||
console.log("📄 전체 데이터 (JSON):");
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
await externalConnection.end();
|
||||
|
||||
// 3. 내부 DB에 연결 정보 저장
|
||||
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
||||
|
||||
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
||||
|
||||
// 중복 체크
|
||||
const existingResult = await internalPool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[digitalTwinConnection.name]
|
||||
);
|
||||
|
||||
let connectionId: number;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
connectionId = existingResult.rows[0].id;
|
||||
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await internalPool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
digitalTwinConnection.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await internalPool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
digitalTwinConnection.name,
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
]
|
||||
);
|
||||
connectionId = result.rows[0].id;
|
||||
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 테스트 완료!");
|
||||
console.log(`\n📌 연결 ID: ${connectionId}`);
|
||||
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("\n❌ 오류 발생:", error.message);
|
||||
console.error("상세 정보:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await internalPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testDigitalTwinDb()
|
||||
.then(() => {
|
||||
console.log("\n🎉 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* 마이그레이션 검증 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function verifyMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 마이그레이션 결과 검증 중...\n');
|
||||
|
||||
// 전체 요소 수
|
||||
const total = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements
|
||||
`);
|
||||
|
||||
// 새로운 subtype별 개수
|
||||
const mapV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
|
||||
`);
|
||||
|
||||
const chart = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
|
||||
`);
|
||||
|
||||
const listV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
|
||||
`);
|
||||
|
||||
const metricV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
|
||||
`);
|
||||
|
||||
const alertV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
|
||||
`);
|
||||
|
||||
// 테스트 subtype 남아있는지 확인
|
||||
const remaining = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
|
||||
`);
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📊 마이그레이션 결과 요약');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`전체 요소 수: ${total.rows[0].count}`);
|
||||
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
|
||||
console.log(`chart: ${chart.rows[0].count}`);
|
||||
console.log(`list-v2: ${listV2.rows[0].count}`);
|
||||
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
|
||||
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
|
||||
console.log('');
|
||||
|
||||
if (parseInt(remaining.rows[0].count) > 0) {
|
||||
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
|
||||
} else {
|
||||
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
|
||||
}
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
|
||||
console.log('');
|
||||
console.log('다음 단계:');
|
||||
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
|
||||
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
|
||||
console.log('3. 문제가 발생하면 백업에서 복원하세요');
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
verifyMigration();
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
const { Client } = require("pg");
|
||||
|
||||
async function createTestUser() {
|
||||
const client = new Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("✅ 데이터베이스 연결 성공");
|
||||
|
||||
// 테스트용 사용자 생성
|
||||
await client.query(`
|
||||
INSERT INTO user_info (user_id, user_name, user_password, status, company_code, data_type)
|
||||
VALUES ('admin', '테스트 관리자', 'f21b1ce8b08dc955bd4afff71b3db1fc', 'active', 'ILSHIN', 'PLM')
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
user_name = EXCLUDED.user_name,
|
||||
user_password = EXCLUDED.user_password,
|
||||
status = EXCLUDED.status
|
||||
`);
|
||||
|
||||
console.log("✅ 테스트 사용자 생성/업데이트 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 오류 발생:", error);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
createTestUser();
|
||||
|
|
@ -8,7 +8,6 @@ import path from "path";
|
|||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
|
|
@ -29,15 +28,9 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
|
|||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
import layoutRoutes from "./routes/layoutRoutes";
|
||||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes";
|
||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
||||
import screenFileRoutes from "./routes/screenFileRoutes";
|
||||
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
|
|
@ -50,39 +43,6 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
|||
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -110,30 +70,21 @@ app.use(compression());
|
|||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
|
||||
app.options("/uploads/*", (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// 정적 파일 서빙 (업로드된 파일들)
|
||||
app.use(
|
||||
"/uploads",
|
||||
(req, res, next) => {
|
||||
// 모든 정적 파일 요청에 CORS 헤더 추가
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads"))
|
||||
express.static(path.join(process.cwd(), "uploads"), {
|
||||
setHeaders: (res, path) => {
|
||||
// 파일 서빙 시 CORS 헤더 설정
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
|
|
@ -177,10 +128,6 @@ const limiter = rateLimit({
|
|||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
|
|
@ -198,7 +145,6 @@ app.use("/api/multilang", multilangRoutes);
|
|||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
@ -210,20 +156,13 @@ app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
|||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||
app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||
|
|
@ -232,37 +171,6 @@ app.use("/api/entity-reference", entityReferenceRoutes);
|
|||
app.use("/api/external-calls", externalCallRoutes);
|
||||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
|
||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -290,63 +198,13 @@ app.listen(PORT, HOST, async () => {
|
|||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||
|
||||
// 데이터베이스 마이그레이션 실행
|
||||
try {
|
||||
const {
|
||||
runDashboardMigration,
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
await runDtgManagementLogMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
|
||||
// 배치 스케줄러 초기화
|
||||
try {
|
||||
await BatchSchedulerService.initializeScheduler();
|
||||
await BatchSchedulerService.initialize();
|
||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
}
|
||||
|
||||
// 리스크/알림 자동 갱신 시작
|
||||
try {
|
||||
const { RiskAlertCacheService } = await import(
|
||||
"./services/riskAlertCacheService"
|
||||
);
|
||||
const cacheService = RiskAlertCacheService.getInstance();
|
||||
cacheService.startAutoRefresh();
|
||||
logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
|
||||
}
|
||||
|
||||
// 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
|
||||
try {
|
||||
const cron = await import("node-cron");
|
||||
const { mailSentHistoryService } = await import(
|
||||
"./services/mailSentHistoryService"
|
||||
);
|
||||
|
||||
cron.schedule("0 2 * * *", async () => {
|
||||
try {
|
||||
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
|
||||
const deletedCount =
|
||||
await mailSentHistoryService.cleanupOldDeletedMails();
|
||||
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
|
||||
} catch (error) {
|
||||
logger.error("❌ 메일 자동 삭제 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import config from "./environment";
|
||||
|
||||
// Prisma 클라이언트 생성 함수
|
||||
function createPrismaClient() {
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: config.databaseUrl,
|
||||
},
|
||||
},
|
||||
log: config.debug ? ["query", "info", "warn", "error"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
// 단일 인스턴스 생성
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
// 데이터베이스 연결 테스트
|
||||
async function testConnection() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터베이스 연결 실패:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 애플리케이션 종료 시 연결 해제
|
||||
process.on("beforeExit", async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 초기 연결 테스트 (개발 환경에서만)
|
||||
if (config.nodeEnv === "development") {
|
||||
testConnection();
|
||||
}
|
||||
|
||||
// 기본 내보내기
|
||||
export = prisma;
|
||||
|
|
@ -75,8 +75,6 @@ const getCorsOrigin = (): string[] | boolean => {
|
|||
"http://localhost:9771", // 로컬 개발 환경
|
||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||
"https://v1.vexplor.com", // 운영 프론트엔드
|
||||
"https://api.vexplor.com", // 운영 백엔드
|
||||
];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
|
||||
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/uploads/mail-attachments'
|
||||
: path.join(process.cwd(), 'uploads', 'mail-attachments');
|
||||
|
||||
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
try {
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메일 첨부파일 디렉토리 생성 실패:', error);
|
||||
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
||||
}
|
||||
|
||||
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
|
||||
function normalizeFileName(filename: string): string {
|
||||
if (!filename) return filename;
|
||||
|
||||
try {
|
||||
// NFC 정규화만 수행 (복잡한 디코딩 제거)
|
||||
return filename.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.error(`Failed to normalize filename: ${filename}`, error);
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 저장 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
try {
|
||||
// 파일명 정규화 (한글-분석.txt 방식)
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
|
||||
console.log('File upload - Processing:', {
|
||||
original: file.originalname,
|
||||
originalHex: Buffer.from(file.originalname).toString('hex'),
|
||||
});
|
||||
|
||||
// UUID + 확장자로 유니크한 파일명 생성
|
||||
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const filename = `${uniqueId}${ext}`;
|
||||
|
||||
console.log('Generated filename:', {
|
||||
original: file.originalname,
|
||||
generated: filename,
|
||||
});
|
||||
|
||||
cb(null, filename);
|
||||
} catch (error) {
|
||||
console.error('Filename processing error:', error);
|
||||
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
|
||||
cb(null, fallbackFilename);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 파일 필터 (허용할 파일 타입)
|
||||
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
|
||||
try {
|
||||
// NFD를 NFC로 정규화만 수행
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize filename in fileFilter:', error);
|
||||
}
|
||||
|
||||
// 위험한 파일 확장자 차단
|
||||
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
console.log(`❌ 차단된 파일 타입: ${ext}`);
|
||||
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// Multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 첨부파일 정보 추출 헬퍼
|
||||
export interface AttachmentInfo {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
|
||||
return files.map((file) => ({
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
mimetype: file.mimetype,
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -1,884 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import https from "https";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { DashboardService } from "../services/DashboardService";
|
||||
import {
|
||||
CreateDashboardRequest,
|
||||
UpdateDashboardRequest,
|
||||
DashboardListQuery,
|
||||
} from "../types/dashboard";
|
||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
|
||||
|
||||
/**
|
||||
* 대시보드 컨트롤러
|
||||
* - REST API 엔드포인트 처리
|
||||
* - 요청 검증 및 응답 포맷팅
|
||||
*/
|
||||
export class DashboardController {
|
||||
/**
|
||||
* 대시보드 생성
|
||||
* POST /api/dashboards
|
||||
*/
|
||||
async createDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
elements,
|
||||
isPublic = false,
|
||||
tags,
|
||||
category,
|
||||
settings,
|
||||
}: CreateDashboardRequest = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!title || title.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 제목이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elements || !Array.isArray(elements)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 요소 데이터가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 제목 길이 체크
|
||||
if (title.length > 200) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "제목은 200자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 설명 길이 체크
|
||||
if (description && description.length > 1000) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardData: CreateDashboardRequest = {
|
||||
title: title.trim(),
|
||||
description: description?.trim(),
|
||||
isPublic,
|
||||
elements,
|
||||
tags,
|
||||
category,
|
||||
settings,
|
||||
};
|
||||
|
||||
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
|
||||
|
||||
const savedDashboard = await DashboardService.createDashboard(
|
||||
dashboardData,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: savedDashboard,
|
||||
message: "대시보드가 성공적으로 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
// console.error('Dashboard creation error:', {
|
||||
// message: error?.message,
|
||||
// stack: error?.stack,
|
||||
// error
|
||||
// });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error?.message || "대시보드 생성 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development" ? error?.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 목록 조회
|
||||
* GET /api/dashboards
|
||||
*/
|
||||
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const query: DashboardListQuery = {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
isPublic:
|
||||
req.query.isPublic === "true"
|
||||
? true
|
||||
: req.query.isPublic === "false"
|
||||
? false
|
||||
: undefined,
|
||||
createdBy: req.query.createdBy as string,
|
||||
};
|
||||
|
||||
// 페이지 번호 유효성 검증
|
||||
if (query.page! < 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "페이지 번호는 1 이상이어야 합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await DashboardService.getDashboards(
|
||||
query,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard list error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* GET /api/dashboards/:id
|
||||
*/
|
||||
async getDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboard = await DashboardService.getDashboardById(
|
||||
id,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (!dashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만)
|
||||
if (userId && dashboard.createdBy !== userId) {
|
||||
await DashboardService.incrementViewCount(id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard get error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 수정
|
||||
* PUT /api/dashboards/:id
|
||||
*/
|
||||
async updateDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData: UpdateDashboardRequest = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (updateData.title !== undefined) {
|
||||
if (
|
||||
typeof updateData.title !== "string" ||
|
||||
updateData.title.trim().length === 0
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 제목을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (updateData.title.length > 200) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "제목은 200자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.title = updateData.title.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
updateData.description !== undefined &&
|
||||
updateData.description &&
|
||||
updateData.description.length > 1000
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "설명은 1000자를 초과할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDashboard = await DashboardService.updateDashboard(
|
||||
id,
|
||||
updateData,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!updatedDashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedDashboard,
|
||||
message: "대시보드가 성공적으로 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard update error:', error);
|
||||
|
||||
if ((error as Error).message.includes("권한이 없습니다")) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: (error as Error).message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 수정 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 삭제
|
||||
* DELETE /api/dashboards/:id
|
||||
*/
|
||||
async deleteDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "대시보드 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await DashboardService.deleteDashboard(id, userId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "대시보드가 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Dashboard delete error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대시보드 삭제 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 대시보드 목록 조회
|
||||
* GET /api/dashboards/my
|
||||
*/
|
||||
async getMyDashboards(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const query: DashboardListQuery = {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
// createdBy 제거 - 회사 대시보드 전체 표시
|
||||
};
|
||||
|
||||
const result = await DashboardService.getDashboards(
|
||||
query,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('My dashboards error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "내 대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행 (SELECT만)
|
||||
* POST /api/dashboards/execute-query
|
||||
*/
|
||||
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// 개발용으로 인증 체크 제거
|
||||
// const userId = req.user?.userId;
|
||||
// if (!userId) {
|
||||
// res.status(401).json({
|
||||
// success: false,
|
||||
// message: '인증이 필요합니다.'
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const { query } = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "쿼리가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "SELECT 쿼리만 허용됩니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const result = await PostgreSQLService.query(query.trim());
|
||||
|
||||
// 결과 변환
|
||||
const columns = result.fields?.map((field) => field.name) || [];
|
||||
const rows = result.rows || [];
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
columns,
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
},
|
||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('Query execution error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "쿼리 실행 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DML 쿼리 실행 (INSERT, UPDATE, DELETE)
|
||||
* POST /api/dashboards/execute-dml
|
||||
*/
|
||||
async executeDML(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { query } = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "쿼리가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const allowedCommands = ["insert", "update", "delete"];
|
||||
const isAllowed = allowedCommands.some((cmd) =>
|
||||
trimmedQuery.startsWith(cmd)
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 위험한 명령어 차단
|
||||
const dangerousPatterns = [
|
||||
/drop\s+table/i,
|
||||
/drop\s+database/i,
|
||||
/truncate/i,
|
||||
/alter\s+table/i,
|
||||
/create\s+table/i,
|
||||
];
|
||||
|
||||
if (dangerousPatterns.some((pattern) => pattern.test(query))) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "허용되지 않는 쿼리입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const result = await PostgreSQLService.query(query.trim());
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rowCount: result.rowCount || 0,
|
||||
command: result.command,
|
||||
},
|
||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("DML execution error:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "쿼리 실행 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 프록시 (CORS 우회용)
|
||||
* POST /api/dashboards/fetch-external-api
|
||||
*/
|
||||
async fetchExternalApi(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
url,
|
||||
method = "GET",
|
||||
headers = {},
|
||||
queryParams = {},
|
||||
body,
|
||||
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
|
||||
} = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "URL이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const urlObj = new URL(url);
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
urlObj.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Axios 요청 설정
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
url: urlObj.toString(),
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
},
|
||||
timeout: 60000, // 60초 타임아웃
|
||||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||
};
|
||||
|
||||
// 연결 정보 (응답에 포함용)
|
||||
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||
|
||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||
if (externalConnectionId) {
|
||||
try {
|
||||
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
|
||||
let companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
companyCode = "*";
|
||||
}
|
||||
|
||||
// 커넥션 로드
|
||||
const connectionResult =
|
||||
await ExternalRestApiConnectionService.getConnectionById(
|
||||
Number(externalConnectionId),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (connectionResult.success && connectionResult.data) {
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 연결 정보 저장 (응답에 포함)
|
||||
connectionInfo = {
|
||||
saveToHistory: connection.save_to_history === "Y",
|
||||
};
|
||||
|
||||
// 인증 헤더 생성 (DB 토큰 등)
|
||||
const authHeaders =
|
||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||
connection.auth_type,
|
||||
connection.auth_config,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 기존 헤더에 인증 헤더 병합
|
||||
requestConfig.headers = {
|
||||
...requestConfig.headers,
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
// API Key가 Query Param인 경우 처리
|
||||
if (
|
||||
connection.auth_type === "api-key" &&
|
||||
connection.auth_config?.keyLocation === "query" &&
|
||||
connection.auth_config?.keyName &&
|
||||
connection.auth_config?.keyValue
|
||||
) {
|
||||
const currentUrl = new URL(requestConfig.url!);
|
||||
currentUrl.searchParams.append(
|
||||
connection.auth_config.keyName,
|
||||
connection.auth_config.keyValue
|
||||
);
|
||||
requestConfig.url = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
} catch (connError) {
|
||||
logger.error(
|
||||
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
|
||||
connError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Body 처리
|
||||
if (body) {
|
||||
requestConfig.data = body;
|
||||
}
|
||||
|
||||
// 디버깅 로그: 실제 요청 정보 출력
|
||||
logger.info(`[fetchExternalApi] 요청 정보:`, {
|
||||
url: requestConfig.url,
|
||||
method: requestConfig.method,
|
||||
headers: requestConfig.headers,
|
||||
body: requestConfig.data,
|
||||
externalConnectionId,
|
||||
});
|
||||
|
||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const hostname = urlObj.hostname;
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
hostname.includes(domain)
|
||||
);
|
||||
|
||||
if (shouldBypassTls) {
|
||||
requestConfig.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
|
||||
if (isKmaApi) {
|
||||
requestConfig.responseType = "arraybuffer";
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
let data = response.data;
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||
const iconv = require("iconv-lite");
|
||||
const buffer = Buffer.from(data);
|
||||
const utf8Text = buffer.toString("utf-8");
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인
|
||||
if (
|
||||
utf8Text.includes("특보") ||
|
||||
utf8Text.includes("경보") ||
|
||||
utf8Text.includes("주의보") ||
|
||||
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
|
||||
) {
|
||||
data = { text: utf8Text, contentType, encoding: "utf-8" };
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
const eucKrText = iconv.decode(buffer, "EUC-KR");
|
||||
data = { text: eucKrText, contentType, encoding: "euc-kr" };
|
||||
}
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
else if (typeof data === "string") {
|
||||
data = { text: data, contentType };
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||
});
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || 500;
|
||||
const message = error.response?.statusText || error.message;
|
||||
|
||||
logger.error("외부 API 호출 오류:", {
|
||||
message,
|
||||
status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? message
|
||||
: "외부 API 호출 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||
* POST /api/dashboards/table-schema
|
||||
*/
|
||||
async getTableSchema(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블명 검증 (SQL 인젝션 방지)
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// PostgreSQL information_schema에서 컬럼 정보 조회
|
||||
const query = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
udt_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
const result = await PostgreSQLService.query(query, [
|
||||
tableName.toLowerCase(),
|
||||
]);
|
||||
|
||||
// 날짜/시간 타입 컬럼 필터링
|
||||
const dateColumns = result.rows
|
||||
.filter((row: any) => {
|
||||
const dataType = row.data_type?.toLowerCase();
|
||||
const udtName = row.udt_name?.toLowerCase();
|
||||
return (
|
||||
dataType === "timestamp" ||
|
||||
dataType === "timestamp without time zone" ||
|
||||
dataType === "timestamp with time zone" ||
|
||||
dataType === "date" ||
|
||||
dataType === "time" ||
|
||||
dataType === "time without time zone" ||
|
||||
dataType === "time with time zone" ||
|
||||
udtName === "timestamp" ||
|
||||
udtName === "timestamptz" ||
|
||||
udtName === "date" ||
|
||||
udtName === "time" ||
|
||||
udtName === "timetz"
|
||||
);
|
||||
})
|
||||
.map((row: any) => row.column_name);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
tableName,
|
||||
columns: result.rows.map((row: any) => ({
|
||||
name: row.column_name,
|
||||
type: row.data_type,
|
||||
udtName: row.udt_name,
|
||||
})),
|
||||
dateColumns,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "스키마 조회 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import YardLayoutService from "../services/YardLayoutService";
|
||||
|
||||
export class YardLayoutController {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts(req: Request, res: Response) {
|
||||
try {
|
||||
const layouts = await YardLayoutService.getAllLayouts();
|
||||
res.json({ success: true, data: layouts });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layouts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.getLayoutById(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const created_by = (req as any).user?.userId || "system";
|
||||
const layout = await YardLayoutService.createLayout({
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error creating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정
|
||||
async updateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const layout = await YardLayoutService.updateLayout(parseInt(id), {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.deleteLayout(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "야드 레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placements = await YardLayoutService.getPlacementsByLayoutId(
|
||||
parseInt(id)
|
||||
);
|
||||
|
||||
res.json({ success: true, data: placements });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching placements:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가 (빈 요소 또는 설정된 요소)
|
||||
async addMaterialPlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
// 데이터 바인딩 재설계 후 material_code와 external_material_id는 선택사항
|
||||
// 빈 요소를 추가할 수 있어야 함
|
||||
|
||||
const placement = await YardLayoutService.addMaterialPlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error adding material placement:", error);
|
||||
|
||||
if (error.code === "23505") {
|
||||
// 유니크 제약 조건 위반
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 배치된 자재입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 배치 추가 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 정보 수정
|
||||
async updatePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
const placement = await YardLayoutService.updatePlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 정보 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 해제
|
||||
async removePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placement = await YardLayoutService.removePlacement(parseInt(id));
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: "배치가 해제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 해제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { placements } = req.body;
|
||||
|
||||
if (!Array.isArray(placements) || placements.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "배치 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
|
||||
parseInt(id),
|
||||
placements
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: updatedPlacements });
|
||||
} catch (error: any) {
|
||||
console.error("Error batch updating placements:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "새 야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const layout = await YardLayoutService.duplicateLayout(
|
||||
parseInt(id),
|
||||
name
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error duplicating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutController();
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -59,56 +59,12 @@ export class AuthController {
|
|||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
try {
|
||||
const { AdminService } = await import("../services/adminService");
|
||||
const paramMap = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
userType: loginResult.userInfo.userType,
|
||||
userLang: "ko",
|
||||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
|
||||
// 2. MENU_URL이 있고 비어있지 않음
|
||||
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
|
||||
const firstMenu = menuList.find((menu: any) => {
|
||||
const level = menu.lev || menu.level;
|
||||
const url = menu.menu_url || menu.url;
|
||||
|
||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||
});
|
||||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
||||
url: firstMenuPath,
|
||||
level: firstMenu.lev || firstMenu.level,
|
||||
seq: firstMenu.seq,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
||||
);
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
@ -141,110 +97,6 @@ export class AuthController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/switch-company
|
||||
* WACE 관리자 전용: 다른 회사로 전환
|
||||
*/
|
||||
static async switchCompany(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.body;
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 토큰이 필요합니다.",
|
||||
error: { code: "TOKEN_MISSING" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 확인
|
||||
const currentUser = JwtUtils.verifyToken(token);
|
||||
|
||||
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
|
||||
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
|
||||
if (currentUser.userType !== "SUPER_ADMIN") {
|
||||
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 전환할 회사 코드 검증
|
||||
if (!companyCode || companyCode.trim() === "") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "전환할 회사 코드가 필요합니다.",
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`=== WACE 관리자 회사 전환 ===`, {
|
||||
userId: currentUser.userId,
|
||||
originalCompanyCode: currentUser.companyCode,
|
||||
targetCompanyCode: companyCode,
|
||||
});
|
||||
|
||||
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
|
||||
if (companyCode !== "*") {
|
||||
const { query } = await import("../database/db");
|
||||
const companies = await query<any>(
|
||||
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
if (companies.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "존재하지 않는 회사 코드입니다.",
|
||||
error: { code: "COMPANY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||
const newPersonBean: PersonBean = {
|
||||
...currentUser,
|
||||
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||
};
|
||||
|
||||
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||
|
||||
logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "회사 전환 완료",
|
||||
data: {
|
||||
token: newToken,
|
||||
companyCode: companyCode.trim(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회사 전환 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||
|
|
@ -330,14 +182,13 @@ export class AuthController {
|
|||
}
|
||||
|
||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||
const userInfoResponse: any = {
|
||||
userId: dbUserInfo.userId,
|
||||
userName: dbUserInfo.userName || "",
|
||||
deptName: dbUserInfo.deptName || "",
|
||||
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
||||
userType: dbUserInfo.userType || "USER",
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
email: dbUserInfo.email || "",
|
||||
photo: dbUserInfo.photo,
|
||||
|
|
@ -489,69 +340,4 @@ export class AuthController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 공차중계 회원가입 API
|
||||
*/
|
||||
static async signup(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||
|
||||
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 입력값이 누락되었습니다.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회원가입 처리
|
||||
const signupResult = await AuthService.signupDriver({
|
||||
userId,
|
||||
password,
|
||||
userName,
|
||||
phoneNumber,
|
||||
licenseNumber,
|
||||
vehicleNumber,
|
||||
vehicleType,
|
||||
});
|
||||
|
||||
if (signupResult.success) {
|
||||
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
});
|
||||
} else {
|
||||
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_FAILED",
|
||||
details: signupResult.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("공차중계 회원가입 API 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import {
|
||||
BatchConfigFilter,
|
||||
CreateBatchConfigRequest,
|
||||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
|
|
@ -21,36 +16,32 @@ export interface AuthenticatedRequest extends Request {
|
|||
|
||||
export class BatchController {
|
||||
/**
|
||||
* 배치 설정 목록 조회 (회사별)
|
||||
* 배치 설정 목록 조회
|
||||
* GET /api/batch-configs
|
||||
*/
|
||||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
|
||||
const filter: BatchConfigFilter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string,
|
||||
is_active: isActive as string
|
||||
};
|
||||
|
||||
const result = await BatchService.getBatchConfigs(
|
||||
filter,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
const result = await BatchService.getBatchConfigs(filter);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||
message: "배치 설정 목록 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -59,13 +50,10 @@ export class BatchController {
|
|||
* 사용 가능한 커넥션 목록 조회
|
||||
* GET /api/batch-configs/connections
|
||||
*/
|
||||
static async getAvailableConnections(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const result = await BatchExternalDbService.getAvailableConnections();
|
||||
|
||||
const result = await BatchService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -75,7 +63,7 @@ export class BatchController {
|
|||
console.error("커넥션 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회에 실패했습니다.",
|
||||
message: "커넥션 목록 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -85,26 +73,20 @@ export class BatchController {
|
|||
* GET /api/batch-configs/connections/:type/tables
|
||||
* GET /api/batch-configs/connections/:type/:id/tables
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
|
||||
if (!type || (type !== "internal" && type !== "external")) {
|
||||
|
||||
if (!type || (type !== 'internal' && type !== 'external')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTables(
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchService.getTablesFromConnection(type, connectionId);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -114,7 +96,7 @@ export class BatchController {
|
|||
console.error("테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회에 실패했습니다.",
|
||||
message: "테이블 목록 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -127,28 +109,24 @@ export class BatchController {
|
|||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
|
||||
|
||||
if (!type || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "연결 타입과 테이블명을 모두 지정해주세요.",
|
||||
message: "연결 타입과 테이블명을 모두 지정해주세요."
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "internal" && type !== "external") {
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getColumns(
|
||||
tableName,
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchService.getTableColumns(type, connectionId, tableName);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -158,36 +136,36 @@ export class BatchController {
|
|||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 정보 조회에 실패했습니다.",
|
||||
message: "컬럼 정보 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회 (회사별)
|
||||
* 특정 배치 설정 조회
|
||||
* GET /api/batch-configs/:id
|
||||
*/
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
const batchConfig = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
data: batchConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
message: "배치 설정 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -199,17 +177,11 @@ export class BatchController {
|
|||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings } = req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
|
||||
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -217,123 +189,102 @@ export class BatchController {
|
|||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
mappings
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
// 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화)
|
||||
if (
|
||||
batchConfig.data &&
|
||||
batchConfig.data.is_active === "Y" &&
|
||||
batchConfig.data.id
|
||||
) {
|
||||
await BatchSchedulerService.updateBatchSchedule(
|
||||
batchConfig.data.id,
|
||||
false
|
||||
);
|
||||
if (batchConfig.data && batchConfig.data.is_active === 'Y' && batchConfig.data.id) {
|
||||
await BatchSchedulerService.updateBatchSchedule(batchConfig.data.id, false);
|
||||
}
|
||||
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다.",
|
||||
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 생성에 실패했습니다.",
|
||||
message: "배치 설정 생성에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 수정 (회사별)
|
||||
* 배치 설정 수정
|
||||
* PUT /api/batch-configs/:id
|
||||
*/
|
||||
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)"
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(
|
||||
Number(id),
|
||||
{
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive,
|
||||
} as UpdateBatchConfigRequest,
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive
|
||||
} as UpdateBatchConfigRequest);
|
||||
|
||||
if (!batchConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 수정되었습니다.",
|
||||
message: "배치 설정이 성공적으로 수정되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 수정에 실패했습니다.",
|
||||
message: "배치 설정 수정에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 삭제 (논리 삭제, 회사별)
|
||||
* 배치 설정 삭제 (논리 삭제)
|
||||
* DELETE /api/batch-configs/:id
|
||||
*/
|
||||
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result = await BatchService.deleteBatchConfig(
|
||||
Number(id),
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
const result = await BatchService.deleteBatchConfig(Number(id));
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "배치 설정이 성공적으로 삭제되었습니다.",
|
||||
message: "배치 설정이 성공적으로 삭제되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 삭제에 실패했습니다.",
|
||||
message: "배치 설정 삭제에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,7 @@
|
|||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
|
||||
import {
|
||||
BatchExecutionLogFilter,
|
||||
CreateBatchExecutionLogRequest,
|
||||
UpdateBatchExecutionLogRequest,
|
||||
} from "../types/batchExecutionLogTypes";
|
||||
import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes";
|
||||
|
||||
export class BatchExecutionLogController {
|
||||
/**
|
||||
|
|
@ -22,7 +18,7 @@ export class BatchExecutionLogController {
|
|||
start_date,
|
||||
end_date,
|
||||
page,
|
||||
limit,
|
||||
limit
|
||||
} = req.query;
|
||||
|
||||
const filter: BatchExecutionLogFilter = {
|
||||
|
|
@ -31,15 +27,11 @@ export class BatchExecutionLogController {
|
|||
start_date: start_date ? new Date(start_date as string) : undefined,
|
||||
end_date: end_date ? new Date(end_date as string) : undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
limit: limit ? Number(limit) : undefined
|
||||
};
|
||||
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result = await BatchExecutionLogService.getExecutionLogs(
|
||||
filter,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionLogs(filter);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -50,7 +42,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -61,14 +53,9 @@ export class BatchExecutionLogController {
|
|||
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
res.status(201).json(result);
|
||||
} else {
|
||||
|
|
@ -79,7 +66,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -91,12 +78,9 @@ export class BatchExecutionLogController {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const data: UpdateBatchExecutionLogRequest = req.body;
|
||||
|
||||
const result = await BatchExecutionLogService.updateExecutionLog(
|
||||
Number(id),
|
||||
data
|
||||
);
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -107,7 +91,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -118,11 +102,9 @@ export class BatchExecutionLogController {
|
|||
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.deleteExecutionLog(
|
||||
Number(id)
|
||||
);
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.deleteExecutionLog(Number(id));
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -133,7 +115,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -144,11 +126,9 @@ export class BatchExecutionLogController {
|
|||
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchConfigId } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.getLatestExecutionLog(
|
||||
Number(batchConfigId)
|
||||
);
|
||||
|
||||
|
||||
const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId));
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -159,7 +139,7 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -169,14 +149,18 @@ export class BatchExecutionLogController {
|
|||
*/
|
||||
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batch_config_id, start_date, end_date } = req.query;
|
||||
const {
|
||||
batch_config_id,
|
||||
start_date,
|
||||
end_date
|
||||
} = req.query;
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionStats(
|
||||
batch_config_id ? Number(batch_config_id) : undefined,
|
||||
start_date ? new Date(start_date as string) : undefined,
|
||||
end_date ? new Date(end_date as string) : undefined
|
||||
);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -187,8 +171,9 @@ export class BatchExecutionLogController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,21 @@
|
|||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
BatchManagementService,
|
||||
BatchConnectionInfo,
|
||||
BatchTableInfo,
|
||||
BatchColumnInfo,
|
||||
} from "../services/batchManagementService";
|
||||
import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class BatchManagementController {
|
||||
/**
|
||||
* 사용 가능한 커넥션 목록 조회 (회사별)
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
*/
|
||||
static async getAvailableConnections(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const result =
|
||||
await BatchManagementService.getAvailableConnections(userCompanyCode);
|
||||
const result = await BatchManagementService.getAvailableConnections();
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
|
|
@ -37,36 +26,28 @@ export class BatchManagementController {
|
|||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 커넥션의 테이블 목록 조회 (회사별)
|
||||
* 특정 커넥션의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (type !== "internal" && type !== "external") {
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTablesFromConnection(
|
||||
type,
|
||||
connectionId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTablesFromConnection(type, connectionId);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -77,34 +58,28 @@ export class BatchManagementController {
|
|||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회 (회사별)
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (type !== "internal" && type !== "external") {
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTableColumns(
|
||||
type,
|
||||
connectionId,
|
||||
tableName,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTableColumns(type, connectionId, tableName);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
|
|
@ -115,7 +90,7 @@ export class BatchManagementController {
|
|||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -126,19 +101,12 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -147,20 +115,20 @@ export class BatchManagementController {
|
|||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
isActive: isActive !== undefined ? isActive : true
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다.",
|
||||
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 생성에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -173,28 +141,28 @@ export class BatchManagementController {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
console.log("🔍 배치 설정 조회 요청:", id);
|
||||
|
||||
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
console.log("📋 조회된 배치 설정:", result.data);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
data: result.data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -206,27 +174,27 @@ export class BatchManagementController {
|
|||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
|
||||
|
||||
const filter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string,
|
||||
is_active: isActive as string
|
||||
};
|
||||
|
||||
const result = await BatchService.getBatchConfigs(filter);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -238,22 +206,20 @@ export class BatchManagementController {
|
|||
static async executeBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 설정 ID를 제공해주세요.",
|
||||
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||
});
|
||||
}
|
||||
|
||||
// 배치 설정 조회
|
||||
const batchConfigResult = await BatchService.getBatchConfigById(
|
||||
Number(id)
|
||||
);
|
||||
const batchConfigResult = await BatchService.getBatchConfigById(Number(id));
|
||||
if (!batchConfigResult.success || !batchConfigResult.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -263,53 +229,38 @@ export class BatchManagementController {
|
|||
console.log(`배치 수동 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
|
||||
|
||||
let executionLog: any = null;
|
||||
|
||||
|
||||
try {
|
||||
// 실행 로그 생성
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
company_code: batchConfig.company_code,
|
||||
execution_status: "RUNNING",
|
||||
execution_status: 'RUNNING',
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
success_records: 0,
|
||||
failed_records: 0,
|
||||
failed_records: 0
|
||||
});
|
||||
|
||||
if (!logResult.success || !logResult.data) {
|
||||
throw new Error(
|
||||
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||
);
|
||||
}
|
||||
|
||||
executionLog = logResult.data;
|
||||
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import(
|
||||
"../services/batchSchedulerService"
|
||||
);
|
||||
const result =
|
||||
await BatchSchedulerService.executeBatchConfig(batchConfig);
|
||||
const { BatchSchedulerService } = await import('../services/batchSchedulerService');
|
||||
const result = await BatchSchedulerService.executeBatchConfig(batchConfig);
|
||||
|
||||
// result가 undefined인 경우 처리
|
||||
if (!result) {
|
||||
throw new Error("배치 실행 결과를 받을 수 없습니다.");
|
||||
throw new Error('배치 실행 결과를 받을 수 없습니다.');
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'SUCCESS',
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
total_records: result.totalRecords,
|
||||
success_records: result.successRecords,
|
||||
failed_records: result.failedRecords,
|
||||
failed_records: result.failedRecords
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
|
@ -319,52 +270,45 @@ export class BatchManagementController {
|
|||
totalRecords: result.totalRecords,
|
||||
successRecords: result.successRecords,
|
||||
failedRecords: result.failedRecords,
|
||||
executionTime: duration,
|
||||
executionTime: duration
|
||||
},
|
||||
message: "배치가 성공적으로 실행되었습니다.",
|
||||
message: "배치가 성공적으로 실행되었습니다."
|
||||
});
|
||||
|
||||
} catch (batchError) {
|
||||
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, batchError);
|
||||
|
||||
|
||||
// 실행 로그 업데이트 (실패) - executionLog가 생성되었을 경우에만
|
||||
try {
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
|
||||
// executionLog가 정의되어 있는지 확인
|
||||
if (typeof executionLog !== "undefined" && executionLog) {
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
if (typeof executionLog !== 'undefined') {
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'FAILED',
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
error_message:
|
||||
batchError instanceof Error
|
||||
? batchError.message
|
||||
: "알 수 없는 오류",
|
||||
error_message: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
} catch (logError) {
|
||||
console.error("실행 로그 업데이트 실패:", logError);
|
||||
console.error('실행 로그 업데이트 실패:', logError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행에 실패했습니다.",
|
||||
error:
|
||||
batchError instanceof Error
|
||||
? batchError.message
|
||||
: "알 수 없는 오류",
|
||||
error: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`배치 실행 오류 (ID: ${req.params.id}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -381,29 +325,26 @@ export class BatchManagementController {
|
|||
if (!id || isNaN(Number(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 설정 ID를 제공해주세요.",
|
||||
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(
|
||||
Number(id),
|
||||
updateData
|
||||
);
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 업데이트되었습니다.",
|
||||
message: "배치 설정이 성공적으로 업데이트되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 업데이트 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 업데이트에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -413,88 +354,40 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method = "GET",
|
||||
const {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method = 'GET',
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
authServiceName, // DB에서 토큰 가져올 서비스명
|
||||
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
||||
paramSource
|
||||
} = req.body;
|
||||
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
if (!apiUrl || !endpoint) {
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL과 엔드포인트는 필수입니다.",
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다."
|
||||
});
|
||||
}
|
||||
|
||||
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
||||
let finalApiKey = apiKey || "";
|
||||
if (authServiceName) {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
|
||||
let tokenQuery: string;
|
||||
let tokenParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 토큰 조회 가능
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 토큰만 조회
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1 AND company_code = $2
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName, companyCode];
|
||||
}
|
||||
|
||||
const tokenResult = await query<{ access_token: string }>(
|
||||
tokenQuery,
|
||||
tokenParams
|
||||
);
|
||||
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||
finalApiKey = tokenResult[0].access_token;
|
||||
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
|
||||
|
||||
console.log("REST API 미리보기 요청:", {
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
authServiceName: authServiceName || "직접 입력",
|
||||
dataArrayPath: dataArrayPath || "전체 응답",
|
||||
paramSource
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
const { RestApiConnector } = await import("../database/RestApiConnector");
|
||||
|
||||
const { RestApiConnector } = await import('../database/RestApiConnector');
|
||||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: finalApiKey,
|
||||
timeout: 30000,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
|
|
@ -503,7 +396,7 @@ export class BatchManagementController {
|
|||
// 파라미터가 있는 경우 엔드포인트 수정
|
||||
let finalEndpoint = endpoint;
|
||||
if (paramType && paramName && paramValue) {
|
||||
if (paramType === "url") {
|
||||
if (paramType === 'url') {
|
||||
// URL 파라미터: /api/users/{userId} → /api/users/123
|
||||
if (endpoint.includes(`{${paramName}}`)) {
|
||||
finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue);
|
||||
|
|
@ -511,101 +404,39 @@ export class BatchManagementController {
|
|||
// 엔드포인트에 {paramName}이 없으면 뒤에 추가
|
||||
finalEndpoint = `${endpoint}/${paramValue}`;
|
||||
}
|
||||
} else if (paramType === "query") {
|
||||
} else if (paramType === 'query') {
|
||||
// 쿼리 파라미터: /api/users?userId=123
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// Request Body 파싱
|
||||
let parsedBody = undefined;
|
||||
if (requestBody && typeof requestBody === "string") {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
console.warn("Request Body JSON 파싱 실패:", e);
|
||||
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
|
||||
// 여기서는 경고 로그 남기고 진행
|
||||
}
|
||||
} else if (requestBody) {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
|
||||
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
|
||||
const result = await connector.executeRequest(
|
||||
finalEndpoint,
|
||||
method as "GET" | "POST" | "PUT" | "DELETE",
|
||||
parsedBody
|
||||
);
|
||||
|
||||
console.log(`[previewRestApiData] executeRequest 결과:`, {
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||
firstRow:
|
||||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
||||
rowsLength: result.rows ? result.rows.length : 'undefined',
|
||||
firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data'
|
||||
});
|
||||
|
||||
// 데이터 배열 추출 헬퍼 함수
|
||||
const getValueByPath = (obj: any, path: string): any => {
|
||||
if (!path) return obj;
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// dataArrayPath가 있으면 해당 경로에서 배열 추출
|
||||
let extractedData: any[] = [];
|
||||
if (dataArrayPath) {
|
||||
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
|
||||
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
|
||||
const arrayData = getValueByPath(rawData, dataArrayPath);
|
||||
|
||||
if (Array.isArray(arrayData)) {
|
||||
extractedData = arrayData;
|
||||
console.log(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
|
||||
typeof arrayData
|
||||
);
|
||||
// 배열이 아니면 단일 객체로 처리
|
||||
if (arrayData) {
|
||||
extractedData = [arrayData];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// dataArrayPath가 없으면 기존 로직 사용
|
||||
extractedData = result.rows;
|
||||
}
|
||||
|
||||
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(
|
||||
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
|
||||
data
|
||||
);
|
||||
|
||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||
|
||||
if (data.length > 0) {
|
||||
// 첫 번째 객체에서 필드명 추출
|
||||
const fields = Object.keys(data[0]);
|
||||
console.log(`[previewRestApiData] 추출된 필드:`, fields);
|
||||
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fields: fields,
|
||||
samples: data,
|
||||
totalCount: extractedData.length,
|
||||
totalCount: result.rowCount || data.length
|
||||
},
|
||||
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
|
|
@ -613,9 +444,9 @@ export class BatchManagementController {
|
|||
data: {
|
||||
fields: [],
|
||||
samples: [],
|
||||
totalCount: 0,
|
||||
totalCount: 0
|
||||
},
|
||||
message: "API에서 데이터를 가져올 수 없습니다.",
|
||||
message: "API에서 데이터를 가져올 수 없습니다."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -623,7 +454,7 @@ export class BatchManagementController {
|
|||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "REST API 데이터 미리보기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -633,28 +464,18 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!batchType ||
|
||||
!cronSchedule ||
|
||||
!apiMappings ||
|
||||
apiMappings.length === 0
|
||||
) {
|
||||
if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
message: "필수 필드가 누락되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -663,40 +484,24 @@ export class BatchManagementController {
|
|||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
apiMappings
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || "",
|
||||
description: description || '',
|
||||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
authServiceName: authServiceName || undefined,
|
||||
dataArrayPath: dataArrayPath || undefined,
|
||||
saveMode: saveMode || "INSERT",
|
||||
conflictKey: conflictKey || undefined,
|
||||
mappings: apiMappings,
|
||||
mappings: apiMappings
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
try {
|
||||
await BatchSchedulerService.scheduleBatch(result.data);
|
||||
console.log(
|
||||
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
||||
);
|
||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
||||
console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`);
|
||||
} catch (schedulerError) {
|
||||
console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError);
|
||||
// 스케줄러 등록 실패해도 배치 저장은 성공으로 처리
|
||||
|
|
@ -705,66 +510,19 @@ export class BatchManagementController {
|
|||
return res.json({
|
||||
success: true,
|
||||
message: "REST API 배치가 성공적으로 저장되었습니다.",
|
||||
data: result.data,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "배치 저장에 실패했습니다.",
|
||||
message: result.message || "배치 저장에 실패했습니다."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("REST API 배치 저장 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 저장 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 토큰 서비스명 목록 조회
|
||||
*/
|
||||
static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
let queryText: string;
|
||||
let queryParams: any[] = [];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 서비스 조회
|
||||
queryText = `SELECT DISTINCT service_name
|
||||
FROM auth_tokens
|
||||
WHERE service_name IS NOT NULL
|
||||
ORDER BY service_name`;
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 서비스만 조회
|
||||
queryText = `SELECT DISTINCT service_name
|
||||
FROM auth_tokens
|
||||
WHERE service_name IS NOT NULL
|
||||
AND company_code = $1
|
||||
ORDER BY service_name`;
|
||||
queryParams = [companyCode];
|
||||
}
|
||||
|
||||
const result = await query<{ service_name: string }>(
|
||||
queryText,
|
||||
queryParams
|
||||
);
|
||||
|
||||
const serviceNames = result.map((row) => row.service_name);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: serviceNames,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("인증 서비스 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
|
||||
message: "배치 저장 중 오류가 발생했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BookingService } from "../services/bookingService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const bookingService = BookingService.getInstance();
|
||||
|
||||
/**
|
||||
* 모든 예약 조회
|
||||
*/
|
||||
export const getBookings = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { status, priority } = req.query;
|
||||
|
||||
const result = await bookingService.getAllBookings({
|
||||
status: status as string,
|
||||
priority: priority as string,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.bookings,
|
||||
newCount: result.newCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ 예약 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "예약 목록 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 예약 수락
|
||||
*/
|
||||
export const acceptBooking = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const booking = await bookingService.acceptBooking(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: booking,
|
||||
message: "예약이 수락되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ 예약 수락 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "예약 수락에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 예약 거절
|
||||
*/
|
||||
export const rejectBooking = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
const booking = await bookingService.rejectBooking(id, reason);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: booking,
|
||||
message: "예약이 거절되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ 예약 거절 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "예약 거절에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { Request, Response } from "express";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class ButtonActionStandardController {
|
||||
// 버튼 액션 목록 조회
|
||||
|
|
@ -8,36 +10,33 @@ export class ButtonActionStandardController {
|
|||
try {
|
||||
const { active, category, search } = req.query;
|
||||
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
const where: any = {};
|
||||
|
||||
if (active) {
|
||||
whereConditions.push(`is_active = $${paramIndex}`);
|
||||
queryParams.push(active as string);
|
||||
paramIndex++;
|
||||
where.is_active = active as string;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereConditions.push(`category = $${paramIndex}`);
|
||||
queryParams.push(category as string);
|
||||
paramIndex++;
|
||||
where.category = category as string;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(`(action_name ILIKE $${paramIndex} OR action_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
||||
queryParams.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
where.OR = [
|
||||
{ action_name: { contains: search as string, mode: "insensitive" } },
|
||||
{
|
||||
action_name_eng: {
|
||||
contains: search as string,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{ description: { contains: search as string, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
const buttonActions = await query<any>(
|
||||
`SELECT * FROM button_action_standards ${whereClause} ORDER BY sort_order ASC, action_type ASC`,
|
||||
queryParams
|
||||
);
|
||||
const buttonActions = await prisma.button_action_standards.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: "asc" }, { action_type: "asc" }],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -59,10 +58,9 @@ export class ButtonActionStandardController {
|
|||
try {
|
||||
const { actionType } = req.params;
|
||||
|
||||
const buttonAction = await queryOne<any>(
|
||||
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||
[actionType]
|
||||
);
|
||||
const buttonAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
if (!buttonAction) {
|
||||
return res.status(404).json({
|
||||
|
|
@ -117,10 +115,9 @@ export class ButtonActionStandardController {
|
|||
}
|
||||
|
||||
// 중복 체크
|
||||
const existingAction = await queryOne<any>(
|
||||
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||
[action_type]
|
||||
);
|
||||
const existingAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type },
|
||||
});
|
||||
|
||||
if (existingAction) {
|
||||
return res.status(409).json({
|
||||
|
|
@ -129,25 +126,28 @@ export class ButtonActionStandardController {
|
|||
});
|
||||
}
|
||||
|
||||
const [newButtonAction] = await query<any>(
|
||||
`INSERT INTO button_action_standards (
|
||||
action_type, action_name, action_name_eng, description, category,
|
||||
default_text, default_text_eng, default_icon, default_color, default_variant,
|
||||
confirmation_required, confirmation_message, validation_rules, action_config,
|
||||
sort_order, is_active, created_by, updated_by, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
action_type, action_name, action_name_eng, description, category,
|
||||
default_text, default_text_eng, default_icon, default_color, default_variant,
|
||||
confirmation_required, confirmation_message,
|
||||
validation_rules ? JSON.stringify(validation_rules) : null,
|
||||
action_config ? JSON.stringify(action_config) : null,
|
||||
sort_order, is_active,
|
||||
req.user?.userId || "system",
|
||||
req.user?.userId || "system"
|
||||
]
|
||||
);
|
||||
const newButtonAction = await prisma.button_action_standards.create({
|
||||
data: {
|
||||
action_type,
|
||||
action_name,
|
||||
action_name_eng,
|
||||
description,
|
||||
category,
|
||||
default_text,
|
||||
default_text_eng,
|
||||
default_icon,
|
||||
default_color,
|
||||
default_variant,
|
||||
confirmation_required,
|
||||
confirmation_message,
|
||||
validation_rules,
|
||||
action_config,
|
||||
sort_order,
|
||||
is_active,
|
||||
created_by: req.user?.userId || "system",
|
||||
updated_by: req.user?.userId || "system",
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
|
|
@ -187,10 +187,9 @@ export class ButtonActionStandardController {
|
|||
} = req.body;
|
||||
|
||||
// 존재 여부 확인
|
||||
const existingAction = await queryOne<any>(
|
||||
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||
[actionType]
|
||||
);
|
||||
const existingAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
if (!existingAction) {
|
||||
return res.status(404).json({
|
||||
|
|
@ -199,101 +198,28 @@ export class ButtonActionStandardController {
|
|||
});
|
||||
}
|
||||
|
||||
const updateFields: string[] = [];
|
||||
const updateParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (action_name !== undefined) {
|
||||
updateFields.push(`action_name = $${paramIndex}`);
|
||||
updateParams.push(action_name);
|
||||
paramIndex++;
|
||||
}
|
||||
if (action_name_eng !== undefined) {
|
||||
updateFields.push(`action_name_eng = $${paramIndex}`);
|
||||
updateParams.push(action_name_eng);
|
||||
paramIndex++;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateFields.push(`description = $${paramIndex}`);
|
||||
updateParams.push(description);
|
||||
paramIndex++;
|
||||
}
|
||||
if (category !== undefined) {
|
||||
updateFields.push(`category = $${paramIndex}`);
|
||||
updateParams.push(category);
|
||||
paramIndex++;
|
||||
}
|
||||
if (default_text !== undefined) {
|
||||
updateFields.push(`default_text = $${paramIndex}`);
|
||||
updateParams.push(default_text);
|
||||
paramIndex++;
|
||||
}
|
||||
if (default_text_eng !== undefined) {
|
||||
updateFields.push(`default_text_eng = $${paramIndex}`);
|
||||
updateParams.push(default_text_eng);
|
||||
paramIndex++;
|
||||
}
|
||||
if (default_icon !== undefined) {
|
||||
updateFields.push(`default_icon = $${paramIndex}`);
|
||||
updateParams.push(default_icon);
|
||||
paramIndex++;
|
||||
}
|
||||
if (default_color !== undefined) {
|
||||
updateFields.push(`default_color = $${paramIndex}`);
|
||||
updateParams.push(default_color);
|
||||
paramIndex++;
|
||||
}
|
||||
if (default_variant !== undefined) {
|
||||
updateFields.push(`default_variant = $${paramIndex}`);
|
||||
updateParams.push(default_variant);
|
||||
paramIndex++;
|
||||
}
|
||||
if (confirmation_required !== undefined) {
|
||||
updateFields.push(`confirmation_required = $${paramIndex}`);
|
||||
updateParams.push(confirmation_required);
|
||||
paramIndex++;
|
||||
}
|
||||
if (confirmation_message !== undefined) {
|
||||
updateFields.push(`confirmation_message = $${paramIndex}`);
|
||||
updateParams.push(confirmation_message);
|
||||
paramIndex++;
|
||||
}
|
||||
if (validation_rules !== undefined) {
|
||||
updateFields.push(`validation_rules = $${paramIndex}`);
|
||||
updateParams.push(validation_rules ? JSON.stringify(validation_rules) : null);
|
||||
paramIndex++;
|
||||
}
|
||||
if (action_config !== undefined) {
|
||||
updateFields.push(`action_config = $${paramIndex}`);
|
||||
updateParams.push(action_config ? JSON.stringify(action_config) : null);
|
||||
paramIndex++;
|
||||
}
|
||||
if (sort_order !== undefined) {
|
||||
updateFields.push(`sort_order = $${paramIndex}`);
|
||||
updateParams.push(sort_order);
|
||||
paramIndex++;
|
||||
}
|
||||
if (is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex}`);
|
||||
updateParams.push(is_active);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
updateFields.push(`updated_by = $${paramIndex}`);
|
||||
updateParams.push(req.user?.userId || "system");
|
||||
paramIndex++;
|
||||
|
||||
updateFields.push(`updated_date = $${paramIndex}`);
|
||||
updateParams.push(new Date());
|
||||
paramIndex++;
|
||||
|
||||
updateParams.push(actionType);
|
||||
|
||||
const [updatedButtonAction] = await query<any>(
|
||||
`UPDATE button_action_standards SET ${updateFields.join(", ")}
|
||||
WHERE action_type = $${paramIndex} RETURNING *`,
|
||||
updateParams
|
||||
);
|
||||
const updatedButtonAction = await prisma.button_action_standards.update({
|
||||
where: { action_type: actionType },
|
||||
data: {
|
||||
action_name,
|
||||
action_name_eng,
|
||||
description,
|
||||
category,
|
||||
default_text,
|
||||
default_text_eng,
|
||||
default_icon,
|
||||
default_color,
|
||||
default_variant,
|
||||
confirmation_required,
|
||||
confirmation_message,
|
||||
validation_rules,
|
||||
action_config,
|
||||
sort_order,
|
||||
is_active,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -316,10 +242,9 @@ export class ButtonActionStandardController {
|
|||
const { actionType } = req.params;
|
||||
|
||||
// 존재 여부 확인
|
||||
const existingAction = await queryOne<any>(
|
||||
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||
[actionType]
|
||||
);
|
||||
const existingAction = await prisma.button_action_standards.findUnique({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
if (!existingAction) {
|
||||
return res.status(404).json({
|
||||
|
|
@ -328,10 +253,9 @@ export class ButtonActionStandardController {
|
|||
});
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"DELETE FROM button_action_standards WHERE action_type = $1",
|
||||
[actionType]
|
||||
);
|
||||
await prisma.button_action_standards.delete({
|
||||
where: { action_type: actionType },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -363,16 +287,18 @@ export class ButtonActionStandardController {
|
|||
}
|
||||
|
||||
// 트랜잭션으로 일괄 업데이트
|
||||
await transaction(async (client) => {
|
||||
for (const item of buttonActions) {
|
||||
await client.query(
|
||||
`UPDATE button_action_standards
|
||||
SET sort_order = $1, updated_by = $2, updated_date = NOW()
|
||||
WHERE action_type = $3`,
|
||||
[item.sort_order, req.user?.userId || "system", item.action_type]
|
||||
);
|
||||
}
|
||||
});
|
||||
await prisma.$transaction(
|
||||
buttonActions.map((item) =>
|
||||
prisma.button_action_standards.update({
|
||||
where: { action_type: item.action_type },
|
||||
data: {
|
||||
sort_order: item.sort_order,
|
||||
updated_by: req.user?.userId || "system",
|
||||
updated_date: new Date(),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -391,17 +317,19 @@ export class ButtonActionStandardController {
|
|||
// 버튼 액션 카테고리 목록 조회
|
||||
static async getButtonActionCategories(req: Request, res: Response) {
|
||||
try {
|
||||
const categories = await query<{ category: string; count: string }>(
|
||||
`SELECT category, COUNT(*) as count
|
||||
FROM button_action_standards
|
||||
WHERE is_active = $1
|
||||
GROUP BY category`,
|
||||
["Y"]
|
||||
);
|
||||
const categories = await prisma.button_action_standards.groupBy({
|
||||
by: ["category"],
|
||||
where: {
|
||||
is_active: "Y",
|
||||
},
|
||||
_count: {
|
||||
category: true,
|
||||
},
|
||||
});
|
||||
|
||||
const categoryList = categories.map((item) => ({
|
||||
category: item.category,
|
||||
count: parseInt(item.count),
|
||||
count: item._count.category,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
|
|
|
|||
|
|
@ -1,606 +0,0 @@
|
|||
/**
|
||||
* 자동 입력 (Auto-Fill) 컨트롤러
|
||||
* 마스터 선택 시 여러 필드 자동 입력 기능
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 자동 입력 그룹 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 목록 조회
|
||||
*/
|
||||
export const getAutoFillGroups = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
g.*,
|
||||
COUNT(m.mapping_id) as mapping_count
|
||||
FROM cascading_auto_fill_group g
|
||||
LEFT JOIN cascading_auto_fill_mapping m
|
||||
ON g.group_code = m.group_code AND g.company_code = m.company_code
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("자동 입력 그룹 목록 조회", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||
*/
|
||||
export const getAutoFillGroupDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 그룹 정보 조회
|
||||
let groupSql = `
|
||||
SELECT * FROM cascading_auto_fill_group
|
||||
WHERE group_code = $1
|
||||
`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const groupResult = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!groupResult) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 매핑 정보 조회
|
||||
const mappingSql = `
|
||||
SELECT * FROM cascading_auto_fill_mapping
|
||||
WHERE group_code = $1 AND company_code = $2
|
||||
ORDER BY sort_order, mapping_id
|
||||
`;
|
||||
const mappingResult = await query(mappingSql, [
|
||||
groupCode,
|
||||
groupResult.company_code,
|
||||
]);
|
||||
|
||||
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...groupResult,
|
||||
mappings: mappingResult,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 그룹 코드 자동 생성 함수
|
||||
*/
|
||||
const generateAutoFillGroupCode = async (
|
||||
companyCode: string
|
||||
): Promise<string> => {
|
||||
const prefix = "AF";
|
||||
const result = await queryOne(
|
||||
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 생성
|
||||
*/
|
||||
export const createAutoFillGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn,
|
||||
mappings = [],
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!groupName || !masterTable || !masterValueColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 코드 자동 생성
|
||||
const groupCode = await generateAutoFillGroupCode(companyCode);
|
||||
|
||||
// 그룹 생성
|
||||
const insertGroupSql = `
|
||||
INSERT INTO cascading_auto_fill_group (
|
||||
group_code, group_name, description,
|
||||
master_table, master_value_column, master_label_column,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const groupResult = await queryOne(insertGroupSql, [
|
||||
groupCode,
|
||||
groupName,
|
||||
description || null,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn || null,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 매핑 생성
|
||||
if (mappings.length > 0) {
|
||||
for (let i = 0; i < mappings.length; i++) {
|
||||
const m = mappings[i];
|
||||
await query(
|
||||
`INSERT INTO cascading_auto_fill_mapping (
|
||||
group_code, company_code, source_column, target_field, target_label,
|
||||
is_editable, is_required, default_value, sort_order
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
groupCode,
|
||||
companyCode,
|
||||
m.sourceColumn,
|
||||
m.targetField,
|
||||
m.targetLabel || null,
|
||||
m.isEditable || "Y",
|
||||
m.isRequired || "N",
|
||||
m.defaultValue || null,
|
||||
m.sortOrder || i + 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "자동 입력 그룹이 생성되었습니다.",
|
||||
data: groupResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 수정
|
||||
*/
|
||||
export const updateAutoFillGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn,
|
||||
isActive,
|
||||
mappings,
|
||||
} = req.body;
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||
const checkParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 업데이트
|
||||
const updateSql = `
|
||||
UPDATE cascading_auto_fill_group SET
|
||||
group_name = COALESCE($1, group_name),
|
||||
description = COALESCE($2, description),
|
||||
master_table = COALESCE($3, master_table),
|
||||
master_value_column = COALESCE($4, master_value_column),
|
||||
master_label_column = COALESCE($5, master_label_column),
|
||||
is_active = COALESCE($6, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE group_code = $7 AND company_code = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const updateResult = await queryOne(updateSql, [
|
||||
groupName,
|
||||
description,
|
||||
masterTable,
|
||||
masterValueColumn,
|
||||
masterLabelColumn,
|
||||
isActive,
|
||||
groupCode,
|
||||
existing.company_code,
|
||||
]);
|
||||
|
||||
// 매핑 업데이트 (전체 교체 방식)
|
||||
if (mappings !== undefined) {
|
||||
// 기존 매핑 삭제
|
||||
await query(
|
||||
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
|
||||
[groupCode, existing.company_code]
|
||||
);
|
||||
|
||||
// 새 매핑 추가
|
||||
for (let i = 0; i < mappings.length; i++) {
|
||||
const m = mappings[i];
|
||||
await query(
|
||||
`INSERT INTO cascading_auto_fill_mapping (
|
||||
group_code, company_code, source_column, target_field, target_label,
|
||||
is_editable, is_required, default_value, sort_order
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
groupCode,
|
||||
existing.company_code,
|
||||
m.sourceColumn,
|
||||
m.targetField,
|
||||
m.targetLabel || null,
|
||||
m.isEditable || "Y",
|
||||
m.isRequired || "N",
|
||||
m.defaultValue || null,
|
||||
m.sortOrder || i + 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "자동 입력 그룹이 수정되었습니다.",
|
||||
data: updateResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 그룹 삭제
|
||||
*/
|
||||
export const deleteAutoFillGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||
const deleteParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING group_code`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "자동 입력 그룹이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 자동 입력 데이터 조회 (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 마스터 옵션 목록 조회
|
||||
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
||||
*/
|
||||
export const getAutoFillMasterOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 그룹 정보 조회
|
||||
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const group = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 마스터 테이블에서 옵션 조회
|
||||
const labelColumn = group.master_label_column || group.master_value_column;
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${group.master_value_column} as value,
|
||||
${labelColumn} as label
|
||||
FROM ${group.master_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
|
||||
if (companyCode !== "*") {
|
||||
// company_code 컬럼 존재 여부 확인
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[group.master_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${paramIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("자동 입력 마스터 옵션 조회", {
|
||||
groupCode,
|
||||
count: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 입력 데이터 조회
|
||||
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||
*/
|
||||
export const getAutoFillData = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const { masterValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!masterValue) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "masterValue 파라미터가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 정보 조회
|
||||
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const group = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 매핑 정보 조회
|
||||
const mappingSql = `
|
||||
SELECT * FROM cascading_auto_fill_mapping
|
||||
WHERE group_code = $1 AND company_code = $2
|
||||
ORDER BY sort_order
|
||||
`;
|
||||
const mappings = await query(mappingSql, [groupCode, group.company_code]);
|
||||
|
||||
if (mappings.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
mappings: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 마스터 테이블에서 데이터 조회
|
||||
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
|
||||
let dataSql = `
|
||||
SELECT ${sourceColumns}
|
||||
FROM ${group.master_table}
|
||||
WHERE ${group.master_value_column} = $1
|
||||
`;
|
||||
const dataParams: any[] = [masterValue];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[group.master_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
dataSql += ` AND company_code = $${paramIndex++}`;
|
||||
dataParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
const dataResult = await queryOne(dataSql, dataParams);
|
||||
|
||||
// 결과를 target_field 기준으로 변환
|
||||
const autoFillData: Record<string, any> = {};
|
||||
const mappingInfo: any[] = [];
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const sourceValue = dataResult?.[mapping.source_column];
|
||||
const finalValue =
|
||||
sourceValue !== null && sourceValue !== undefined
|
||||
? sourceValue
|
||||
: mapping.default_value;
|
||||
|
||||
autoFillData[mapping.target_field] = finalValue;
|
||||
mappingInfo.push({
|
||||
targetField: mapping.target_field,
|
||||
targetLabel: mapping.target_label,
|
||||
value: finalValue,
|
||||
isEditable: mapping.is_editable === "Y",
|
||||
isRequired: mapping.is_required === "Y",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("자동 입력 데이터 조회", {
|
||||
groupCode,
|
||||
masterValue,
|
||||
fieldCount: mappingInfo.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: autoFillData,
|
||||
mappings: mappingInfo,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "자동 입력 데이터 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,562 +0,0 @@
|
|||
/**
|
||||
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
||||
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 조건부 연쇄 규칙 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 목록 조회
|
||||
*/
|
||||
export const getConditions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive, relationCode, relationType } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM cascading_condition
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
// 관계 코드 필터
|
||||
if (relationCode) {
|
||||
sql += ` AND relation_code = $${paramIndex++}`;
|
||||
params.push(relationCode);
|
||||
}
|
||||
|
||||
// 관계 유형 필터 (RELATION / HIERARCHY)
|
||||
if (relationType) {
|
||||
sql += ` AND relation_type = $${paramIndex++}`;
|
||||
params.push(relationType);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY relation_code, priority, condition_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 목록 조회", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 상세 조회
|
||||
*/
|
||||
export const getConditionDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||
const params: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, params);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 생성
|
||||
*/
|
||||
export const createCondition = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
relationType = "RELATION",
|
||||
relationCode,
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator = "EQ",
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority = 0,
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!relationCode ||
|
||||
!conditionName ||
|
||||
!conditionField ||
|
||||
!conditionValue ||
|
||||
!filterColumn ||
|
||||
!filterValues
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_condition (
|
||||
relation_type, relation_code, condition_name,
|
||||
condition_field, condition_operator, condition_value,
|
||||
filter_column, filter_values, priority,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
relationType,
|
||||
relationCode,
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 생성", {
|
||||
conditionId: result?.condition_id,
|
||||
relationCode,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 생성되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 수정
|
||||
*/
|
||||
export const updateCondition = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 규칙 확인
|
||||
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||
const checkParams: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_condition SET
|
||||
condition_name = COALESCE($1, condition_name),
|
||||
condition_field = COALESCE($2, condition_field),
|
||||
condition_operator = COALESCE($3, condition_operator),
|
||||
condition_value = COALESCE($4, condition_value),
|
||||
filter_column = COALESCE($5, filter_column),
|
||||
filter_values = COALESCE($6, filter_values),
|
||||
priority = COALESCE($7, priority),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE condition_id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
isActive,
|
||||
Number(conditionId),
|
||||
]);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 삭제
|
||||
*/
|
||||
export const deleteCondition = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
|
||||
const deleteParams: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING condition_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 조건부 필터링 적용 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 조건에 따른 필터링된 옵션 조회
|
||||
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||
*/
|
||||
export const getFilteredOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { relationCode } = req.params;
|
||||
const { conditionFieldValue, parentValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 1. 기본 연쇄 관계 정보 조회
|
||||
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
|
||||
const relationParams: any[] = [relationCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
relationSql += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
|
||||
const relation = await queryOne(relationSql, relationParams);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 해당 관계에 적용되는 조건 규칙 조회
|
||||
let conditionSql = `
|
||||
SELECT * FROM cascading_condition
|
||||
WHERE relation_code = $1 AND is_active = 'Y'
|
||||
`;
|
||||
const conditionParams: any[] = [relationCode];
|
||||
let conditionParamIndex = 2;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
|
||||
conditionParams.push(companyCode);
|
||||
}
|
||||
|
||||
conditionSql += ` ORDER BY priority DESC`;
|
||||
|
||||
const conditions = await query(conditionSql, conditionParams);
|
||||
|
||||
// 3. 조건에 맞는 규칙 찾기
|
||||
let matchedCondition: any = null;
|
||||
|
||||
if (conditionFieldValue) {
|
||||
for (const cond of conditions) {
|
||||
const isMatch = evaluateCondition(
|
||||
conditionFieldValue as string,
|
||||
cond.condition_operator,
|
||||
cond.condition_value
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchedCondition = cond;
|
||||
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 옵션 조회 쿼리 생성
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label
|
||||
FROM ${relation.child_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 부모 값 필터 (기본 연쇄)
|
||||
if (parentValue) {
|
||||
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(parentValue);
|
||||
}
|
||||
|
||||
// 조건부 필터 적용
|
||||
if (matchedCondition) {
|
||||
const filterValues = matchedCondition.filter_values
|
||||
.split(",")
|
||||
.map((v: string) => v.trim());
|
||||
const placeholders = filterValues
|
||||
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
|
||||
.join(",");
|
||||
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||
optionsParams.push(...filterValues);
|
||||
optionsParamIndex += filterValues.length;
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.child_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (relation.child_order_column) {
|
||||
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsSql += ` ORDER BY ${relation.child_label_column}`;
|
||||
}
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("조건부 필터링 옵션 조회", {
|
||||
relationCode,
|
||||
conditionFieldValue,
|
||||
parentValue,
|
||||
matchedCondition: matchedCondition?.condition_name,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
appliedCondition: matchedCondition
|
||||
? {
|
||||
conditionId: matchedCondition.condition_id,
|
||||
conditionName: matchedCondition.condition_name,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 필터링 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건 평가 함수
|
||||
*/
|
||||
function evaluateCondition(
|
||||
actualValue: string,
|
||||
operator: string,
|
||||
expectedValue: string
|
||||
): boolean {
|
||||
const actual = actualValue.toLowerCase().trim();
|
||||
const expected = expectedValue.toLowerCase().trim();
|
||||
|
||||
switch (operator.toUpperCase()) {
|
||||
case "EQ":
|
||||
case "=":
|
||||
case "EQUALS":
|
||||
return actual === expected;
|
||||
|
||||
case "NEQ":
|
||||
case "!=":
|
||||
case "<>":
|
||||
case "NOT_EQUALS":
|
||||
return actual !== expected;
|
||||
|
||||
case "CONTAINS":
|
||||
case "LIKE":
|
||||
return actual.includes(expected);
|
||||
|
||||
case "NOT_CONTAINS":
|
||||
case "NOT_LIKE":
|
||||
return !actual.includes(expected);
|
||||
|
||||
case "STARTS_WITH":
|
||||
return actual.startsWith(expected);
|
||||
|
||||
case "ENDS_WITH":
|
||||
return actual.endsWith(expected);
|
||||
|
||||
case "IN":
|
||||
const inValues = expected.split(",").map((v) => v.trim());
|
||||
return inValues.includes(actual);
|
||||
|
||||
case "NOT_IN":
|
||||
const notInValues = expected.split(",").map((v) => v.trim());
|
||||
return !notInValues.includes(actual);
|
||||
|
||||
case "GT":
|
||||
case ">":
|
||||
return parseFloat(actual) > parseFloat(expected);
|
||||
|
||||
case "GTE":
|
||||
case ">=":
|
||||
return parseFloat(actual) >= parseFloat(expected);
|
||||
|
||||
case "LT":
|
||||
case "<":
|
||||
return parseFloat(actual) < parseFloat(expected);
|
||||
|
||||
case "LTE":
|
||||
case "<=":
|
||||
return parseFloat(actual) <= parseFloat(expected);
|
||||
|
||||
case "IS_NULL":
|
||||
case "NULL":
|
||||
return actual === "" || actual === "null" || actual === "undefined";
|
||||
|
||||
case "IS_NOT_NULL":
|
||||
case "NOT_NULL":
|
||||
return actual !== "" && actual !== "null" && actual !== "undefined";
|
||||
|
||||
default:
|
||||
logger.warn(`알 수 없는 연산자: ${operator}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,772 +0,0 @@
|
|||
/**
|
||||
* 다단계 계층 (Hierarchy) 컨트롤러
|
||||
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 계층 그룹 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 계층 그룹 목록 조회
|
||||
*/
|
||||
export const getHierarchyGroups = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive, hierarchyType } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
|
||||
FROM cascading_hierarchy_group g
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
if (hierarchyType) {
|
||||
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
|
||||
params.push(hierarchyType);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY g.group_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 상세 조회 (레벨 포함)
|
||||
*/
|
||||
export const getHierarchyGroupDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 그룹 조회
|
||||
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
groupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const group = await queryOne(groupSql, groupParams);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 레벨 조회
|
||||
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||
const levelParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
levelSql += ` AND company_code = $2`;
|
||||
levelParams.push(companyCode);
|
||||
}
|
||||
|
||||
levelSql += ` ORDER BY level_order`;
|
||||
|
||||
const levels = await query(levelSql, levelParams);
|
||||
|
||||
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...group,
|
||||
levels: levels,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 코드 자동 생성 함수
|
||||
*/
|
||||
const generateHierarchyGroupCode = async (
|
||||
companyCode: string
|
||||
): Promise<string> => {
|
||||
const prefix = "HG";
|
||||
const result = await queryOne(
|
||||
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 생성
|
||||
*/
|
||||
export const createHierarchyGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
hierarchyType = "MULTI_TABLE",
|
||||
maxLevels,
|
||||
isFixedLevels = "Y",
|
||||
// Self-reference 설정
|
||||
selfRefTable,
|
||||
selfRefIdColumn,
|
||||
selfRefParentColumn,
|
||||
selfRefValueColumn,
|
||||
selfRefLabelColumn,
|
||||
selfRefLevelColumn,
|
||||
selfRefOrderColumn,
|
||||
// BOM 설정
|
||||
bomTable,
|
||||
bomParentColumn,
|
||||
bomChildColumn,
|
||||
bomItemTable,
|
||||
bomItemIdColumn,
|
||||
bomItemLabelColumn,
|
||||
bomQtyColumn,
|
||||
bomLevelColumn,
|
||||
// 메시지
|
||||
emptyMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
// 레벨 (MULTI_TABLE 타입인 경우)
|
||||
levels = [],
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!groupName || !hierarchyType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
|
||||
});
|
||||
}
|
||||
|
||||
// 그룹 코드 자동 생성
|
||||
const groupCode = await generateHierarchyGroupCode(companyCode);
|
||||
|
||||
// 그룹 생성
|
||||
const insertGroupSql = `
|
||||
INSERT INTO cascading_hierarchy_group (
|
||||
group_code, group_name, description, hierarchy_type,
|
||||
max_levels, is_fixed_levels,
|
||||
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
|
||||
bom_table, bom_parent_column, bom_child_column,
|
||||
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
|
||||
empty_message, no_options_message, loading_message,
|
||||
company_code, is_active, created_by, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const group = await queryOne(insertGroupSql, [
|
||||
groupCode,
|
||||
groupName,
|
||||
description || null,
|
||||
hierarchyType,
|
||||
maxLevels || null,
|
||||
isFixedLevels,
|
||||
selfRefTable || null,
|
||||
selfRefIdColumn || null,
|
||||
selfRefParentColumn || null,
|
||||
selfRefValueColumn || null,
|
||||
selfRefLabelColumn || null,
|
||||
selfRefLevelColumn || null,
|
||||
selfRefOrderColumn || null,
|
||||
bomTable || null,
|
||||
bomParentColumn || null,
|
||||
bomChildColumn || null,
|
||||
bomItemTable || null,
|
||||
bomItemIdColumn || null,
|
||||
bomItemLabelColumn || null,
|
||||
bomQtyColumn || null,
|
||||
bomLevelColumn || null,
|
||||
emptyMessage || "선택해주세요",
|
||||
noOptionsMessage || "옵션이 없습니다",
|
||||
loadingMessage || "로딩 중...",
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
// 레벨 생성 (MULTI_TABLE 타입인 경우)
|
||||
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
|
||||
for (const level of levels) {
|
||||
await query(
|
||||
`INSERT INTO cascading_hierarchy_level (
|
||||
group_code, company_code, level_order, level_name, level_code,
|
||||
table_name, value_column, label_column, parent_key_column,
|
||||
filter_column, filter_value, order_column, order_direction,
|
||||
placeholder, is_required, is_searchable, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
|
||||
[
|
||||
groupCode,
|
||||
companyCode,
|
||||
level.levelOrder,
|
||||
level.levelName,
|
||||
level.levelCode || null,
|
||||
level.tableName,
|
||||
level.valueColumn,
|
||||
level.labelColumn,
|
||||
level.parentKeyColumn || null,
|
||||
level.filterColumn || null,
|
||||
level.filterValue || null,
|
||||
level.orderColumn || null,
|
||||
level.orderDirection || "ASC",
|
||||
level.placeholder || `${level.levelName} 선택`,
|
||||
level.isRequired || "Y",
|
||||
level.isSearchable || "N",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "계층 그룹이 생성되었습니다.",
|
||||
data: group,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 수정
|
||||
*/
|
||||
export const updateHierarchyGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
const {
|
||||
groupName,
|
||||
description,
|
||||
maxLevels,
|
||||
isFixedLevels,
|
||||
emptyMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||
const checkParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_hierarchy_group SET
|
||||
group_name = COALESCE($1, group_name),
|
||||
description = COALESCE($2, description),
|
||||
max_levels = COALESCE($3, max_levels),
|
||||
is_fixed_levels = COALESCE($4, is_fixed_levels),
|
||||
empty_message = COALESCE($5, empty_message),
|
||||
no_options_message = COALESCE($6, no_options_message),
|
||||
loading_message = COALESCE($7, loading_message),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_by = $9,
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE group_code = $10 AND company_code = $11
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
groupName,
|
||||
description,
|
||||
maxLevels,
|
||||
isFixedLevels,
|
||||
emptyMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
isActive,
|
||||
userId,
|
||||
groupCode,
|
||||
existing.company_code,
|
||||
]);
|
||||
|
||||
logger.info("계층 그룹 수정", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "계층 그룹이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 계층 그룹 삭제
|
||||
*/
|
||||
export const deleteHierarchyGroup = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 레벨 먼저 삭제
|
||||
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||
const levelParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteLevelsSql += ` AND company_code = $2`;
|
||||
levelParams.push(companyCode);
|
||||
}
|
||||
|
||||
await query(deleteLevelsSql, levelParams);
|
||||
|
||||
// 그룹 삭제
|
||||
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||
const groupParams: any[] = [groupCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteGroupSql += ` AND company_code = $2`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteGroupSql += ` RETURNING group_code`;
|
||||
|
||||
const result = await queryOne(deleteGroupSql, groupParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("계층 그룹 삭제", { groupCode, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "계층 그룹이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 그룹 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "계층 그룹 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 계층 레벨 관리
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 레벨 추가
|
||||
*/
|
||||
export const addLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { groupCode } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
levelOrder,
|
||||
levelName,
|
||||
levelCode,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
orderColumn,
|
||||
orderDirection = "ASC",
|
||||
placeholder,
|
||||
isRequired = "Y",
|
||||
isSearchable = "N",
|
||||
} = req.body;
|
||||
|
||||
// 그룹 존재 확인
|
||||
const groupCheck = await queryOne(
|
||||
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
|
||||
[groupCode, companyCode]
|
||||
);
|
||||
|
||||
if (!groupCheck) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "계층 그룹을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_hierarchy_level (
|
||||
group_code, company_code, level_order, level_name, level_code,
|
||||
table_name, value_column, label_column, parent_key_column,
|
||||
filter_column, filter_value, order_column, order_direction,
|
||||
placeholder, is_required, is_searchable, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
groupCode,
|
||||
groupCheck.company_code,
|
||||
levelOrder,
|
||||
levelName,
|
||||
levelCode || null,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn || null,
|
||||
filterColumn || null,
|
||||
filterValue || null,
|
||||
orderColumn || null,
|
||||
orderDirection,
|
||||
placeholder || `${levelName} 선택`,
|
||||
isRequired,
|
||||
isSearchable,
|
||||
]);
|
||||
|
||||
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "레벨이 추가되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 추가 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "레벨 추가에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 레벨 수정
|
||||
*/
|
||||
export const updateLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { levelId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
levelName,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
orderColumn,
|
||||
orderDirection,
|
||||
placeholder,
|
||||
isRequired,
|
||||
isSearchable,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||
const checkParams: any[] = [Number(levelId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_hierarchy_level SET
|
||||
level_name = COALESCE($1, level_name),
|
||||
table_name = COALESCE($2, table_name),
|
||||
value_column = COALESCE($3, value_column),
|
||||
label_column = COALESCE($4, label_column),
|
||||
parent_key_column = COALESCE($5, parent_key_column),
|
||||
filter_column = COALESCE($6, filter_column),
|
||||
filter_value = COALESCE($7, filter_value),
|
||||
order_column = COALESCE($8, order_column),
|
||||
order_direction = COALESCE($9, order_direction),
|
||||
placeholder = COALESCE($10, placeholder),
|
||||
is_required = COALESCE($11, is_required),
|
||||
is_searchable = COALESCE($12, is_searchable),
|
||||
is_active = COALESCE($13, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE level_id = $14
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
levelName,
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
parentKeyColumn,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
orderColumn,
|
||||
orderDirection,
|
||||
placeholder,
|
||||
isRequired,
|
||||
isSearchable,
|
||||
isActive,
|
||||
Number(levelId),
|
||||
]);
|
||||
|
||||
logger.info("계층 레벨 수정", { levelId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "레벨이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "레벨 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 레벨 삭제
|
||||
*/
|
||||
export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { levelId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||
const deleteParams: any[] = [Number(levelId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING level_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("계층 레벨 삭제", { levelId });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "레벨이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "레벨 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 계층 옵션 조회 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 특정 레벨의 옵션 조회
|
||||
*/
|
||||
export const getLevelOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { groupCode, levelOrder } = req.params;
|
||||
const { parentValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 레벨 정보 조회
|
||||
let levelSql = `
|
||||
SELECT l.*, g.hierarchy_type
|
||||
FROM cascading_hierarchy_level l
|
||||
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
|
||||
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
|
||||
`;
|
||||
const levelParams: any[] = [groupCode, Number(levelOrder)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
levelSql += ` AND l.company_code = $3`;
|
||||
levelParams.push(companyCode);
|
||||
}
|
||||
|
||||
const level = await queryOne(levelSql, levelParams);
|
||||
|
||||
if (!level) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레벨을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 옵션 조회
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${level.value_column} as value,
|
||||
${level.label_column} as label
|
||||
FROM ${level.table_name}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 부모 값 필터 (레벨 2 이상)
|
||||
if (level.parent_key_column && parentValue) {
|
||||
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(parentValue);
|
||||
}
|
||||
|
||||
// 고정 필터
|
||||
if (level.filter_column && level.filter_value) {
|
||||
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(level.filter_value);
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[level.table_name]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (level.order_column) {
|
||||
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsSql += ` ORDER BY ${level.label_column}`;
|
||||
}
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("계층 레벨 옵션 조회", {
|
||||
groupCode,
|
||||
levelOrder,
|
||||
parentValue,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
levelInfo: {
|
||||
levelId: level.level_id,
|
||||
levelName: level.level_name,
|
||||
placeholder: level.placeholder,
|
||||
isRequired: level.is_required,
|
||||
isSearchable: level.is_searchable,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,537 +0,0 @@
|
|||
/**
|
||||
* 상호 배제 (Mutual Exclusion) 컨트롤러
|
||||
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 상호 배제 규칙 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 목록 조회
|
||||
*/
|
||||
export const getExclusions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM cascading_mutual_exclusion
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY exclusion_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("상호 배제 규칙 목록 조회", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 상세 조회
|
||||
*/
|
||||
export const getExclusionDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||
const params: any[] = [Number(exclusionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, params);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 배제 코드 자동 생성 함수
|
||||
*/
|
||||
const generateExclusionCode = async (companyCode: string): Promise<string> => {
|
||||
const prefix = "EX";
|
||||
const result = await queryOne(
|
||||
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 생성
|
||||
*/
|
||||
export const createExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
exclusionName,
|
||||
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
exclusionType = "SAME_VALUE",
|
||||
errorMessage = "동일한 값을 선택할 수 없습니다",
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
||||
});
|
||||
}
|
||||
|
||||
// 배제 코드 자동 생성
|
||||
const exclusionCode = await generateExclusionCode(companyCode);
|
||||
|
||||
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
|
||||
const existingCheck = await queryOne(
|
||||
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
|
||||
[exclusionCode, companyCode]
|
||||
);
|
||||
|
||||
if (existingCheck) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 배제 코드입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_mutual_exclusion (
|
||||
exclusion_code, exclusion_name, field_names,
|
||||
source_table, value_column, label_column,
|
||||
exclusion_type, error_message,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
exclusionCode,
|
||||
exclusionName,
|
||||
fieldNames,
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn || null,
|
||||
exclusionType,
|
||||
errorMessage,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "상호 배제 규칙이 생성되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 수정
|
||||
*/
|
||||
export const updateExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
exclusionName,
|
||||
fieldNames,
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
exclusionType,
|
||||
errorMessage,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 규칙 확인
|
||||
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||
const checkParams: any[] = [Number(exclusionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_mutual_exclusion SET
|
||||
exclusion_name = COALESCE($1, exclusion_name),
|
||||
field_names = COALESCE($2, field_names),
|
||||
source_table = COALESCE($3, source_table),
|
||||
value_column = COALESCE($4, value_column),
|
||||
label_column = COALESCE($5, label_column),
|
||||
exclusion_type = COALESCE($6, exclusion_type),
|
||||
error_message = COALESCE($7, error_message),
|
||||
is_active = COALESCE($8, is_active)
|
||||
WHERE exclusion_id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
exclusionName,
|
||||
fieldNames,
|
||||
sourceTable,
|
||||
valueColumn,
|
||||
labelColumn,
|
||||
exclusionType,
|
||||
errorMessage,
|
||||
isActive,
|
||||
Number(exclusionId),
|
||||
]);
|
||||
|
||||
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "상호 배제 규칙이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상호 배제 규칙 삭제
|
||||
*/
|
||||
export const deleteExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||
const deleteParams: any[] = [Number(exclusionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING exclusion_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "상호 배제 규칙이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 상호 배제 검증 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 상호 배제 검증
|
||||
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
||||
*/
|
||||
export const validateExclusion = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionCode } = req.params;
|
||||
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 배제 규칙 조회
|
||||
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||
const exclusionParams: any[] = [exclusionCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
exclusionSql += ` AND company_code = $2`;
|
||||
exclusionParams.push(companyCode);
|
||||
}
|
||||
|
||||
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||
|
||||
if (!exclusion) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 필드명 파싱
|
||||
const fields = exclusion.field_names
|
||||
.split(",")
|
||||
.map((f: string) => f.trim());
|
||||
|
||||
// 필드 값 수집
|
||||
const values: string[] = [];
|
||||
for (const field of fields) {
|
||||
if (fieldValues[field]) {
|
||||
values.push(fieldValues[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// 상호 배제 검증
|
||||
let isValid = true;
|
||||
let errorMessage = null;
|
||||
let conflictingFields: string[] = [];
|
||||
|
||||
if (exclusion.exclusion_type === "SAME_VALUE") {
|
||||
// 같은 값이 있는지 확인
|
||||
const uniqueValues = new Set(values);
|
||||
if (uniqueValues.size !== values.length) {
|
||||
isValid = false;
|
||||
errorMessage = exclusion.error_message;
|
||||
|
||||
// 충돌하는 필드 찾기
|
||||
const valueCounts: Record<string, string[]> = {};
|
||||
for (const field of fields) {
|
||||
const val = fieldValues[field];
|
||||
if (val) {
|
||||
if (!valueCounts[val]) {
|
||||
valueCounts[val] = [];
|
||||
}
|
||||
valueCounts[val].push(field);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, fieldList] of Object.entries(valueCounts)) {
|
||||
if (fieldList.length > 1) {
|
||||
conflictingFields = fieldList;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("상호 배제 검증", {
|
||||
exclusionCode,
|
||||
isValid,
|
||||
fieldValues,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isValid,
|
||||
errorMessage: isValid ? null : errorMessage,
|
||||
conflictingFields,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 검증 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 검증에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 필드에 대한 배제 옵션 조회
|
||||
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
||||
*/
|
||||
export const getExcludedOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { exclusionCode } = req.params;
|
||||
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 배제 규칙 조회
|
||||
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||
const exclusionParams: any[] = [exclusionCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
exclusionSql += ` AND company_code = $2`;
|
||||
exclusionParams.push(companyCode);
|
||||
}
|
||||
|
||||
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||
|
||||
if (!exclusion) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 옵션 조회
|
||||
const labelColumn = exclusion.label_column || exclusion.value_column;
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${exclusion.value_column} as value,
|
||||
${labelColumn} as label
|
||||
FROM ${exclusion.source_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[exclusion.source_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 선택된 값 제외
|
||||
if (selectedValues) {
|
||||
const excludeValues = (selectedValues as string)
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v);
|
||||
if (excludeValues.length > 0) {
|
||||
const placeholders = excludeValues
|
||||
.map((_, i) => `$${optionsParamIndex + i}`)
|
||||
.join(",");
|
||||
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
||||
optionsParams.push(...excludeValues);
|
||||
}
|
||||
}
|
||||
|
||||
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("상호 배제 옵션 조회", {
|
||||
exclusionCode,
|
||||
currentField,
|
||||
excludedCount: (selectedValues as string)?.split(",").length || 0,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상호 배제 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,798 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
/**
|
||||
* 연쇄 관계 목록 조회
|
||||
*/
|
||||
export const getCascadingRelations = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
relation_id,
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active,
|
||||
created_by,
|
||||
created_date,
|
||||
updated_by,
|
||||
updated_date
|
||||
FROM cascading_relation
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
// - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능
|
||||
// - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가)
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 활성 상태 필터링
|
||||
if (isActive !== undefined) {
|
||||
query += ` AND is_active = $${paramIndex}`;
|
||||
params.push(isActive);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY relation_name ASC`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("연쇄 관계 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 상세 조회
|
||||
*/
|
||||
export const getCascadingRelationById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
relation_id,
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active,
|
||||
created_by,
|
||||
created_date,
|
||||
updated_by,
|
||||
updated_date
|
||||
FROM cascading_relation
|
||||
WHERE relation_id = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [id];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 코드로 조회
|
||||
*/
|
||||
export const getCascadingRelationByCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
relation_id,
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active
|
||||
FROM cascading_relation
|
||||
WHERE relation_code = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
|
||||
const params: any[] = [code];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
query += ` LIMIT 1`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 코드 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 생성
|
||||
*/
|
||||
export const createCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
const {
|
||||
relationCode,
|
||||
relationName,
|
||||
description,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn,
|
||||
childOrderDirection,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
clearOnParentChange,
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!relationCode ||
|
||||
!relationName ||
|
||||
!parentTable ||
|
||||
!parentValueColumn ||
|
||||
!childTable ||
|
||||
!childFilterColumn ||
|
||||
!childValueColumn ||
|
||||
!childLabelColumn
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 코드 체크
|
||||
const duplicateCheck = await pool.query(
|
||||
`SELECT relation_id FROM cascading_relation
|
||||
WHERE relation_code = $1 AND company_code = $2`,
|
||||
[relationCode, companyCode]
|
||||
);
|
||||
|
||||
if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 관계 코드입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO cascading_relation (
|
||||
relation_code,
|
||||
relation_name,
|
||||
description,
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column,
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction,
|
||||
empty_parent_message,
|
||||
no_options_message,
|
||||
loading_message,
|
||||
clear_on_parent_change,
|
||||
company_code,
|
||||
is_active,
|
||||
created_by,
|
||||
created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
relationCode,
|
||||
relationName,
|
||||
description || null,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn || null,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn || null,
|
||||
childOrderDirection || "ASC",
|
||||
emptyParentMessage || "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage || "선택 가능한 항목이 없습니다",
|
||||
loadingMessage || "로딩 중...",
|
||||
clearOnParentChange !== false ? "Y" : "N",
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("연쇄 관계 생성", {
|
||||
relationId: result.rows[0].relation_id,
|
||||
relationCode,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
message: "연쇄 관계가 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 생성 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 수정
|
||||
*/
|
||||
export const updateCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
const {
|
||||
relationName,
|
||||
description,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn,
|
||||
childOrderDirection,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
clearOnParentChange,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 권한 체크
|
||||
const existingCheck = await pool.query(
|
||||
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existingCheck.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 다른 회사의 데이터는 수정 불가 (최고 관리자 제외)
|
||||
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||
if (
|
||||
companyCode !== "*" &&
|
||||
existingCompanyCode !== companyCode &&
|
||||
existingCompanyCode !== "*"
|
||||
) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "수정 권한이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE cascading_relation SET
|
||||
relation_name = COALESCE($1, relation_name),
|
||||
description = COALESCE($2, description),
|
||||
parent_table = COALESCE($3, parent_table),
|
||||
parent_value_column = COALESCE($4, parent_value_column),
|
||||
parent_label_column = COALESCE($5, parent_label_column),
|
||||
child_table = COALESCE($6, child_table),
|
||||
child_filter_column = COALESCE($7, child_filter_column),
|
||||
child_value_column = COALESCE($8, child_value_column),
|
||||
child_label_column = COALESCE($9, child_label_column),
|
||||
child_order_column = COALESCE($10, child_order_column),
|
||||
child_order_direction = COALESCE($11, child_order_direction),
|
||||
empty_parent_message = COALESCE($12, empty_parent_message),
|
||||
no_options_message = COALESCE($13, no_options_message),
|
||||
loading_message = COALESCE($14, loading_message),
|
||||
clear_on_parent_change = COALESCE($15, clear_on_parent_change),
|
||||
is_active = COALESCE($16, is_active),
|
||||
updated_by = $17,
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE relation_id = $18
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
relationName,
|
||||
description,
|
||||
parentTable,
|
||||
parentValueColumn,
|
||||
parentLabelColumn,
|
||||
childTable,
|
||||
childFilterColumn,
|
||||
childValueColumn,
|
||||
childLabelColumn,
|
||||
childOrderColumn,
|
||||
childOrderDirection,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
clearOnParentChange !== undefined
|
||||
? clearOnParentChange
|
||||
? "Y"
|
||||
: "N"
|
||||
: null,
|
||||
isActive !== undefined ? (isActive ? "Y" : "N") : null,
|
||||
userId,
|
||||
id,
|
||||
]);
|
||||
|
||||
logger.info("연쇄 관계 수정", {
|
||||
relationId: id,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
message: "연쇄 관계가 수정되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 수정 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계 삭제
|
||||
*/
|
||||
export const deleteCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
// 권한 체크
|
||||
const existingCheck = await pool.query(
|
||||
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existingCheck.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외)
|
||||
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||
if (
|
||||
companyCode !== "*" &&
|
||||
existingCompanyCode !== companyCode &&
|
||||
existingCompanyCode !== "*"
|
||||
) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "삭제 권한이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 소프트 삭제 (is_active = 'N')
|
||||
await pool.query(
|
||||
`UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`,
|
||||
[userId, id]
|
||||
);
|
||||
|
||||
logger.info("연쇄 관계 삭제", {
|
||||
relationId: id,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "연쇄 관계가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 관계 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 관계 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||
* parent_table에서 전체 옵션을 조회합니다.
|
||||
*/
|
||||
export const getParentOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 관계 정보 조회
|
||||
let relationQuery = `
|
||||
SELECT
|
||||
parent_table,
|
||||
parent_value_column,
|
||||
parent_label_column
|
||||
FROM cascading_relation
|
||||
WHERE relation_code = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
|
||||
const relationParams: any[] = [code];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
relationQuery += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
relationQuery += ` LIMIT 1`;
|
||||
|
||||
const relationResult = await pool.query(relationQuery, relationParams);
|
||||
|
||||
if (relationResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const relation = relationResult.rows[0];
|
||||
|
||||
// 라벨 컬럼이 없으면 값 컬럼 사용
|
||||
const labelColumn =
|
||||
relation.parent_label_column || relation.parent_value_column;
|
||||
|
||||
// 부모 옵션 조회
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
${relation.parent_value_column} as value,
|
||||
${labelColumn} as label
|
||||
FROM ${relation.parent_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||
const tableInfoResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.parent_table]
|
||||
);
|
||||
|
||||
const optionsParams: any[] = [];
|
||||
|
||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||
if (
|
||||
tableInfoResult.rowCount &&
|
||||
tableInfoResult.rowCount > 0 &&
|
||||
companyCode !== "*"
|
||||
) {
|
||||
optionsQuery += ` AND company_code = $1`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
|
||||
// status 컬럼이 있으면 활성 상태만 조회
|
||||
const statusInfoResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'status'`,
|
||||
[relation.parent_table]
|
||||
);
|
||||
|
||||
if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) {
|
||||
optionsQuery += ` AND (status IS NULL OR status != 'N')`;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
optionsQuery += ` ORDER BY ${labelColumn} ASC`;
|
||||
|
||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||
|
||||
logger.info("부모 옵션 조회", {
|
||||
relationCode: code,
|
||||
parentTable: relation.parent_table,
|
||||
optionsCount: optionsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: optionsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("부모 옵션 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "부모 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 관계로 자식 옵션 조회
|
||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||
*
|
||||
* 다중 부모값 지원:
|
||||
* - parentValue: 단일 값 (예: "공정검사")
|
||||
* - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열)
|
||||
*/
|
||||
export const getCascadingOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const { parentValue, parentValues } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 다중 부모값 파싱
|
||||
let parentValueArray: string[] = [];
|
||||
|
||||
if (parentValues) {
|
||||
// parentValues가 있으면 우선 사용 (다중 선택)
|
||||
if (Array.isArray(parentValues)) {
|
||||
parentValueArray = parentValues.map(v => String(v));
|
||||
} else {
|
||||
// 콤마로 구분된 문자열
|
||||
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
} else if (parentValue) {
|
||||
// 기존 단일 값 호환
|
||||
parentValueArray = [String(parentValue)];
|
||||
}
|
||||
|
||||
if (parentValueArray.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "부모 값이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 관계 정보 조회
|
||||
let relationQuery = `
|
||||
SELECT
|
||||
child_table,
|
||||
child_filter_column,
|
||||
child_value_column,
|
||||
child_label_column,
|
||||
child_order_column,
|
||||
child_order_direction
|
||||
FROM cascading_relation
|
||||
WHERE relation_code = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
|
||||
const relationParams: any[] = [code];
|
||||
|
||||
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||
if (companyCode !== "*") {
|
||||
relationQuery += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
relationQuery += ` LIMIT 1`;
|
||||
|
||||
const relationResult = await pool.query(relationQuery, relationParams);
|
||||
|
||||
if (relationResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const relation = relationResult.rows[0];
|
||||
|
||||
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
|
||||
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
|
||||
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
|
||||
let optionsQuery = `
|
||||
SELECT DISTINCT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label,
|
||||
${relation.child_filter_column} as parent_value
|
||||
FROM ${relation.child_table}
|
||||
WHERE ${relation.child_filter_column} IN (${placeholders})
|
||||
`;
|
||||
|
||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||
const tableInfoResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.child_table]
|
||||
);
|
||||
|
||||
const optionsParams: any[] = [...parentValueArray];
|
||||
let paramIndex = parentValueArray.length + 1;
|
||||
|
||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||
if (
|
||||
tableInfoResult.rowCount &&
|
||||
tableInfoResult.rowCount > 0 &&
|
||||
companyCode !== "*"
|
||||
) {
|
||||
optionsQuery += ` AND company_code = $${paramIndex}`;
|
||||
optionsParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (relation.child_order_column) {
|
||||
optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`;
|
||||
}
|
||||
|
||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||
|
||||
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
|
||||
relationCode: code,
|
||||
parentValues: parentValueArray,
|
||||
optionsCount: optionsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: optionsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("연쇄 옵션 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,456 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import pool from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 병합 - 모든 관련 테이블에 적용
|
||||
* 데이터(레코드)는 삭제하지 않고, 컬럼 값만 변경
|
||||
*/
|
||||
export async function mergeCodeAllTables(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { columnName, oldValue, newValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!columnName || !oldValue || !newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (columnName, oldValue, newValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 값으로 병합 시도 방지
|
||||
if (oldValue === newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "기존 값과 새 값이 동일합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("코드 병합 시작", {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM merge_code_all_tables($1, $2, $3, $4)",
|
||||
[columnName, oldValue, newValue, companyCode]
|
||||
);
|
||||
|
||||
// 결과 처리 (pool.query 반환 타입 처리)
|
||||
const affectedTables = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = affectedTables.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.rows_updated || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("코드 병합 완료", {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTablesCount: affectedTables.length,
|
||||
totalRowsUpdated: totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||
data: {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTables: affectedTables.map((row) => ({
|
||||
tableName: row.table_name,
|
||||
rowsUpdated: parseInt(row.rows_updated),
|
||||
})),
|
||||
totalRowsUpdated: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("코드 병합 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CODE_MERGE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컬럼을 가진 테이블 목록 조회
|
||||
*/
|
||||
export async function getTablesWithColumn(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { columnName } = req.params;
|
||||
|
||||
try {
|
||||
if (!columnName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "컬럼명이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("컬럼을 가진 테이블 목록 조회", { columnName });
|
||||
|
||||
const query = `
|
||||
SELECT DISTINCT t.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
WHERE c.column_name = $1
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
ORDER BY t.table_name
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [columnName]);
|
||||
const rows = (result as any).rows || [];
|
||||
|
||||
logger.info(`컬럼을 가진 테이블 조회 완료: ${rows.length}개`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "테이블 목록 조회 성공",
|
||||
data: {
|
||||
columnName,
|
||||
tables: rows.map((row: any) => row.table_name),
|
||||
count: rows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("테이블 목록 조회 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "TABLE_LIST_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||
*/
|
||||
export async function previewCodeMerge(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { columnName, oldValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
if (!columnName || !oldValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (columnName, oldValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("코드 병합 미리보기", { columnName, oldValue, companyCode });
|
||||
|
||||
// 해당 컬럼을 가진 테이블 찾기
|
||||
const tablesQuery = `
|
||||
SELECT DISTINCT t.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
WHERE c.column_name = $1
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
`;
|
||||
|
||||
const tablesResult = await pool.query(tablesQuery, [columnName]);
|
||||
|
||||
// 각 테이블에서 영향받을 행 수 계산
|
||||
const preview = [];
|
||||
const tableRows = Array.isArray(tablesResult) ? tablesResult : ((tablesResult as any).rows || []);
|
||||
|
||||
for (const row of tableRows) {
|
||||
const tableName = row.table_name;
|
||||
|
||||
// 동적 SQL 생성 (테이블명과 컬럼명은 파라미터 바인딩 불가)
|
||||
// SQL 인젝션 방지: 테이블명과 컬럼명은 information_schema에서 검증된 값
|
||||
const countQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${columnName}" = $1 AND company_code = $2`;
|
||||
|
||||
try {
|
||||
const countResult = await pool.query(countQuery, [oldValue, companyCode]);
|
||||
const rows = (countResult as any).rows || [];
|
||||
const count = rows.length > 0 ? parseInt(rows[0].count) : 0;
|
||||
|
||||
if (count > 0) {
|
||||
preview.push({
|
||||
tableName,
|
||||
affectedRows: count,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`테이블 ${tableName} 조회 실패:`, error.message);
|
||||
// 테이블 접근 실패 시 건너뛰기
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const totalRows = preview.reduce((sum, item) => sum + item.affectedRows, 0);
|
||||
|
||||
logger.info("코드 병합 미리보기 완료", {
|
||||
tablesCount: preview.length,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "코드 병합 미리보기 완료",
|
||||
data: {
|
||||
columnName,
|
||||
oldValue,
|
||||
preview,
|
||||
totalAffectedRows: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("코드 병합 미리보기 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "PREVIEW_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
|
||||
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
|
||||
*/
|
||||
export async function mergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue, newValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!oldValue || !newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 값으로 병합 시도 방지
|
||||
if (oldValue === newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "기존 값과 새 값이 동일합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 시작", {
|
||||
oldValue,
|
||||
newValue,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM merge_code_by_value($1, $2, $3)",
|
||||
[oldValue, newValue, companyCode]
|
||||
);
|
||||
|
||||
// 결과 처리
|
||||
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = affectedData.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 완료", {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTablesCount: affectedData.length,
|
||||
totalRowsUpdated: totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||
data: {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedData: affectedData.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
rowsUpdated: parseInt(row.out_rows_updated),
|
||||
})),
|
||||
totalRowsUpdated: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CODE_MERGE_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 미리보기
|
||||
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
|
||||
*/
|
||||
export async function previewMergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
if (!oldValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM preview_merge_code_by_value($1, $2)",
|
||||
[oldValue, companyCode]
|
||||
);
|
||||
|
||||
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = preview.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기 완료", {
|
||||
tablesCount: preview.length,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "코드 병합 미리보기 완료",
|
||||
data: {
|
||||
oldValue,
|
||||
preview: preview.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
affectedRows: parseInt(row.out_affected_rows),
|
||||
})),
|
||||
totalAffectedRows: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 미리보기 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "PREVIEW_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -20,25 +20,15 @@ export class CommonCodeController {
|
|||
*/
|
||||
async getCategories(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { search, isActive, page = "1", size = "20", menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
const { search, isActive, page = "1", size = "20" } = req.query;
|
||||
|
||||
const categories = await this.commonCodeService.getCategories(
|
||||
{
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true"
|
||||
? true
|
||||
: isActive === "false"
|
||||
? false
|
||||
: undefined,
|
||||
page: parseInt(page as string),
|
||||
size: parseInt(size as string),
|
||||
},
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
const categories = await this.commonCodeService.getCategories({
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||
page: parseInt(page as string),
|
||||
size: parseInt(size as string),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -63,55 +53,24 @@ export class CommonCodeController {
|
|||
async getCodes(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { search, isActive, page, size, menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
const { search, isActive, page, size } = req.query;
|
||||
|
||||
const result = await this.commonCodeService.getCodes(
|
||||
categoryCode,
|
||||
{
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true"
|
||||
? true
|
||||
: isActive === "false"
|
||||
? false
|
||||
: undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
size: size ? parseInt(size as string) : undefined,
|
||||
},
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
const result = await this.commonCodeService.getCodes(categoryCode, {
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
size: size ? parseInt(size as string) : undefined,
|
||||
});
|
||||
|
||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||
const transformedData = result.data.map((code: any) => ({
|
||||
// 새로운 필드명 (카멜케이스)
|
||||
codeValue: code.code_value,
|
||||
codeName: code.code_name,
|
||||
codeNameEng: code.code_name_eng,
|
||||
description: code.description,
|
||||
sortOrder: code.sort_order,
|
||||
isActive: code.is_active,
|
||||
isActive: code.is_active === "Y",
|
||||
useYn: code.is_active,
|
||||
companyCode: code.company_code,
|
||||
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
|
||||
depth: code.depth, // 계층구조: 깊이
|
||||
|
||||
// 기존 필드명도 유지 (하위 호환성)
|
||||
code_category: code.code_category,
|
||||
code_value: code.code_value,
|
||||
code_name: code.code_name,
|
||||
code_name_eng: code.code_name_eng,
|
||||
sort_order: code.sort_order,
|
||||
is_active: code.is_active,
|
||||
company_code: code.company_code,
|
||||
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
|
||||
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
|
||||
created_date: code.created_date,
|
||||
created_by: code.created_by,
|
||||
updated_date: code.updated_date,
|
||||
updated_by: code.updated_by,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
|
|
@ -137,9 +96,7 @@ export class CommonCodeController {
|
|||
async createCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const categoryData: CreateCategoryData = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const menuObjid = req.body.menuObjid;
|
||||
const userId = req.user?.userId || "SYSTEM"; // 인증 미들웨어에서 설정된 사용자 ID
|
||||
|
||||
// 입력값 검증
|
||||
if (!categoryData.categoryCode || !categoryData.categoryName) {
|
||||
|
|
@ -149,18 +106,9 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
|
||||
if (!menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "메뉴 OBJID는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const category = await this.commonCodeService.createCategory(
|
||||
categoryData,
|
||||
userId,
|
||||
companyCode,
|
||||
Number(menuObjid)
|
||||
userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -171,10 +119,10 @@ export class CommonCodeController {
|
|||
} catch (error) {
|
||||
logger.error("카테고리 생성 실패:", error);
|
||||
|
||||
// PostgreSQL 에러 처리
|
||||
// Prisma 에러 처리
|
||||
if (
|
||||
(error as any)?.code === "23505" || // PostgreSQL unique_violation
|
||||
(error instanceof Error && error.message.includes("Unique constraint"))
|
||||
error instanceof Error &&
|
||||
error.message.includes("Unique constraint")
|
||||
) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
|
|
@ -199,13 +147,11 @@ export class CommonCodeController {
|
|||
const { categoryCode } = req.params;
|
||||
const categoryData: Partial<CreateCategoryData> = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const category = await this.commonCodeService.updateCategory(
|
||||
categoryCode,
|
||||
categoryData,
|
||||
userId,
|
||||
companyCode
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -241,9 +187,8 @@ export class CommonCodeController {
|
|||
async deleteCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
await this.commonCodeService.deleteCategory(categoryCode, companyCode);
|
||||
await this.commonCodeService.deleteCategory(categoryCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -279,8 +224,6 @@ export class CommonCodeController {
|
|||
const { categoryCode } = req.params;
|
||||
const codeData: CreateCodeData = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const menuObjid = req.body.menuObjid;
|
||||
|
||||
// 입력값 검증
|
||||
if (!codeData.codeValue || !codeData.codeName) {
|
||||
|
|
@ -290,17 +233,10 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
|
||||
// 공통코드관리 메뉴 OBJID: 1757401858940
|
||||
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
|
||||
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
|
||||
|
||||
const code = await this.commonCodeService.createCode(
|
||||
categoryCode,
|
||||
codeData,
|
||||
userId,
|
||||
companyCode,
|
||||
effectiveMenuObjid
|
||||
userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -338,14 +274,12 @@ export class CommonCodeController {
|
|||
const { categoryCode, codeValue } = req.params;
|
||||
const codeData: Partial<CreateCodeData> = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const code = await this.commonCodeService.updateCode(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
codeData,
|
||||
userId,
|
||||
companyCode
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -384,13 +318,8 @@ export class CommonCodeController {
|
|||
async deleteCode(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode, codeValue } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
await this.commonCodeService.deleteCode(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
companyCode
|
||||
);
|
||||
await this.commonCodeService.deleteCode(categoryCode, codeValue);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -427,12 +356,8 @@ export class CommonCodeController {
|
|||
async getCodeOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const options = await this.commonCodeService.getCodeOptions(
|
||||
categoryCode,
|
||||
userCompanyCode
|
||||
);
|
||||
const options = await this.commonCodeService.getCodeOptions(categoryCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -485,13 +410,12 @@ export class CommonCodeController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 카테고리 중복 검사 (회사별)
|
||||
* 카테고리 중복 검사
|
||||
* GET /api/common-codes/categories/check-duplicate?field=categoryCode&value=USER_STATUS&excludeCode=OLD_CODE
|
||||
*/
|
||||
async checkCategoryDuplicate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { field, value, excludeCode } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 입력값 검증
|
||||
if (!field || !value) {
|
||||
|
|
@ -513,8 +437,7 @@ export class CommonCodeController {
|
|||
const result = await this.commonCodeService.checkCategoryDuplicate(
|
||||
field as "categoryCode" | "categoryName" | "categoryNameEng",
|
||||
value as string,
|
||||
excludeCode as string,
|
||||
userCompanyCode
|
||||
excludeCode as string
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -537,14 +460,13 @@ export class CommonCodeController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 코드 중복 검사 (회사별)
|
||||
* 코드 중복 검사
|
||||
* GET /api/common-codes/categories/:categoryCode/codes/check-duplicate?field=codeValue&value=ACTIVE&excludeCode=OLD_CODE
|
||||
*/
|
||||
async checkCodeDuplicate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { field, value, excludeCode } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 입력값 검증
|
||||
if (!field || !value) {
|
||||
|
|
@ -567,8 +489,7 @@ export class CommonCodeController {
|
|||
categoryCode,
|
||||
field as "codeValue" | "codeName" | "codeNameEng",
|
||||
value as string,
|
||||
excludeCode as string,
|
||||
userCompanyCode
|
||||
excludeCode as string
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -590,129 +511,4 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계층구조 코드 조회
|
||||
* GET /api/common-codes/categories/:categoryCode/hierarchy
|
||||
* Query: parentCodeValue (optional), depth (optional), menuObjid (optional)
|
||||
*/
|
||||
async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { parentCodeValue, depth, menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
|
||||
// parentCodeValue가 빈 문자열이면 최상위 코드 조회
|
||||
const parentValue = parentCodeValue === '' || parentCodeValue === undefined
|
||||
? null
|
||||
: parentCodeValue as string;
|
||||
|
||||
const codes = await this.commonCodeService.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
parentValue,
|
||||
depth ? parseInt(depth as string) : undefined,
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
|
||||
// 프론트엔드 형식으로 변환
|
||||
const transformedData = codes.map((code: any) => ({
|
||||
codeValue: code.code_value,
|
||||
codeName: code.code_name,
|
||||
codeNameEng: code.code_name_eng,
|
||||
description: code.description,
|
||||
sortOrder: code.sort_order,
|
||||
isActive: code.is_active,
|
||||
parentCodeValue: code.parent_code_value,
|
||||
depth: code.depth,
|
||||
// 기존 필드도 유지
|
||||
code_category: code.code_category,
|
||||
code_value: code.code_value,
|
||||
code_name: code.code_name,
|
||||
code_name_eng: code.code_name_eng,
|
||||
sort_order: code.sort_order,
|
||||
is_active: code.is_active,
|
||||
parent_code_value: code.parent_code_value,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: transformedData,
|
||||
message: `계층구조 코드 조회 성공 (${categoryCode})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "계층구조 코드 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 트리 조회
|
||||
* GET /api/common-codes/categories/:categoryCode/tree
|
||||
*/
|
||||
async getCodeTree(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { menuObjid } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||
|
||||
const result = await this.commonCodeService.getCodeTree(
|
||||
categoryCode,
|
||||
userCompanyCode,
|
||||
menuObjidNum
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `코드 트리 조회 성공 (${categoryCode})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 트리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 코드 존재 여부 확인
|
||||
* GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children
|
||||
*/
|
||||
async hasChildren(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode, codeValue } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const hasChildren = await this.commonCodeService.hasChildren(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { hasChildren },
|
||||
message: "자식 코드 확인 완료",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
|
||||
error
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자식 코드 확인 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
getDataflowDiagrams as getDataflowDiagramsService,
|
||||
getDataflowDiagramById as getDataflowDiagramByIdService,
|
||||
|
|
@ -13,33 +12,15 @@ import { logger } from "../utils/logger";
|
|||
/**
|
||||
* 관계도 목록 조회 (페이지네이션)
|
||||
*/
|
||||
export const getDataflowDiagrams = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const getDataflowDiagrams = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const size = parseInt(req.query.size as string) || 20;
|
||||
const searchTerm = req.query.searchTerm as string;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
// 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
// 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
logger.info("관계도 목록 조회", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode,
|
||||
filterCompanyCode: companyCode,
|
||||
page,
|
||||
size,
|
||||
});
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
|
||||
const result = await getDataflowDiagramsService(
|
||||
companyCode,
|
||||
|
|
@ -65,21 +46,13 @@ export const getDataflowDiagrams = async (
|
|||
/**
|
||||
* 특정 관계도 조회
|
||||
*/
|
||||
export const getDataflowDiagramById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const getDataflowDiagramById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -114,10 +87,7 @@ export const getDataflowDiagramById = async (
|
|||
/**
|
||||
* 새로운 관계도 생성
|
||||
*/
|
||||
export const createDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const createDataflowDiagram = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
diagram_name,
|
||||
|
|
@ -126,31 +96,27 @@ export const createDataflowDiagram = async (
|
|||
category,
|
||||
control,
|
||||
plan,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
} = req.body;
|
||||
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
|
||||
// 회사 코드는 로그인한 사용자의 회사 코드 사용 (슈퍼 관리자는 요청 body에서 지정 가능)
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*" && req.body.company_code) {
|
||||
// 슈퍼 관리자가 특정 회사로 생성하는 경우
|
||||
companyCode = req.body.company_code;
|
||||
} else {
|
||||
// 일반 사용자/회사 관리자는 자신의 회사로 생성
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
logger.info(`새 관계도 생성 요청:`, {
|
||||
diagram_name,
|
||||
companyCode,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
});
|
||||
logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code });
|
||||
logger.info(`node_positions:`, node_positions);
|
||||
logger.info(`category:`, category);
|
||||
logger.info(`control:`, control);
|
||||
logger.info(`plan:`, plan);
|
||||
logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
const companyCode =
|
||||
company_code ||
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userId =
|
||||
created_by ||
|
||||
updated_by ||
|
||||
(req.headers["x-user-id"] as string) ||
|
||||
"SYSTEM";
|
||||
|
||||
if (!diagram_name || !relationships) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -188,7 +154,7 @@ export const createDataflowDiagram = async (
|
|||
|
||||
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
|
||||
const isDuplicateError =
|
||||
(error && typeof error === "object" && (error as any).code === "23505") || // PostgreSQL unique_violation
|
||||
(error && typeof error === "object" && (error as any).code === "P2002") || // Prisma unique constraint error code
|
||||
(error instanceof Error &&
|
||||
(error.message.includes("unique constraint") ||
|
||||
error.message.includes("Unique constraint") ||
|
||||
|
|
@ -218,31 +184,24 @@ export const createDataflowDiagram = async (
|
|||
/**
|
||||
* 관계도 수정
|
||||
*/
|
||||
export const updateDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const updateDataflowDiagram = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const { updated_by } = req.body;
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userId =
|
||||
updated_by || (req.headers["x-user-id"] as string) || "SYSTEM";
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
|
||||
logger.info(`관계도 수정 요청`, {
|
||||
diagramId,
|
||||
companyCode,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
});
|
||||
logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`);
|
||||
logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
logger.info(`node_positions:`, req.body.node_positions);
|
||||
logger.info(`요청 Body 키들:`, Object.keys(req.body));
|
||||
logger.info(`요청 Body 타입:`, typeof req.body);
|
||||
logger.info(`node_positions 타입:`, typeof req.body.node_positions);
|
||||
logger.info(`node_positions 값:`, req.body.node_positions);
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -277,7 +236,7 @@ export const updateDataflowDiagram = async (
|
|||
} catch (error) {
|
||||
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
|
||||
const isDuplicateError =
|
||||
(error && typeof error === "object" && (error as any).code === "23505") || // PostgreSQL unique_violation
|
||||
(error && typeof error === "object" && (error as any).code === "P2002") || // Prisma unique constraint error code
|
||||
(error instanceof Error &&
|
||||
(error.message.includes("unique constraint") ||
|
||||
error.message.includes("Unique constraint") ||
|
||||
|
|
@ -306,21 +265,13 @@ export const updateDataflowDiagram = async (
|
|||
/**
|
||||
* 관계도 삭제
|
||||
*/
|
||||
export const deleteDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const deleteDataflowDiagram = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCode = (req.query.companyCode as string) || "*";
|
||||
} else {
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
const companyCode =
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -355,25 +306,21 @@ export const deleteDataflowDiagram = async (
|
|||
/**
|
||||
* 관계도 복제
|
||||
*/
|
||||
export const copyDataflowDiagram = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const copyDataflowDiagram = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const diagramId = parseInt(req.params.diagramId);
|
||||
const { new_name } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
|
||||
// 회사 코드는 로그인한 사용자의 회사 코드 사용
|
||||
let companyCode: string;
|
||||
if (userCompanyCode === "*" && req.body.companyCode) {
|
||||
// 슈퍼 관리자가 특정 회사로 복제하는 경우
|
||||
companyCode = req.body.companyCode;
|
||||
} else {
|
||||
// 일반 사용자/회사 관리자는 자신의 회사로 복제
|
||||
companyCode = userCompanyCode || "*";
|
||||
}
|
||||
const {
|
||||
new_name,
|
||||
companyCode: bodyCompanyCode,
|
||||
userId: bodyUserId,
|
||||
} = req.body;
|
||||
const companyCode =
|
||||
bodyCompanyCode ||
|
||||
(req.query.companyCode as string) ||
|
||||
(req.headers["x-company-code"] as string) ||
|
||||
"*";
|
||||
const userId =
|
||||
bodyUserId || (req.headers["x-user-id"] as string) || "SYSTEM";
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* 🔥 데이터플로우 실행 컨트롤러
|
||||
*
|
||||
*
|
||||
* 버튼 제어에서 관계 실행 시 사용되는 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query } from "../database/db";
|
||||
import prisma from "../config/database";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
/**
|
||||
|
|
@ -29,23 +29,13 @@ export async function executeDataAction(
|
|||
|
||||
// 연결 정보에 따라 다른 데이터베이스에 저장
|
||||
let result;
|
||||
|
||||
|
||||
if (connection && connection.id !== 0) {
|
||||
// 외부 데이터베이스 연결
|
||||
result = await executeExternalDatabaseAction(
|
||||
tableName,
|
||||
data,
|
||||
actionType,
|
||||
connection
|
||||
);
|
||||
result = await executeExternalDatabaseAction(tableName, data, actionType, connection);
|
||||
} else {
|
||||
// 메인 데이터베이스 (현재 시스템)
|
||||
result = await executeMainDatabaseAction(
|
||||
tableName,
|
||||
data,
|
||||
actionType,
|
||||
companyCode
|
||||
);
|
||||
result = await executeMainDatabaseAction(tableName, data, actionType, companyCode);
|
||||
}
|
||||
|
||||
logger.info(`데이터 액션 실행 완료: ${actionType} on ${tableName}`, result);
|
||||
|
|
@ -55,6 +45,7 @@ export async function executeDataAction(
|
|||
message: `데이터 액션 실행 완료: ${actionType}`,
|
||||
data: result,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 액션 실행 실패:", error);
|
||||
res.status(500).json({
|
||||
|
|
@ -82,13 +73,13 @@ async function executeMainDatabaseAction(
|
|||
};
|
||||
|
||||
switch (actionType.toLowerCase()) {
|
||||
case "insert":
|
||||
case 'insert':
|
||||
return await executeInsert(tableName, dataWithCompany);
|
||||
case "update":
|
||||
case 'update':
|
||||
return await executeUpdate(tableName, dataWithCompany);
|
||||
case "upsert":
|
||||
case 'upsert':
|
||||
return await executeUpsert(tableName, dataWithCompany);
|
||||
case "delete":
|
||||
case 'delete':
|
||||
return await executeDelete(tableName, dataWithCompany);
|
||||
default:
|
||||
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
||||
|
|
@ -109,37 +100,25 @@ async function executeExternalDatabaseAction(
|
|||
connection: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info(
|
||||
`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`
|
||||
);
|
||||
logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`);
|
||||
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
|
||||
|
||||
// 🔥 실제 외부 DB 연결 및 실행 로직 구현
|
||||
const { MultiConnectionQueryService } = await import(
|
||||
"../services/multiConnectionQueryService"
|
||||
);
|
||||
const { MultiConnectionQueryService } = await import('../services/multiConnectionQueryService');
|
||||
const queryService = new MultiConnectionQueryService();
|
||||
|
||||
let result;
|
||||
switch (actionType.toLowerCase()) {
|
||||
case "insert":
|
||||
result = await queryService.insertDataToConnection(
|
||||
connection.id,
|
||||
tableName,
|
||||
data
|
||||
);
|
||||
case 'insert':
|
||||
result = await queryService.insertDataToConnection(connection.id, tableName, data);
|
||||
logger.info(`외부 DB INSERT 성공:`, result);
|
||||
break;
|
||||
case "update":
|
||||
case 'update':
|
||||
// TODO: UPDATE 로직 구현 (조건 필요)
|
||||
throw new Error(
|
||||
"UPDATE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다."
|
||||
);
|
||||
case "delete":
|
||||
throw new Error('UPDATE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.');
|
||||
case 'delete':
|
||||
// TODO: DELETE 로직 구현 (조건 필요)
|
||||
throw new Error(
|
||||
"DELETE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다."
|
||||
);
|
||||
throw new Error('DELETE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.');
|
||||
default:
|
||||
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
||||
}
|
||||
|
|
@ -160,28 +139,25 @@ async function executeExternalDatabaseAction(
|
|||
/**
|
||||
* INSERT 실행
|
||||
*/
|
||||
async function executeInsert(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<any> {
|
||||
async function executeInsert(tableName: string, data: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
// 동적 테이블 접근을 위한 raw query 사용
|
||||
const columns = Object.keys(data).join(", ");
|
||||
const columns = Object.keys(data).join(', ');
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||
|
||||
const insertQuery = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
|
||||
|
||||
logger.info(`INSERT 쿼리 실행:`, { query: insertQuery, values });
|
||||
|
||||
const result = await query<any>(insertQuery, values);
|
||||
const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
|
||||
|
||||
logger.info(`INSERT 쿼리 실행:`, { query, values });
|
||||
|
||||
const result = await prisma.$queryRawUnsafe(query, ...values);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "insert",
|
||||
action: 'insert',
|
||||
tableName,
|
||||
data: result,
|
||||
affectedRows: result.length,
|
||||
affectedRows: Array.isArray(result) ? result.length : 1,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`INSERT 실행 오류:`, error);
|
||||
|
|
@ -192,79 +168,32 @@ async function executeInsert(
|
|||
/**
|
||||
* UPDATE 실행
|
||||
*/
|
||||
async function executeUpdate(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<any> {
|
||||
async function executeUpdate(tableName: string, data: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
logger.info(`UPDATE 액션 시작:`, { tableName, receivedData: data });
|
||||
|
||||
// 1. 테이블의 실제 기본키 조회
|
||||
const primaryKeyQuery = `
|
||||
SELECT a.attname as column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
||||
`;
|
||||
|
||||
const pkResult = await query<{ column_name: string }>(primaryKeyQuery, [
|
||||
tableName,
|
||||
]);
|
||||
|
||||
if (!pkResult || pkResult.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다`);
|
||||
// ID 또는 기본키를 기준으로 업데이트
|
||||
const { id, ...updateData } = data;
|
||||
|
||||
if (!id) {
|
||||
throw new Error('UPDATE를 위한 ID가 필요합니다');
|
||||
}
|
||||
|
||||
const primaryKeyColumn = pkResult[0].column_name;
|
||||
logger.info(`테이블 ${tableName}의 기본키:`, primaryKeyColumn);
|
||||
|
||||
// 2. 기본키 값 추출
|
||||
const primaryKeyValue = data[primaryKeyColumn];
|
||||
|
||||
if (!primaryKeyValue && primaryKeyValue !== 0) {
|
||||
logger.error(`UPDATE 실패: 기본키 값이 없음`, {
|
||||
primaryKeyColumn,
|
||||
receivedData: data,
|
||||
availableKeys: Object.keys(data),
|
||||
});
|
||||
throw new Error(
|
||||
`UPDATE를 위한 기본키 값이 필요합니다 (${primaryKeyColumn})`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 업데이트할 데이터에서 기본키 제외
|
||||
const updateData = { ...data };
|
||||
delete updateData[primaryKeyColumn];
|
||||
|
||||
logger.info(`UPDATE 데이터 준비:`, {
|
||||
primaryKeyColumn,
|
||||
primaryKeyValue,
|
||||
updateFields: Object.keys(updateData),
|
||||
});
|
||||
|
||||
// 4. 동적 UPDATE 쿼리 생성
|
||||
const setClause = Object.keys(updateData)
|
||||
.map((key, index) => `${key} = $${index + 1}`)
|
||||
.join(", ");
|
||||
|
||||
.join(', ');
|
||||
|
||||
const values = Object.values(updateData);
|
||||
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = $${values.length + 1} RETURNING *`;
|
||||
|
||||
logger.info(`UPDATE 쿼리 실행:`, {
|
||||
query: updateQuery,
|
||||
values: [...values, primaryKeyValue],
|
||||
});
|
||||
|
||||
const result = await query<any>(updateQuery, [...values, primaryKeyValue]);
|
||||
|
||||
logger.info(`UPDATE 성공:`, { affectedRows: result.length });
|
||||
const query = `UPDATE ${tableName} SET ${setClause} WHERE id = $${values.length + 1} RETURNING *`;
|
||||
|
||||
logger.info(`UPDATE 쿼리 실행:`, { query, values: [...values, id] });
|
||||
|
||||
const result = await prisma.$queryRawUnsafe(query, ...values, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "update",
|
||||
action: 'update',
|
||||
tableName,
|
||||
data: result,
|
||||
affectedRows: result.length,
|
||||
affectedRows: Array.isArray(result) ? result.length : 1,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`UPDATE 실행 오류:`, error);
|
||||
|
|
@ -275,10 +204,7 @@ async function executeUpdate(
|
|||
/**
|
||||
* UPSERT 실행
|
||||
*/
|
||||
async function executeUpsert(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<any> {
|
||||
async function executeUpsert(tableName: string, data: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
// 먼저 INSERT를 시도하고, 실패하면 UPDATE
|
||||
try {
|
||||
|
|
@ -297,29 +223,26 @@ async function executeUpsert(
|
|||
/**
|
||||
* DELETE 실행
|
||||
*/
|
||||
async function executeDelete(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<any> {
|
||||
async function executeDelete(tableName: string, data: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
const { id } = data;
|
||||
|
||||
|
||||
if (!id) {
|
||||
throw new Error("DELETE를 위한 ID가 필요합니다");
|
||||
throw new Error('DELETE를 위한 ID가 필요합니다');
|
||||
}
|
||||
|
||||
const deleteQuery = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
|
||||
|
||||
logger.info(`DELETE 쿼리 실행:`, { query: deleteQuery, values: [id] });
|
||||
|
||||
const result = await query<any>(deleteQuery, [id]);
|
||||
const query = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
|
||||
|
||||
logger.info(`DELETE 쿼리 실행:`, { query, values: [id] });
|
||||
|
||||
const result = await prisma.$queryRawUnsafe(query, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "delete",
|
||||
action: 'delete',
|
||||
tableName,
|
||||
data: result,
|
||||
affectedRows: result.length,
|
||||
affectedRows: Array.isArray(result) ? result.length : 1,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`DELETE 실행 오류:`, error);
|
||||
|
|
|
|||
|
|
@ -383,79 +383,6 @@ export class DDLController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/:tableName - 테이블 삭제 (최고 관리자 전용)
|
||||
*/
|
||||
static async dropTable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
|
||||
// 입력값 기본 검증
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "테이블명이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("테이블 삭제 요청", {
|
||||
tableName,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// DDL 실행 서비스 호출
|
||||
const ddlService = new DDLExecutionService();
|
||||
const result = await ddlService.dropTable(
|
||||
tableName,
|
||||
userCompanyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
tableName,
|
||||
executedQuery: result.executedQuery,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 삭제 컨트롤러 오류:", {
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
userId: req.user?.userId,
|
||||
tableName: req.params.tableName,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
details: "테이블 삭제 중 서버 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
/**
|
||||
* 배송/화물 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import * as deliveryService from '../services/deliveryService';
|
||||
|
||||
/**
|
||||
* GET /api/delivery/status
|
||||
* 배송 현황 조회
|
||||
*/
|
||||
export async function getDeliveryStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await deliveryService.getDeliveryStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배송 현황 조회 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '배송 현황 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/delivery/delayed
|
||||
* 지연 배송 목록 조회
|
||||
*/
|
||||
export async function getDelayedDeliveries(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const deliveries = await deliveryService.getDelayedDeliveries();
|
||||
res.json({
|
||||
success: true,
|
||||
data: deliveries,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('지연 배송 조회 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '지연 배송 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/delivery/issues
|
||||
* 고객 이슈 목록 조회
|
||||
*/
|
||||
export async function getCustomerIssues(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { status } = req.query;
|
||||
const issues = await deliveryService.getCustomerIssues(status as string);
|
||||
res.json({
|
||||
success: true,
|
||||
data: issues,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('고객 이슈 조회 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '고객 이슈 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/delivery/:id/status
|
||||
* 배송 상태 업데이트
|
||||
*/
|
||||
export async function updateDeliveryStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, delayReason } = req.body;
|
||||
|
||||
await deliveryService.updateDeliveryStatus(id, status, delayReason);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '배송 상태가 업데이트되었습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배송 상태 업데이트 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '배송 상태 업데이트에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/delivery/issues/:id/status
|
||||
* 고객 이슈 상태 업데이트
|
||||
*/
|
||||
export async function updateIssueStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
await deliveryService.updateIssueStatus(id, status);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '이슈 상태가 업데이트되었습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이슈 상태 업데이트 실패:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '이슈 상태 업데이트에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,534 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별)
|
||||
*/
|
||||
export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("부서 목록 조회", { companyCode, userCompanyCode });
|
||||
|
||||
// 최고 관리자가 아니면 자신의 회사만 조회 가능
|
||||
if (userCompanyCode !== "*" && userCompanyCode !== companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "해당 회사의 부서를 조회할 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 부서 목록 조회 (부서원 수 포함)
|
||||
const departments = await query<any>(`
|
||||
SELECT
|
||||
d.dept_code,
|
||||
d.dept_name,
|
||||
d.company_code,
|
||||
d.parent_dept_code,
|
||||
COUNT(DISTINCT ud.user_id) as member_count
|
||||
FROM dept_info d
|
||||
LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code
|
||||
WHERE d.company_code = $1
|
||||
GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code
|
||||
ORDER BY d.dept_name
|
||||
`, [companyCode]);
|
||||
|
||||
// 응답 형식 변환
|
||||
const formattedDepartments = departments.map((dept) => ({
|
||||
dept_code: dept.dept_code,
|
||||
dept_name: dept.dept_name,
|
||||
company_code: dept.company_code,
|
||||
parent_dept_code: dept.parent_dept_code,
|
||||
memberCount: parseInt(dept.member_count || "0"),
|
||||
}));
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: formattedDepartments,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 목록 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 상세 조회
|
||||
*/
|
||||
export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
|
||||
const department = await queryOne<any>(`
|
||||
SELECT
|
||||
dept_code,
|
||||
dept_name,
|
||||
company_code,
|
||||
parent_dept_code
|
||||
FROM dept_info
|
||||
WHERE dept_code = $1
|
||||
`, [deptCode]);
|
||||
|
||||
if (!department) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "부서를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: department,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 상세 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 생성
|
||||
*/
|
||||
export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const { dept_name, parent_dept_code } = req.body;
|
||||
|
||||
if (!dept_name || !dept_name.trim()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "부서명을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 회사 내 중복 부서명 확인
|
||||
const duplicate = await queryOne<any>(`
|
||||
SELECT dept_code, dept_name
|
||||
FROM dept_info
|
||||
WHERE company_code = $1 AND dept_name = $2
|
||||
`, [companyCode, dept_name.trim()]);
|
||||
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: `"${dept_name}" 부서가 이미 존재합니다.`,
|
||||
isDuplicate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 이름 조회
|
||||
const company = await queryOne<any>(`
|
||||
SELECT company_name FROM company_mng WHERE company_code = $1
|
||||
`, [companyCode]);
|
||||
|
||||
const companyName = company?.company_name || companyCode;
|
||||
|
||||
// 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...)
|
||||
const codeResult = await queryOne<any>(`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number
|
||||
FROM dept_info
|
||||
WHERE dept_code ~ '^DEPT_[0-9]+$'
|
||||
`);
|
||||
|
||||
const nextNumber = codeResult?.next_number || 1;
|
||||
const deptCode = `DEPT_${nextNumber}`;
|
||||
|
||||
// 부서 생성
|
||||
const result = await query<any>(`
|
||||
INSERT INTO dept_info (
|
||||
dept_code,
|
||||
dept_name,
|
||||
company_code,
|
||||
company_name,
|
||||
parent_dept_code,
|
||||
status,
|
||||
regdate
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
RETURNING *
|
||||
`, [
|
||||
deptCode,
|
||||
dept_name.trim(),
|
||||
companyCode,
|
||||
companyName,
|
||||
parent_dept_code || null,
|
||||
'active',
|
||||
]);
|
||||
|
||||
logger.info("부서 생성 성공", { deptCode, dept_name });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "부서가 생성되었습니다.",
|
||||
data: result[0],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 생성 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 수정
|
||||
*/
|
||||
export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
const { dept_name, parent_dept_code } = req.body;
|
||||
|
||||
if (!dept_name || !dept_name.trim()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "부서명을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await query<any>(`
|
||||
UPDATE dept_info
|
||||
SET
|
||||
dept_name = $1,
|
||||
parent_dept_code = $2
|
||||
WHERE dept_code = $3
|
||||
RETURNING *
|
||||
`, [dept_name.trim(), parent_dept_code || null, deptCode]);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "부서를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("부서 수정 성공", { deptCode });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "부서가 수정되었습니다.",
|
||||
data: result[0],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 수정 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제
|
||||
*/
|
||||
export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
|
||||
// 하위 부서 확인
|
||||
const hasChildren = await queryOne<any>(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM dept_info
|
||||
WHERE parent_dept_code = $1
|
||||
`, [deptCode]);
|
||||
|
||||
if (parseInt(hasChildren?.count || "0") > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 부서원 삭제 (부서 삭제 전에 먼저 삭제)
|
||||
const deletedMembers = await query<any>(`
|
||||
DELETE FROM user_dept
|
||||
WHERE dept_code = $1
|
||||
RETURNING user_id
|
||||
`, [deptCode]);
|
||||
|
||||
const memberCount = deletedMembers.length;
|
||||
|
||||
// 부서 삭제
|
||||
const result = await query<any>(`
|
||||
DELETE FROM dept_info
|
||||
WHERE dept_code = $1
|
||||
RETURNING dept_code, dept_name
|
||||
`, [deptCode]);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "부서를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("부서 삭제 성공", {
|
||||
deptCode,
|
||||
deptName: result[0].dept_name,
|
||||
deletedMemberCount: memberCount
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: memberCount > 0
|
||||
? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)`
|
||||
: "부서가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서 삭제 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
*/
|
||||
export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
|
||||
const members = await query<any>(`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.user_name,
|
||||
u.email,
|
||||
u.tel as phone,
|
||||
u.cell_phone,
|
||||
u.position_name,
|
||||
ud.dept_code,
|
||||
d.dept_name,
|
||||
ud.is_primary
|
||||
FROM user_dept ud
|
||||
JOIN user_info u ON ud.user_id = u.user_id
|
||||
JOIN dept_info d ON ud.dept_code = d.dept_code
|
||||
WHERE ud.dept_code = $1
|
||||
ORDER BY ud.is_primary DESC, u.user_name
|
||||
`, [deptCode]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: members,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서원 목록 조회 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서원 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 검색 (부서원 추가용)
|
||||
*/
|
||||
export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const { search } = req.query;
|
||||
|
||||
if (!search || typeof search !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "검색어를 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 검색 (ID 또는 이름)
|
||||
const users = await query<any>(`
|
||||
SELECT
|
||||
user_id,
|
||||
user_name,
|
||||
email,
|
||||
position_name,
|
||||
company_code
|
||||
FROM user_info
|
||||
WHERE company_code = $1
|
||||
AND (
|
||||
user_id ILIKE $2 OR
|
||||
user_name ILIKE $2
|
||||
)
|
||||
ORDER BY user_name
|
||||
LIMIT 20
|
||||
`, [companyCode, `%${search}%`]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: users,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("사용자 검색 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사용자 검색 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 추가
|
||||
*/
|
||||
export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode } = req.params;
|
||||
const { user_id } = req.body;
|
||||
|
||||
if (!user_id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자 ID를 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 존재 확인
|
||||
const user = await queryOne<any>(`
|
||||
SELECT user_id, user_name
|
||||
FROM user_info
|
||||
WHERE user_id = $1
|
||||
`, [user_id]);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 부서원인지 확인
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT *
|
||||
FROM user_dept
|
||||
WHERE user_id = $1 AND dept_code = $2
|
||||
`, [user_id, deptCode]);
|
||||
|
||||
if (existing) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 해당 부서의 부서원입니다.",
|
||||
isDuplicate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 주 부서가 있는지 확인
|
||||
const hasPrimary = await queryOne<any>(`
|
||||
SELECT *
|
||||
FROM user_dept
|
||||
WHERE user_id = $1 AND is_primary = true
|
||||
`, [user_id]);
|
||||
|
||||
// 부서원 추가
|
||||
await query<any>(`
|
||||
INSERT INTO user_dept (user_id, dept_code, is_primary, created_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
`, [user_id, deptCode, !hasPrimary]);
|
||||
|
||||
logger.info("부서원 추가 성공", { user_id, deptCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "부서원이 추가되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서원 추가 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서원 추가 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 제거
|
||||
*/
|
||||
export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode, userId } = req.params;
|
||||
|
||||
const result = await query<any>(`
|
||||
DELETE FROM user_dept
|
||||
WHERE user_id = $1 AND dept_code = $2
|
||||
RETURNING *
|
||||
`, [userId, deptCode]);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 부서원을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("부서원 제거 성공", { userId, deptCode });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "부서원이 제거되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("부서원 제거 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "부서원 제거 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주 부서 설정
|
||||
*/
|
||||
export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { deptCode, userId } = req.params;
|
||||
|
||||
// 다른 부서의 주 부서 해제
|
||||
await query<any>(`
|
||||
UPDATE user_dept
|
||||
SET is_primary = false
|
||||
WHERE user_id = $1
|
||||
`, [userId]);
|
||||
|
||||
// 해당 부서를 주 부서로 설정
|
||||
await query<any>(`
|
||||
UPDATE user_dept
|
||||
SET is_primary = true
|
||||
WHERE user_id = $1 AND dept_code = $2
|
||||
`, [userId, deptCode]);
|
||||
|
||||
logger.info("주 부서 설정 성공", { userId, deptCode });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "주 부서가 설정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("주 부서 설정 실패", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "주 부서 설정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import logger from "../utils/logger";
|
||||
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
|
||||
|
||||
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
|
||||
export async function getExternalDbConnector(connectionId: number) {
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
|
||||
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
|
||||
return {
|
||||
executeQuery: async (sql: string, params?: any[]) => {
|
||||
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||
return { rows: result };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 동적 계층 구조 데이터 조회 (범용)
|
||||
export const getHierarchyData = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !hierarchyConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
const result: any = {
|
||||
warehouse: null,
|
||||
levels: [],
|
||||
materials: [],
|
||||
};
|
||||
|
||||
// 창고 데이터 조회
|
||||
if (config.warehouse) {
|
||||
const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`;
|
||||
const warehouseResult = await connector.executeQuery(warehouseQuery);
|
||||
result.warehouse = warehouseResult.rows;
|
||||
}
|
||||
|
||||
// 각 레벨 데이터 조회
|
||||
if (config.levels && Array.isArray(config.levels)) {
|
||||
for (const level of config.levels) {
|
||||
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
||||
const levelResult = await connector.executeQuery(levelQuery);
|
||||
|
||||
result.levels.push({
|
||||
level: level.level,
|
||||
name: level.name,
|
||||
data: levelResult.rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 자재 데이터 조회 (개수만)
|
||||
if (config.material) {
|
||||
const materialQuery = `
|
||||
SELECT
|
||||
${config.material.locationKeyColumn} as location_key,
|
||||
COUNT(*) as count
|
||||
FROM ${config.material.tableName}
|
||||
GROUP BY ${config.material.locationKeyColumn}
|
||||
`;
|
||||
const materialResult = await connector.executeQuery(materialQuery);
|
||||
result.materials = materialResult.rows;
|
||||
}
|
||||
|
||||
logger.info("동적 계층 구조 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
warehouseCount: result.warehouse?.length || 0,
|
||||
levelCounts: result.levels.map((l: any) => ({
|
||||
level: l.level,
|
||||
count: l.data.length,
|
||||
})),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("동적 계층 구조 데이터 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 레벨의 하위 데이터 조회
|
||||
export const getChildrenData = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
|
||||
req.body;
|
||||
|
||||
if (
|
||||
!externalDbConnectionId ||
|
||||
!hierarchyConfig ||
|
||||
!parentLevel ||
|
||||
!parentKey
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
// 다음 레벨 찾기
|
||||
const nextLevel = config.levels?.find(
|
||||
(l: any) => l.level === parentLevel + 1
|
||||
);
|
||||
|
||||
if (!nextLevel) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "하위 레벨이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 하위 데이터 조회
|
||||
const query = `
|
||||
SELECT * FROM ${nextLevel.tableName}
|
||||
WHERE ${nextLevel.parentKeyColumn} = '${parentKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("하위 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
parentLevel,
|
||||
parentKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("하위 데이터 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "하위 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getWarehouses = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("창고 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
count: result.rows.length,
|
||||
data: result.rows, // 실제 데이터 확인
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getAreas = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !warehouseKey || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE WAREKEY = '${warehouseKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("구역 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
warehouseKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("구역 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "구역 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getLocations = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !areaKey || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE AREAKEY = '${areaKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("위치 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
areaKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("위치 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "위치 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||
export const getMaterials = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
locaKey,
|
||||
tableName,
|
||||
keyColumn,
|
||||
locationKeyColumn,
|
||||
layerColumn,
|
||||
} = req.query;
|
||||
|
||||
if (
|
||||
!externalDbConnectionId ||
|
||||
!locaKey ||
|
||||
!tableName ||
|
||||
!locationKeyColumn
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
// 동적 쿼리 생성
|
||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||
${orderByClause}
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
logger.info(`자재 조회 쿼리: ${query}`);
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("자재 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locaKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자재 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||
export const getMaterialCounts = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !locationKeys || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
|
||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
LOCAKEY as location_key,
|
||||
COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE LOCAKEY IN (${keysString})
|
||||
GROUP BY LOCAKEY
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("자재 개수 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locationCount: locationKeys.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자재 개수 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 개수 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
export const getLayouts = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { externalDbConnectionId, warehouseKey } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
l.*,
|
||||
u1.user_name as created_by_name,
|
||||
u2.user_name as updated_by_name,
|
||||
COUNT(o.id) as object_count
|
||||
FROM digital_twin_layout l
|
||||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 최고 관리자는 모든 레이아웃 조회 가능
|
||||
if (companyCode && companyCode !== '*') {
|
||||
query += ` WHERE l.company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
} else {
|
||||
query += ` WHERE 1=1`;
|
||||
}
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||
params.push(externalDbConnectionId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (warehouseKey) {
|
||||
query += ` AND l.warehouse_key = $${paramIndex}`;
|
||||
params.push(warehouseKey);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += `
|
||||
GROUP BY l.id, u1.user_name, u2.user_name
|
||||
ORDER BY l.updated_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("레이아웃 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 상세 조회 (객체 포함)
|
||||
export const getLayoutById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
|
||||
let layoutQuery: string;
|
||||
let layoutParams: any[];
|
||||
|
||||
if (companyCode && companyCode !== '*') {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1 AND l.company_code = $2
|
||||
`;
|
||||
layoutParams = [id, companyCode];
|
||||
} else {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1
|
||||
`;
|
||||
layoutParams = [id];
|
||||
}
|
||||
|
||||
const layoutResult = await pool.query(layoutQuery, layoutParams);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 배치된 객체들 조회
|
||||
const objectsQuery = `
|
||||
SELECT *
|
||||
FROM digital_twin_objects
|
||||
WHERE layout_id = $1
|
||||
ORDER BY display_order, created_at
|
||||
`;
|
||||
|
||||
const objectsResult = await pool.query(objectsQuery, [id]);
|
||||
|
||||
logger.info("레이아웃 상세 조회", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
objectCount: objectsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
layout: layoutResult.rows[0],
|
||||
objects: objectsResult.rows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 상세 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 생성
|
||||
export const createLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 레이아웃 생성
|
||||
const layoutQuery = `
|
||||
INSERT INTO digital_twin_layout (
|
||||
company_code, external_db_connection_id, warehouse_key,
|
||||
layout_name, description, hierarchy_config, created_by, updated_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(layoutQuery, [
|
||||
companyCode,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const layoutId = layoutResult.rows[0].id;
|
||||
|
||||
// 객체들 저장
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
layout_id, object_type, object_name,
|
||||
position_x, position_y, position_z,
|
||||
size_x, size_y, size_z,
|
||||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
`;
|
||||
|
||||
for (const obj of objects) {
|
||||
await client.query(objectQuery, [
|
||||
layoutId,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
obj.parentId || null,
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("레이아웃 생성", {
|
||||
companyCode,
|
||||
layoutId,
|
||||
objectCount: objects?.length || 0,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: layoutResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("레이아웃 생성 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 수정
|
||||
export const updateLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 레이아웃 기본 정보 수정
|
||||
const updateLayoutQuery = `
|
||||
UPDATE digital_twin_layout
|
||||
SET layout_name = $1,
|
||||
description = $2,
|
||||
hierarchy_config = $3,
|
||||
external_db_connection_id = $4,
|
||||
warehouse_key = $5,
|
||||
updated_by = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7 AND company_code = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(updateLayoutQuery, [
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
externalDbConnectionId || null,
|
||||
warehouseKey || null,
|
||||
userId,
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 객체 삭제
|
||||
await client.query(
|
||||
"DELETE FROM digital_twin_objects WHERE layout_id = $1",
|
||||
[id]
|
||||
);
|
||||
|
||||
// 새 객체 저장 (부모-자식 관계 처리)
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
layout_id, object_type, object_name,
|
||||
position_x, position_y, position_z,
|
||||
size_x, size_y, size_z,
|
||||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// 임시 ID (음수) → 실제 DB ID 매핑
|
||||
const idMapping: { [tempId: number]: number } = {};
|
||||
|
||||
// 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들)
|
||||
for (const obj of objects.filter((o) => !o.parentId)) {
|
||||
const result = await client.query(objectQuery, [
|
||||
id,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
null, // parent_id
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
|
||||
// 임시 ID와 실제 DB ID 매핑
|
||||
if (obj.id) {
|
||||
idMapping[obj.id] = result.rows[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// 2단계: 자식 객체 저장 (parentId가 있는 것들)
|
||||
for (const obj of objects.filter((o) => o.parentId)) {
|
||||
const realParentId = idMapping[obj.parentId!] || null;
|
||||
|
||||
await client.query(objectQuery, [
|
||||
id,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
realParentId, // 실제 DB ID 사용
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("레이아웃 수정", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
objectCount: objects?.length || 0,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: layoutResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("레이아웃 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 삭제
|
||||
export const deleteLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const query = `
|
||||
DELETE FROM digital_twin_layout
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("레이아웃 삭제", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
DigitalTwinTemplateService,
|
||||
DigitalTwinLayoutTemplate,
|
||||
} from "../services/DigitalTwinTemplateService";
|
||||
|
||||
export const listMappingTemplates = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const externalDbConnectionId = req.query.externalDbConnectionId
|
||||
? Number(req.query.externalDbConnectionId)
|
||||
: undefined;
|
||||
const layoutType =
|
||||
typeof req.query.layoutType === "string"
|
||||
? req.query.layoutType
|
||||
: undefined;
|
||||
|
||||
const result = await DigitalTwinTemplateService.listTemplates(
|
||||
companyCode,
|
||||
{
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data as DigitalTwinLayoutTemplate[],
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getMappingTemplateById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.getTemplateById(
|
||||
companyCode,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿을 찾을 수 없습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createMappingTemplate = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !externalDbConnectionId || !config) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.createTemplate(
|
||||
companyCode,
|
||||
userId,
|
||||
{
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,459 +0,0 @@
|
|||
// 공차중계 운전자 컨트롤러
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
export class DriverController {
|
||||
/**
|
||||
* GET /api/driver/profile
|
||||
* 운전자 프로필 조회
|
||||
*/
|
||||
static async getProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const userResult = await query<any>(
|
||||
`SELECT
|
||||
user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userResult[0];
|
||||
|
||||
// 공차중계 사용자가 아닌 경우
|
||||
if (user.signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 차량 정보 조회
|
||||
const vehicleResult = await query<any>(
|
||||
`SELECT
|
||||
vehicle_number, vehicle_type, driver_name, driver_phone, status
|
||||
FROM vehicles
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
phoneNumber: user.cell_phone,
|
||||
licenseNumber: user.license_number,
|
||||
vehicleNumber: user.vehicle_number,
|
||||
vehicleType: vehicle?.vehicle_type || null,
|
||||
vehicleStatus: vehicle?.status || null,
|
||||
branchName: user.branch_name || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("운전자 프로필 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "프로필 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/driver/profile
|
||||
* 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종)
|
||||
*/
|
||||
static async updateProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body;
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const oldVehicleNumber = userCheck[0].vehicle_number;
|
||||
|
||||
// 차량번호 변경 시 중복 확인
|
||||
if (vehicleNumber && vehicleNumber !== oldVehicleNumber) {
|
||||
const duplicateCheck = await query<any>(
|
||||
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`,
|
||||
[vehicleNumber, userId]
|
||||
);
|
||||
|
||||
if (duplicateCheck.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 차량번호입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// user_info 업데이트
|
||||
await query(
|
||||
`UPDATE user_info SET
|
||||
user_name = COALESCE($1, user_name),
|
||||
cell_phone = COALESCE($2, cell_phone),
|
||||
license_number = COALESCE($3, license_number),
|
||||
vehicle_number = COALESCE($4, vehicle_number),
|
||||
branch_name = COALESCE($5, branch_name)
|
||||
WHERE user_id = $6`,
|
||||
[userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId]
|
||||
);
|
||||
|
||||
// vehicles 테이블 업데이트
|
||||
await query(
|
||||
`UPDATE vehicles SET
|
||||
vehicle_number = COALESCE($1, vehicle_number),
|
||||
vehicle_type = COALESCE($2, vehicle_type),
|
||||
driver_name = COALESCE($3, driver_name),
|
||||
driver_phone = COALESCE($4, driver_phone),
|
||||
branch_name = COALESCE($5, branch_name),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $6`,
|
||||
[vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId]
|
||||
);
|
||||
|
||||
logger.info(`운전자 프로필 수정 완료: ${userId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "프로필이 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("운전자 프로필 수정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "프로필 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/driver/status
|
||||
* 차량 상태 변경 (대기/정비만 가능)
|
||||
*/
|
||||
static async updateStatus(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = req.body;
|
||||
|
||||
// 허용된 상태값만 (대기: off, 정비: maintenance)
|
||||
const allowedStatuses = ["off", "maintenance"];
|
||||
if (!status || !allowedStatuses.includes(status)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// vehicles 테이블 상태 업데이트
|
||||
const updateResult = await query(
|
||||
`UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`,
|
||||
[status, userId]
|
||||
);
|
||||
|
||||
logger.info(`차량 상태 변경: ${userId} -> ${status}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("차량 상태 변경 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "상태 변경 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/driver/vehicle
|
||||
* 차량 삭제 (user_id = NULL 처리, 기록 보존)
|
||||
*/
|
||||
static async deleteVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존)
|
||||
await query(
|
||||
`UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// user_info에서 vehicle_number를 NULL로 변경
|
||||
await query(
|
||||
`UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
logger.info(`차량 삭제 완료 (기록 보존): ${userId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "차량이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("차량 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "차량 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/driver/vehicle
|
||||
* 새 차량 등록
|
||||
*/
|
||||
static async registerVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { vehicleNumber, vehicleType, branchName } = req.body;
|
||||
|
||||
if (!vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "차량번호는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 차량이 있는지 확인
|
||||
if (userCheck[0].vehicle_number) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 차량번호 중복 확인
|
||||
const duplicateCheck = await query<any>(
|
||||
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`,
|
||||
[vehicleNumber]
|
||||
);
|
||||
|
||||
if (duplicateCheck.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 등록된 차량번호입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userName = userCheck[0].user_name;
|
||||
const userPhone = userCheck[0].cell_phone;
|
||||
// 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용)
|
||||
const userCompanyCode = companyCode || userCheck[0].company_code;
|
||||
|
||||
// vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off')
|
||||
await query(
|
||||
`INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`,
|
||||
[vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode]
|
||||
);
|
||||
|
||||
// user_info에 vehicle_number 업데이트
|
||||
await query(
|
||||
`UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`,
|
||||
[vehicleNumber, userId]
|
||||
);
|
||||
|
||||
logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "차량이 등록되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("차량 등록 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "차량 등록 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/driver/account
|
||||
* 회원 탈퇴 (차량 정보 포함 삭제)
|
||||
*/
|
||||
static async deleteAccount(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 공차중계 사용자 확인
|
||||
const userCheck = await query<any>(
|
||||
`SELECT signup_type FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userCheck[0].signup_type !== "DRIVER") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공차중계 사용자만 탈퇴할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// vehicles 테이블에서 삭제
|
||||
await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]);
|
||||
|
||||
// user_info 테이블에서 삭제
|
||||
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
|
||||
|
||||
logger.info(`회원 탈퇴 완료: ${userId}`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "회원 탈퇴가 완료되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("회원 탈퇴 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원 탈퇴 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12,14 +12,6 @@ export const saveFormData = async (
|
|||
const { companyCode, userId } = req.user as any;
|
||||
const { screenId, tableName, data } = req.body;
|
||||
|
||||
// 🔍 디버깅: 사용자 정보 확인
|
||||
console.log("🔍 [saveFormData] 사용자 정보:", {
|
||||
userId,
|
||||
companyCode,
|
||||
reqUser: req.user,
|
||||
dataWriter: data.writer,
|
||||
});
|
||||
|
||||
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
|
||||
if (screenId === undefined || screenId === null || !tableName || !data) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -33,12 +25,9 @@ export const saveFormData = async (
|
|||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
console.log("✅ [saveFormData] 최종 writer 값:", formDataWithMeta.writer);
|
||||
|
||||
// company_code는 사용자가 명시적으로 입력한 경우에만 추가
|
||||
if (data.company_code !== undefined) {
|
||||
formDataWithMeta.company_code = data.company_code;
|
||||
|
|
@ -47,18 +36,10 @@ export const saveFormData = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// 클라이언트 IP 주소 추출
|
||||
const ipAddress =
|
||||
req.ip ||
|
||||
(req.headers["x-forwarded-for"] as string) ||
|
||||
req.socket.remoteAddress ||
|
||||
"unknown";
|
||||
|
||||
const result = await dynamicFormService.saveFormData(
|
||||
screenId,
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
ipAddress
|
||||
formDataWithMeta
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
@ -97,7 +78,6 @@ export const saveFormDataEnhanced = async (
|
|||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
|
|
@ -146,7 +126,6 @@ export const updateFormData = async (
|
|||
const formDataWithMeta = {
|
||||
...data,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
|
|
@ -199,11 +178,10 @@ export const updateFormDataPartial = async (
|
|||
const newDataWithMeta = {
|
||||
...newData,
|
||||
updated_by: userId,
|
||||
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
};
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
parseInt(id),
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
@ -230,8 +208,8 @@ export const deleteFormData = async (
|
|||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, screenId } = req.body;
|
||||
const { companyCode } = req.user as any;
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -240,16 +218,7 @@ export const deleteFormData = async (
|
|||
});
|
||||
}
|
||||
|
||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||
|
||||
await dynamicFormService.deleteFormData(
|
||||
id,
|
||||
tableName,
|
||||
companyCode,
|
||||
userId,
|
||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||
);
|
||||
await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -428,207 +397,3 @@ export const getTableColumns = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
export const updateFieldValue = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } =
|
||||
req.body;
|
||||
|
||||
console.log("🔄 [updateFieldValue] 요청:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!tableName ||
|
||||
!keyField ||
|
||||
keyValue === undefined ||
|
||||
!updateField ||
|
||||
updateValue === undefined
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!validNamePattern.test(tableName) ||
|
||||
!validNamePattern.test(keyField) ||
|
||||
!validNamePattern.test(updateField)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 업데이트 쿼리 실행
|
||||
const result = await dynamicFormService.updateFieldValue(
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log("✅ [updateFieldValue] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "필드 값이 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [updateFieldValue] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "필드 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 이력 저장 (연속 위치 추적용)
|
||||
* POST /api/dynamic-form/location-history
|
||||
*/
|
||||
export const saveLocationHistory = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId: loginUserId } = req.user as any;
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
recordedAt,
|
||||
vehicleId,
|
||||
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
|
||||
} = req.body;
|
||||
|
||||
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
|
||||
// 없으면 로그인한 사용자의 userId 사용
|
||||
const userId = requestUserId || loginUserId;
|
||||
|
||||
console.log("📍 [saveLocationHistory] 요청:", {
|
||||
userId,
|
||||
requestUserId,
|
||||
loginUserId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
tripId,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.saveLocationHistory({
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus: tripStatus || "active",
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
recordedAt: recordedAt || new Date().toISOString(),
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
console.log("✅ [saveLocationHistory] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "위치 이력이 저장되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [saveLocationHistory] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 이력 저장에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 이력 조회 (경로 조회용)
|
||||
* GET /api/dynamic-form/location-history/:tripId
|
||||
*/
|
||||
export const getLocationHistory = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.params;
|
||||
const { userId, startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📍 [getLocationHistory] 요청:", {
|
||||
tripId,
|
||||
userId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
});
|
||||
|
||||
const result = await dynamicFormService.getLocationHistory({
|
||||
companyCode,
|
||||
tripId,
|
||||
userId: userId as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 1000,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
count: result.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getLocationHistory] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 이력 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,9 +27,6 @@ export class EntityJoinController {
|
|||
enableEntityJoin = true,
|
||||
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
|
||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||
...otherParams
|
||||
} = req.query;
|
||||
|
|
@ -39,7 +36,6 @@ export class EntityJoinController {
|
|||
size,
|
||||
enableEntityJoin,
|
||||
search,
|
||||
autoFilter,
|
||||
});
|
||||
|
||||
// 검색 조건 처리
|
||||
|
|
@ -55,43 +51,6 @@ export class EntityJoinController {
|
|||
}
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 자동 필터 처리
|
||||
if (autoFilter) {
|
||||
try {
|
||||
const parsedAutoFilter =
|
||||
typeof autoFilter === "string" ? JSON.parse(autoFilter) : autoFilter;
|
||||
|
||||
if (parsedAutoFilter.enabled && (req as any).user) {
|
||||
const filterColumn = parsedAutoFilter.filterColumn || "company_code";
|
||||
const userField = parsedAutoFilter.userField || "companyCode";
|
||||
const userValue = ((req as any).user as any)[userField];
|
||||
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||
let finalCompanyCode = userValue;
|
||||
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||
originalCompanyCode: userValue,
|
||||
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalCompanyCode) {
|
||||
searchConditions[filterColumn] = finalCompanyCode;
|
||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||
filterColumn,
|
||||
finalCompanyCode,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("자동 필터 파싱 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 조인 컬럼 정보 처리
|
||||
let parsedAdditionalJoinColumns: any[] = [];
|
||||
if (additionalJoinColumns) {
|
||||
|
|
@ -125,32 +84,6 @@ export class EntityJoinController {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 데이터 필터 처리
|
||||
let parsedDataFilter: any = undefined;
|
||||
if (dataFilter) {
|
||||
try {
|
||||
parsedDataFilter =
|
||||
typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter;
|
||||
logger.info("데이터 필터 파싱 완료:", parsedDataFilter);
|
||||
} catch (error) {
|
||||
logger.warn("데이터 필터 파싱 오류:", error);
|
||||
parsedDataFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
let parsedExcludeFilter: any = undefined;
|
||||
if (excludeFilter) {
|
||||
try {
|
||||
parsedExcludeFilter =
|
||||
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
|
||||
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
|
||||
} catch (error) {
|
||||
logger.warn("제외 필터 파싱 오류:", error);
|
||||
parsedExcludeFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||
tableName,
|
||||
{
|
||||
|
|
@ -166,8 +99,6 @@ export class EntityJoinController {
|
|||
enableEntityJoin === "true" || enableEntityJoin === true,
|
||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -436,16 +367,18 @@ export class EntityJoinController {
|
|||
config.referenceTable
|
||||
);
|
||||
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
// 현재 display_column으로 사용 중인 컬럼 제외
|
||||
const currentDisplayColumn =
|
||||
config.displayColumn || config.displayColumns[0];
|
||||
|
||||
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
||||
const availableColumns = columns.filter(
|
||||
(col) => col.columnName !== currentDisplayColumn
|
||||
);
|
||||
|
||||
return {
|
||||
joinConfig: config,
|
||||
tableName: config.referenceTable,
|
||||
currentDisplayColumn: currentDisplayColumn,
|
||||
availableColumns: columns.map((col) => ({
|
||||
availableColumns: availableColumns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnName,
|
||||
dataType: col.dataType,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Request, Response } from "express";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface EntityReferenceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
|
@ -37,12 +39,12 @@ export class EntityReferenceController {
|
|||
});
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columnInfo = await queryOne<any>(
|
||||
`SELECT * FROM column_labels
|
||||
WHERE table_name = $1 AND column_name = $2
|
||||
LIMIT 1`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
const columnInfo = await prisma.column_labels.findFirst({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!columnInfo) {
|
||||
return res.status(404).json({
|
||||
|
|
@ -74,7 +76,7 @@ export class EntityReferenceController {
|
|||
|
||||
// 참조 테이블이 실제로 존재하는지 확인
|
||||
try {
|
||||
await query<any>(`SELECT 1 FROM ${referenceTable} LIMIT 1`);
|
||||
await prisma.$queryRawUnsafe(`SELECT 1 FROM ${referenceTable} LIMIT 1`);
|
||||
logger.info(
|
||||
`Entity 참조 설정: ${tableName}.${columnName} -> ${referenceTable}.${referenceColumn} (display: ${displayColumn})`
|
||||
);
|
||||
|
|
@ -90,26 +92,26 @@ export class EntityReferenceController {
|
|||
}
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
let query = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
const queryParams: any[] = [];
|
||||
|
||||
// 검색 조건 추가
|
||||
if (search) {
|
||||
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
||||
query += ` WHERE ${displayColumn} ILIKE $1`;
|
||||
queryParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||
query += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||
queryParams.push(Number(limit));
|
||||
|
||||
logger.info(`실행할 쿼리: ${sqlQuery}`, {
|
||||
logger.info(`실행할 쿼리: ${query}`, {
|
||||
queryParams,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
});
|
||||
|
||||
const referenceData = await query<any>(sqlQuery, queryParams);
|
||||
const referenceData = await prisma.$queryRawUnsafe(query, ...queryParams);
|
||||
|
||||
// 옵션 형태로 변환
|
||||
const options: EntityReferenceOption[] = (referenceData as any[]).map(
|
||||
|
|
@ -156,22 +158,29 @@ export class EntityReferenceController {
|
|||
});
|
||||
|
||||
// code_info 테이블에서 코드 데이터 조회
|
||||
const queryParams: any[] = [codeCategory, 'Y'];
|
||||
let sqlQuery = `
|
||||
SELECT code_value, code_name
|
||||
FROM code_info
|
||||
WHERE code_category = $1 AND is_active = $2
|
||||
`;
|
||||
let whereCondition: any = {
|
||||
code_category: codeCategory,
|
||||
is_active: "Y",
|
||||
};
|
||||
|
||||
if (search) {
|
||||
sqlQuery += ` AND code_name ILIKE $3`;
|
||||
queryParams.push(`%${search}%`);
|
||||
whereCondition.code_name = {
|
||||
contains: String(search),
|
||||
mode: "insensitive",
|
||||
};
|
||||
}
|
||||
|
||||
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
||||
queryParams.push(Number(limit));
|
||||
|
||||
const codeData = await query<any>(sqlQuery, queryParams);
|
||||
const codeData = await prisma.code_info.findMany({
|
||||
where: whereCondition,
|
||||
select: {
|
||||
code_value: true,
|
||||
code_name: true,
|
||||
},
|
||||
orderBy: {
|
||||
code_name: "asc",
|
||||
},
|
||||
take: Number(limit),
|
||||
});
|
||||
|
||||
// 옵션 형태로 변환
|
||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
searchText = "",
|
||||
searchFields = "",
|
||||
filterCondition = "{}",
|
||||
page = "1",
|
||||
limit = "20",
|
||||
} = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
// 멀티테넌시
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
// 검색 필드 파싱
|
||||
const requestedFields = searchFields
|
||||
? (searchFields as string).split(",").map((f) => f.trim())
|
||||
: [];
|
||||
|
||||
// 🆕 테이블의 실제 컬럼 목록 조회
|
||||
const pool = getPool();
|
||||
const columnsResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 🆕 존재하는 컬럼만 필터링
|
||||
const fields = requestedFields.filter((field) => {
|
||||
if (existingColumns.has(field)) {
|
||||
return true;
|
||||
} else {
|
||||
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const existingColumnsArray = Array.from(existingColumns);
|
||||
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
if (companyCode !== "*") {
|
||||
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
||||
if (existingColumns.has("company_code")) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 조건
|
||||
if (searchText) {
|
||||
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
||||
let searchableFields = fields;
|
||||
if (searchableFields.length === 0) {
|
||||
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
|
||||
const defaultSearchColumns = [
|
||||
'name', 'code', 'description', 'title', 'label',
|
||||
'item_name', 'item_code', 'item_number',
|
||||
'equipment_name', 'equipment_code',
|
||||
'inspection_item', 'consumable_name', // 소모품명 추가
|
||||
'supplier_name', 'customer_name', 'product_name',
|
||||
];
|
||||
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
|
||||
|
||||
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
|
||||
}
|
||||
|
||||
if (searchableFields.length > 0) {
|
||||
const searchConditions = searchableFields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
// 검색어 파라미터 추가
|
||||
searchableFields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 필터 조건 (존재하는 컬럼만)
|
||||
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
|
||||
// 특수 키 형식: column__operator (예: division__in, name__like)
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
// 특수 키 형식 파싱: column__operator
|
||||
let columnName = key;
|
||||
let operator = "=";
|
||||
|
||||
if (key.includes("__")) {
|
||||
const parts = key.split("__");
|
||||
columnName = parts[0];
|
||||
operator = parts[1] || "=";
|
||||
}
|
||||
|
||||
if (!existingColumns.has(columnName)) {
|
||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 연산자별 WHERE 조건 생성
|
||||
switch (operator) {
|
||||
case "=":
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "!=":
|
||||
whereConditions.push(`"${columnName}" != $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">":
|
||||
whereConditions.push(`"${columnName}" > $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<":
|
||||
whereConditions.push(`"${columnName}" < $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">=":
|
||||
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<=":
|
||||
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "in":
|
||||
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
|
||||
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (inValues.length > 0) {
|
||||
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" IN (${placeholders})`);
|
||||
params.push(...inValues);
|
||||
paramIndex += inValues.length;
|
||||
}
|
||||
break;
|
||||
case "notIn":
|
||||
// NOT IN 연산자
|
||||
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (notInValues.length > 0) {
|
||||
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
|
||||
params.push(...notInValues);
|
||||
paramIndex += notInValues.length;
|
||||
}
|
||||
break;
|
||||
case "like":
|
||||
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
|
||||
params.push(`%${value}%`);
|
||||
paramIndex++;
|
||||
break;
|
||||
default:
|
||||
// 알 수 없는 연산자는 등호로 처리
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 페이징
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const countResult = await pool.query(
|
||||
countQuery,
|
||||
params.slice(0, params.length - 2)
|
||||
);
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("엔티티 검색 성공", {
|
||||
tableName,
|
||||
searchText,
|
||||
companyCode,
|
||||
rowCount: dataResult.rowCount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엔티티 검색 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import excelMappingService from "../services/excelMappingService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
* POST /api/excel-mapping/find
|
||||
*/
|
||||
export async function findMappingByColumns(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, excelColumns } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 excelColumns(배열)가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 조회 요청", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
const template = await excelMappingService.findMappingByColumns(
|
||||
tableName,
|
||||
excelColumns,
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (template) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: "기존 매핑 템플릿을 찾았습니다.",
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: "일치하는 매핑 템플릿이 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 저장 (UPSERT)
|
||||
* POST /api/excel-mapping/save
|
||||
*/
|
||||
export async function saveMappingTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, excelColumns, columnMappings } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!tableName || !excelColumns || !columnMappings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, excelColumns, columnMappings가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 저장 요청", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const template = await excelMappingService.saveMappingTemplate(
|
||||
tableName,
|
||||
excelColumns,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: "매핑 템플릿이 저장되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 저장 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 매핑 템플릿 목록 조회
|
||||
* GET /api/excel-mapping/list/:tableName
|
||||
*/
|
||||
export async function getMappingTemplates(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 목록 조회 요청", {
|
||||
tableName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const templates = await excelMappingService.getMappingTemplates(
|
||||
tableName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 삭제
|
||||
* DELETE /api/excel-mapping/:id
|
||||
*/
|
||||
export async function deleteMappingTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "id가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 삭제 요청", {
|
||||
id,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const deleted = await excelMappingService.deleteMappingTemplate(
|
||||
parseInt(id),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (deleted) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "매핑 템플릿이 삭제되었습니다.",
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3,8 +3,10 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { generateUUID } from "../utils/generateId";
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
||||
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
||||
|
|
@ -62,44 +64,41 @@ const storage = multer.diskStorage({
|
|||
filename: (req, file, cb) => {
|
||||
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
|
||||
const timestamp = Date.now();
|
||||
|
||||
|
||||
console.log("📁 파일명 처리:", {
|
||||
originalname: file.originalname,
|
||||
encoding: file.encoding,
|
||||
mimetype: file.mimetype,
|
||||
mimetype: file.mimetype
|
||||
});
|
||||
|
||||
|
||||
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
|
||||
let decodedName;
|
||||
try {
|
||||
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
|
||||
const buffer = Buffer.from(file.originalname, "latin1");
|
||||
decodedName = buffer.toString("utf8");
|
||||
console.log("📁 파일명 디코딩:", {
|
||||
original: file.originalname,
|
||||
decoded: decodedName,
|
||||
});
|
||||
const buffer = Buffer.from(file.originalname, 'latin1');
|
||||
decodedName = buffer.toString('utf8');
|
||||
console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName });
|
||||
} catch (error) {
|
||||
// 디코딩 실패 시 원본 사용
|
||||
decodedName = file.originalname;
|
||||
console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
||||
}
|
||||
|
||||
|
||||
// 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성
|
||||
// 위험한 문자만 제거: / \ : * ? " < > |
|
||||
const sanitizedName = decodedName
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
|
||||
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
|
||||
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
|
||||
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
|
||||
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
|
||||
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
|
||||
|
||||
const savedFileName = `${timestamp}_${sanitizedName}`;
|
||||
|
||||
|
||||
console.log("📁 파일명 변환:", {
|
||||
original: file.originalname,
|
||||
sanitized: sanitizedName,
|
||||
saved: savedFileName,
|
||||
saved: savedFileName
|
||||
});
|
||||
|
||||
|
||||
cb(null, savedFileName);
|
||||
},
|
||||
});
|
||||
|
|
@ -170,7 +169,7 @@ const upload = multer({
|
|||
"audio/ogg",
|
||||
// Apple/맥 파일
|
||||
"application/vnd.apple.pages", // .pages (Pages)
|
||||
"application/vnd.apple.numbers", // .numbers (Numbers)
|
||||
"application/vnd.apple.numbers", // .numbers (Numbers)
|
||||
"application/vnd.apple.keynote", // .keynote (Keynote)
|
||||
"application/x-iwork-pages-sffpages", // .pages (다른 MIME)
|
||||
"application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME)
|
||||
|
|
@ -232,11 +231,7 @@ export const uploadFiles = async (
|
|||
|
||||
// 자동 연결 로직 - target_objid 자동 생성
|
||||
let finalTargetObjid = targetObjid;
|
||||
|
||||
// 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시
|
||||
const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_'));
|
||||
|
||||
if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) {
|
||||
if (autoLink === "true" && linkedTable && recordId) {
|
||||
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
|
||||
if (isVirtualFileColumn === "true" && columnName) {
|
||||
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
|
||||
|
|
@ -251,20 +246,14 @@ export const uploadFiles = async (
|
|||
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
|
||||
let decodedOriginalName;
|
||||
try {
|
||||
const buffer = Buffer.from(file.originalname, "latin1");
|
||||
decodedOriginalName = buffer.toString("utf8");
|
||||
console.log("💾 DB 저장용 파일명 디코딩:", {
|
||||
original: file.originalname,
|
||||
decoded: decodedOriginalName,
|
||||
});
|
||||
const buffer = Buffer.from(file.originalname, 'latin1');
|
||||
decodedOriginalName = buffer.toString('utf8');
|
||||
console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName });
|
||||
} catch (error) {
|
||||
decodedOriginalName = file.originalname;
|
||||
console.log(
|
||||
"💾 DB 저장용 파일명 디코딩 실패, 원본 사용:",
|
||||
file.originalname
|
||||
);
|
||||
console.log("💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
||||
}
|
||||
|
||||
|
||||
// 파일 확장자 추출
|
||||
const fileExt = path
|
||||
.extname(decodedOriginalName)
|
||||
|
|
@ -280,7 +269,7 @@ export const uploadFiles = async (
|
|||
|
||||
// 회사코드가 *인 경우 company_*로 변환
|
||||
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
||||
|
||||
|
||||
// 임시 파일을 최종 위치로 이동
|
||||
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
||||
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
||||
|
|
@ -294,34 +283,27 @@ export const uploadFiles = async (
|
|||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
// attach_file_info 테이블에 저장
|
||||
const objidValue = parseInt(
|
||||
generateUUID().replace(/-/g, "").substring(0, 15),
|
||||
16
|
||||
);
|
||||
|
||||
const [fileRecord] = await query<any>(
|
||||
`INSERT INTO attach_file_info (
|
||||
objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name,
|
||||
file_size, file_ext, file_path, company_code, writer, regdate, status, parent_target_objid
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
objidValue,
|
||||
finalTargetObjid,
|
||||
file.filename,
|
||||
decodedOriginalName,
|
||||
docType,
|
||||
docTypeName,
|
||||
file.size,
|
||||
fileExt,
|
||||
fullFilePath,
|
||||
companyCode,
|
||||
writer,
|
||||
new Date(),
|
||||
"ACTIVE",
|
||||
parentTargetObjid,
|
||||
]
|
||||
);
|
||||
const fileRecord = await prisma.attach_file_info.create({
|
||||
data: {
|
||||
objid: parseInt(
|
||||
generateUUID().replace(/-/g, "").substring(0, 15),
|
||||
16
|
||||
),
|
||||
target_objid: finalTargetObjid,
|
||||
saved_file_name: file.filename,
|
||||
real_file_name: decodedOriginalName,
|
||||
doc_type: docType,
|
||||
doc_type_name: docTypeName,
|
||||
file_size: file.size,
|
||||
file_ext: fileExt,
|
||||
file_path: fullFilePath, // 회사별 디렉토리 포함된 경로
|
||||
company_code: companyCode, // 회사코드 추가
|
||||
writer: writer,
|
||||
regdate: new Date(),
|
||||
status: "ACTIVE",
|
||||
parent_target_objid: parentTargetObjid,
|
||||
},
|
||||
});
|
||||
|
||||
savedFiles.push({
|
||||
objid: fileRecord.objid.toString(),
|
||||
|
|
@ -341,64 +323,6 @@ export const uploadFiles = async (
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
|
||||
|
||||
// 🔍 디버깅: 레코드 모드 조건 확인
|
||||
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
|
||||
isRecordMode,
|
||||
linkedTable,
|
||||
recordId,
|
||||
columnName,
|
||||
finalTargetObjid,
|
||||
"req.body.isRecordMode": req.body.isRecordMode,
|
||||
"req.body.linkedTable": req.body.linkedTable,
|
||||
"req.body.recordId": req.body.recordId,
|
||||
"req.body.columnName": req.body.columnName,
|
||||
});
|
||||
|
||||
if (isRecordMode && linkedTable && recordId && columnName) {
|
||||
try {
|
||||
// 해당 레코드의 모든 첨부파일 조회
|
||||
const allFiles = await query<any>(
|
||||
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||
ORDER BY regdate DESC`,
|
||||
[finalTargetObjid]
|
||||
);
|
||||
|
||||
// attachments JSONB 형태로 변환
|
||||
const attachmentsJson = allFiles.map((f: any) => ({
|
||||
objid: f.objid.toString(),
|
||||
realFileName: f.real_file_name,
|
||||
fileSize: Number(f.file_size),
|
||||
fileExt: f.file_ext,
|
||||
filePath: f.file_path,
|
||||
regdate: f.regdate?.toISOString(),
|
||||
}));
|
||||
|
||||
// 해당 테이블의 attachments 컬럼 업데이트
|
||||
// 🔒 멀티테넌시: company_code 필터 추가
|
||||
await query(
|
||||
`UPDATE ${linkedTable}
|
||||
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[JSON.stringify(attachmentsJson), recordId, companyCode]
|
||||
);
|
||||
|
||||
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
|
||||
tableName: linkedTable,
|
||||
recordId: recordId,
|
||||
columnName: columnName,
|
||||
fileCount: attachmentsJson.length,
|
||||
});
|
||||
} catch (updateError) {
|
||||
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
|
||||
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${files.length}개 파일 업로드 완료`,
|
||||
|
|
@ -425,93 +349,15 @@ export const deleteFile = async (
|
|||
const { objid } = req.params;
|
||||
const { writer = "system" } = req.body;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
||||
[parseInt(objid)]
|
||||
);
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 삭제 시도:", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode: companyCode,
|
||||
fileCompanyCode: fileRecord.company_code,
|
||||
objid,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
||||
await query<any>(
|
||||
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
|
||||
["DELETED", parseInt(objid)]
|
||||
);
|
||||
|
||||
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||
const targetObjid = fileRecord.target_objid;
|
||||
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
||||
// targetObjid 파싱: tableName:recordId:columnName 형식
|
||||
const parts = targetObjid.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const [tableName, recordId, columnName] = parts;
|
||||
|
||||
try {
|
||||
// 해당 레코드의 남은 첨부파일 조회
|
||||
const remainingFiles = await query<any>(
|
||||
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||
ORDER BY regdate DESC`,
|
||||
[targetObjid]
|
||||
);
|
||||
|
||||
// attachments JSONB 형태로 변환
|
||||
const attachmentsJson = remainingFiles.map((f: any) => ({
|
||||
objid: f.objid.toString(),
|
||||
realFileName: f.real_file_name,
|
||||
fileSize: Number(f.file_size),
|
||||
fileExt: f.file_ext,
|
||||
filePath: f.file_path,
|
||||
regdate: f.regdate?.toISOString(),
|
||||
}));
|
||||
|
||||
// 해당 테이블의 attachments 컬럼 업데이트
|
||||
// 🔒 멀티테넌시: company_code 필터 추가
|
||||
await query(
|
||||
`UPDATE ${tableName}
|
||||
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
||||
);
|
||||
|
||||
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
remainingFiles: attachmentsJson.length,
|
||||
});
|
||||
} catch (updateError) {
|
||||
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
||||
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||
}
|
||||
}
|
||||
}
|
||||
const deletedFile = await prisma.attach_file_info.update({
|
||||
where: {
|
||||
objid: parseInt(objid),
|
||||
},
|
||||
data: {
|
||||
status: "DELETED",
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -541,12 +387,17 @@ export const getLinkedFiles = async (
|
|||
const baseTargetObjid = `${tableName}:${recordId}`;
|
||||
|
||||
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
|
||||
const files = await query<any>(
|
||||
`SELECT * FROM attach_file_info
|
||||
WHERE target_objid LIKE $1 AND status = $2
|
||||
ORDER BY regdate DESC`,
|
||||
[`${baseTargetObjid}%`, "ACTIVE"]
|
||||
);
|
||||
const files = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: {
|
||||
startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일
|
||||
},
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const fileList = files.map((file: any) => ({
|
||||
objid: file.objid.toString(),
|
||||
|
|
@ -590,28 +441,24 @@ export const getFileList = async (
|
|||
try {
|
||||
const { targetObjid, docType, companyCode } = req.query;
|
||||
|
||||
const whereConditions: string[] = ["status = $1"];
|
||||
const queryParams: any[] = ["ACTIVE"];
|
||||
let paramIndex = 2;
|
||||
const where: any = {
|
||||
status: "ACTIVE",
|
||||
};
|
||||
|
||||
if (targetObjid) {
|
||||
whereConditions.push(`target_objid = $${paramIndex}`);
|
||||
queryParams.push(targetObjid as string);
|
||||
paramIndex++;
|
||||
where.target_objid = targetObjid as string;
|
||||
}
|
||||
|
||||
if (docType) {
|
||||
whereConditions.push(`doc_type = $${paramIndex}`);
|
||||
queryParams.push(docType as string);
|
||||
paramIndex++;
|
||||
where.doc_type = docType as string;
|
||||
}
|
||||
|
||||
const files = await query<any>(
|
||||
`SELECT * FROM attach_file_info
|
||||
WHERE ${whereConditions.join(" AND ")}
|
||||
ORDER BY regdate DESC`,
|
||||
queryParams
|
||||
);
|
||||
const files = await prisma.attach_file_info.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const fileList = files.map((file: any) => ({
|
||||
objid: file.objid.toString(),
|
||||
|
|
@ -651,20 +498,15 @@ export const getComponentFiles = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, componentId, tableName, recordId, columnName } =
|
||||
req.query;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const { screenId, componentId, tableName, recordId, columnName } = req.query;
|
||||
|
||||
console.log("📂 [getComponentFiles] API 호출:", {
|
||||
screenId,
|
||||
componentId,
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
user: req.user?.userId,
|
||||
companyCode, // 🔒 멀티테넌시 로그
|
||||
user: req.user?.userId
|
||||
});
|
||||
|
||||
if (!screenId || !componentId) {
|
||||
|
|
@ -677,35 +519,51 @@ export const getComponentFiles = async (
|
|||
}
|
||||
|
||||
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
||||
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || "field_1"}`;
|
||||
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", {
|
||||
templateTargetObjid,
|
||||
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`;
|
||||
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid });
|
||||
|
||||
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
||||
const allFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: {
|
||||
target_objid: true,
|
||||
real_file_name: true,
|
||||
regdate: true,
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
// 🔒 멀티테넌시: 회사별 필터링 추가
|
||||
const templateFiles = await query<any>(
|
||||
`SELECT * FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
||||
ORDER BY regdate DESC`,
|
||||
[templateTargetObjid, "ACTIVE", companyCode]
|
||||
);
|
||||
|
||||
console.log(
|
||||
"📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):",
|
||||
templateFiles.length
|
||||
);
|
||||
console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name })));
|
||||
|
||||
const templateFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: templateTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length);
|
||||
|
||||
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
||||
let dataFiles: any[] = [];
|
||||
if (tableName && recordId && columnName) {
|
||||
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
// 🔒 멀티테넌시: 회사별 필터링 추가
|
||||
dataFiles = await query<any>(
|
||||
`SELECT * FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = $2 AND company_code = $3
|
||||
ORDER BY regdate DESC`,
|
||||
[dataTargetObjid, "ACTIVE", companyCode]
|
||||
);
|
||||
dataFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: dataTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 정보 포맷팅 함수
|
||||
|
|
@ -724,21 +582,15 @@ export const getComponentFiles = async (
|
|||
regdate: file.regdate?.toISOString(),
|
||||
status: file.status,
|
||||
isTemplate, // 템플릿 파일 여부 표시
|
||||
isRepresentative: file.is_representative || false, // 대표 파일 여부
|
||||
});
|
||||
|
||||
const formattedTemplateFiles = templateFiles.map((file) =>
|
||||
formatFileInfo(file, true)
|
||||
);
|
||||
const formattedDataFiles = dataFiles.map((file) =>
|
||||
formatFileInfo(file, false)
|
||||
);
|
||||
const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true));
|
||||
const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false));
|
||||
|
||||
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
||||
const totalFiles =
|
||||
formattedDataFiles.length > 0
|
||||
? formattedDataFiles
|
||||
: formattedTemplateFiles;
|
||||
const totalFiles = formattedDataFiles.length > 0
|
||||
? formattedDataFiles
|
||||
: formattedTemplateFiles;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -750,10 +602,9 @@ export const getComponentFiles = async (
|
|||
dataCount: formattedDataFiles.length,
|
||||
totalCount: totalFiles.length,
|
||||
templateTargetObjid,
|
||||
dataTargetObjid:
|
||||
tableName && recordId && columnName
|
||||
? `${tableName}:${recordId}:${columnName}`
|
||||
: null,
|
||||
dataTargetObjid: tableName && recordId && columnName
|
||||
? `${tableName}:${recordId}:${columnName}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -777,13 +628,11 @@ export const previewFile = async (
|
|||
const { objid } = req.params;
|
||||
const { serverFilename } = req.query;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const fileRecord = await queryOne<any>(
|
||||
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||
[parseInt(objid)]
|
||||
);
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: {
|
||||
objid: parseInt(objid),
|
||||
},
|
||||
});
|
||||
|
||||
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
||||
res.status(404).json({
|
||||
|
|
@ -793,30 +642,15 @@ export const previewFile = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 접근 시도:", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode: companyCode,
|
||||
fileCompanyCode: fileRecord.company_code,
|
||||
objid,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let fileCompanyCode = filePathParts[2] || "DEFAULT";
|
||||
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (fileCompanyCode === "company_*") {
|
||||
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
|
|
@ -826,7 +660,7 @@ export const previewFile = async (
|
|||
}
|
||||
|
||||
const companyUploadDir = getCompanyUploadDir(
|
||||
fileCompanyCode,
|
||||
companyCode,
|
||||
dateFolder || undefined
|
||||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
|
@ -839,7 +673,7 @@ export const previewFile = async (
|
|||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath),
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
|
|
@ -876,9 +710,8 @@ export const previewFile = async (
|
|||
mimeType = "application/octet-stream";
|
||||
}
|
||||
|
||||
// CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요)
|
||||
const origin = req.headers.origin || "http://localhost:9771";
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
// CORS 헤더 설정 (더 포괄적으로)
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS"
|
||||
|
|
@ -915,13 +748,11 @@ export const downloadFile = async (
|
|||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
||||
[parseInt(objid)]
|
||||
);
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: {
|
||||
objid: parseInt(objid),
|
||||
},
|
||||
});
|
||||
|
||||
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
||||
res.status(404).json({
|
||||
|
|
@ -931,30 +762,15 @@ export const downloadFile = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 다운로드 시도:", {
|
||||
userId: req.user?.userId,
|
||||
userCompanyCode: companyCode,
|
||||
fileCompanyCode: fileRecord.company_code,
|
||||
objid,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
|
||||
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (fileCompanyCode === "company_*") {
|
||||
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
|
|
@ -965,7 +781,7 @@ export const downloadFile = async (
|
|||
}
|
||||
|
||||
const companyUploadDir = getCompanyUploadDir(
|
||||
fileCompanyCode,
|
||||
companyCode,
|
||||
dateFolder || undefined
|
||||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
|
@ -978,7 +794,7 @@ export const downloadFile = async (
|
|||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath),
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
|
|
@ -1013,10 +829,7 @@ export const downloadFile = async (
|
|||
/**
|
||||
* Google Docs Viewer용 임시 공개 토큰 생성
|
||||
*/
|
||||
export const generateTempToken = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
|
|
@ -1029,10 +842,9 @@ export const generateTempToken = async (
|
|||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
const fileRecord = await queryOne<any>(
|
||||
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||
[objid]
|
||||
);
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
|
|
@ -1112,10 +924,9 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await queryOne<any>(
|
||||
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||
[tokenData.objid]
|
||||
);
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: tokenData.objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
|
|
@ -1136,10 +947,7 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
|||
if (filePathParts.length >= 6) {
|
||||
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
||||
}
|
||||
const companyUploadDir = getCompanyUploadDir(
|
||||
companyCode,
|
||||
dateFolder || undefined
|
||||
);
|
||||
const companyUploadDir = getCompanyUploadDir(companyCode, dateFolder || undefined);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
// 파일 존재 확인
|
||||
|
|
@ -1154,18 +962,15 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
|||
// MIME 타입 설정
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
|
||||
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx":
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
|
|
@ -1179,10 +984,7 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
|||
|
||||
// 파일 헤더 설정
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
|
||||
);
|
||||
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`);
|
||||
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
||||
|
||||
// 파일 스트림 전송
|
||||
|
|
@ -1197,68 +999,5 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 대표 파일 설정
|
||||
*/
|
||||
export const setRepresentativeFile = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 파일 존재 여부 및 권한 확인
|
||||
const fileRecord = await queryOne<any>(
|
||||
`SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`,
|
||||
[parseInt(objid), "ACTIVE"]
|
||||
);
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 멀티테넌시: 회사 코드 확인
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 target_objid의 다른 파일들의 is_representative를 false로 설정
|
||||
await query<any>(
|
||||
`UPDATE attach_file_info
|
||||
SET is_representative = false
|
||||
WHERE target_objid = $1 AND objid != $2`,
|
||||
[fileRecord.target_objid, parseInt(objid)]
|
||||
);
|
||||
|
||||
// 선택한 파일을 대표 파일로 설정
|
||||
await query<any>(
|
||||
`UPDATE attach_file_info
|
||||
SET is_representative = true
|
||||
WHERE objid = $1`,
|
||||
[parseInt(objid)]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "대표 파일이 설정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("대표 파일 설정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "대표 파일 설정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Multer 미들웨어 export
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
|
|
|||
|
|
@ -1,889 +0,0 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* 플로우 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { FlowDefinitionService } from "../services/flowDefinitionService";
|
||||
import { FlowStepService } from "../services/flowStepService";
|
||||
import { FlowConnectionService } from "../services/flowConnectionService";
|
||||
import { FlowExecutionService } from "../services/flowExecutionService";
|
||||
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
||||
|
||||
export class FlowController {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
private flowStepService: FlowStepService;
|
||||
private flowConnectionService: FlowConnectionService;
|
||||
private flowExecutionService: FlowExecutionService;
|
||||
private flowDataMoveService: FlowDataMoveService;
|
||||
|
||||
constructor() {
|
||||
this.flowDefinitionService = new FlowDefinitionService();
|
||||
this.flowStepService = new FlowStepService();
|
||||
this.flowConnectionService = new FlowConnectionService();
|
||||
this.flowExecutionService = new FlowExecutionService();
|
||||
this.flowDataMoveService = new FlowDataMoveService();
|
||||
}
|
||||
|
||||
// ==================== 플로우 정의 ====================
|
||||
|
||||
/**
|
||||
* 플로우 정의 생성
|
||||
*/
|
||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
console.log("🔍 createFlowDefinition called with:", {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "Name is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
||||
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
|
||||
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
|
||||
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
|
||||
const tableExists =
|
||||
await this.flowDefinitionService.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `Table '${tableName}' does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.create(
|
||||
{
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||
},
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flowDef,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error creating flow definition:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create flow definition",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 목록 조회
|
||||
*/
|
||||
getFlowDefinitions = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { tableName, isActive } = req.query;
|
||||
const user = (req as any).user;
|
||||
const userCompanyCode = user?.companyCode;
|
||||
|
||||
console.log("🎯 getFlowDefinitions called:", {
|
||||
userId: user?.userId,
|
||||
userCompanyCode: userCompanyCode,
|
||||
userType: user?.userType,
|
||||
tableName,
|
||||
isActive,
|
||||
});
|
||||
|
||||
const flows = await this.flowDefinitionService.findAll(
|
||||
tableName as string | undefined,
|
||||
isActive !== undefined ? isActive === "true" : undefined,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow definitions:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow definitions",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 상세 조회 (단계 및 연결 포함)
|
||||
*/
|
||||
getFlowDefinitionDetail = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
|
||||
const definition = await this.flowDefinitionService.findById(flowId);
|
||||
if (!definition) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = await this.flowStepService.findByFlowId(flowId);
|
||||
const connections = await this.flowConnectionService.findByFlowId(flowId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
definition,
|
||||
steps,
|
||||
connections,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow definition detail:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow definition detail",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 수정
|
||||
*/
|
||||
updateFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
const { name, description, isActive } = req.body;
|
||||
|
||||
const flowDef = await this.flowDefinitionService.update(flowId, {
|
||||
name,
|
||||
description,
|
||||
isActive,
|
||||
});
|
||||
|
||||
if (!flowDef) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flowDef,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating flow definition:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update flow definition",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 정의 삭제
|
||||
*/
|
||||
deleteFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
|
||||
const success = await this.flowDefinitionService.delete(flowId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Flow definition deleted successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting flow definition:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to delete flow definition",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 플로우 단계 ====================
|
||||
|
||||
/**
|
||||
* 플로우 단계 목록 조회
|
||||
*/
|
||||
getFlowSteps = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
|
||||
const steps = await this.flowStepService.findByFlowId(flowDefinitionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: steps,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow steps:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow steps",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 단계 생성
|
||||
*/
|
||||
createFlowStep = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
const {
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
} = req.body;
|
||||
|
||||
if (!stepName || stepOrder === undefined) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "stepName and stepOrder are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const step = await this.flowStepService.create({
|
||||
flowDefinitionId,
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: step,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error creating flow step:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create flow step",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 단계 수정
|
||||
*/
|
||||
updateFlowStep = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { stepId } = req.params;
|
||||
const id = parseInt(stepId);
|
||||
const {
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
moveType,
|
||||
statusColumn,
|
||||
statusValue,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
integrationType,
|
||||
integrationConfig,
|
||||
displayConfig,
|
||||
} = req.body;
|
||||
|
||||
const step = await this.flowStepService.update(id, {
|
||||
stepName,
|
||||
stepOrder,
|
||||
tableName,
|
||||
conditionJson,
|
||||
color,
|
||||
positionX,
|
||||
positionY,
|
||||
moveType,
|
||||
statusColumn,
|
||||
statusValue,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
integrationType,
|
||||
integrationConfig,
|
||||
displayConfig,
|
||||
});
|
||||
|
||||
if (!step) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: step,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating flow step:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update flow step",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 단계 삭제
|
||||
*/
|
||||
deleteFlowStep = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { stepId } = req.params;
|
||||
const id = parseInt(stepId);
|
||||
|
||||
const success = await this.flowStepService.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Flow step deleted successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting flow step:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to delete flow step",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 플로우 연결 ====================
|
||||
|
||||
/**
|
||||
* 플로우 연결 목록 조회
|
||||
*/
|
||||
getFlowConnections = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
|
||||
const connections =
|
||||
await this.flowConnectionService.findByFlowId(flowDefinitionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connections,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching flow connections:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to fetch flow connections",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 연결 생성
|
||||
*/
|
||||
createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
|
||||
|
||||
if (!flowDefinitionId || !fromStepId || !toStepId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowDefinitionId, fromStepId, and toStepId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.flowConnectionService.create({
|
||||
flowDefinitionId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
label,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error creating connection:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create connection",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 연결 삭제
|
||||
*/
|
||||
deleteConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { connectionId } = req.params;
|
||||
const id = parseInt(connectionId);
|
||||
|
||||
const success = await this.flowConnectionService.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Connection not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Connection deleted successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting connection:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to delete connection",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 플로우 실행 ====================
|
||||
|
||||
/**
|
||||
* 단계별 데이터 카운트 조회
|
||||
*/
|
||||
getStepDataCount = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
|
||||
const count = await this.flowExecutionService.getStepDataCount(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { count },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting step data count:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step data count",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 단계별 데이터 리스트 조회
|
||||
*/
|
||||
getStepDataList = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
const { page = 1, pageSize = 20 } = req.query;
|
||||
|
||||
const data = await this.flowExecutionService.getStepDataList(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId),
|
||||
parseInt(page as string),
|
||||
parseInt(pageSize as string)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting step data list:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step data list",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 스텝의 컬럼 라벨 조회
|
||||
*/
|
||||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
|
||||
flowId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
const step = await this.flowStepService.findById(parseInt(stepId));
|
||||
if (!step) {
|
||||
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.findById(
|
||||
parseInt(flowId)
|
||||
);
|
||||
if (!flowDef) {
|
||||
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
console.log("📋 [FlowController] 테이블명 결정:", {
|
||||
stepTableName: step.tableName,
|
||||
flowTableName: flowDef.tableName,
|
||||
selectedTableName: tableName,
|
||||
});
|
||||
|
||||
if (!tableName) {
|
||||
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
|
||||
res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// column_labels 테이블에서 라벨 정보 조회
|
||||
const { query } = await import("../database/db");
|
||||
const labelRows = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label
|
||||
FROM column_labels
|
||||
WHERE table_name = $1 AND column_label IS NOT NULL`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`✅ [FlowController] column_labels 조회 완료:`, {
|
||||
tableName,
|
||||
rowCount: labelRows.length,
|
||||
labels: labelRows.map((r) => ({
|
||||
col: r.column_name,
|
||||
label: r.column_label,
|
||||
})),
|
||||
});
|
||||
|
||||
// { columnName: label } 형태의 객체로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
labelRows.forEach((row) => {
|
||||
if (row.column_label) {
|
||||
labels[row.column_name] = row.column_label;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: labels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [FlowController] 컬럼 라벨 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step column labels",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우의 모든 단계별 카운트 조회
|
||||
*/
|
||||
getAllStepCounts = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
const counts = await this.flowExecutionService.getAllStepCounts(
|
||||
parseInt(flowId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: counts,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting all step counts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get all step counts",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터를 다음 단계로 이동
|
||||
*/
|
||||
moveData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, recordId, toStepId, note } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
if (!flowId || !recordId || !toStepId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, recordId, and toStepId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.flowDataMoveService.moveDataToStep(
|
||||
flowId,
|
||||
recordId,
|
||||
toStepId,
|
||||
userId,
|
||||
note
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data moved successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error moving data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to move data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 여러 데이터를 동시에 이동
|
||||
*/
|
||||
moveBatchData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, fromStepId, toStepId, dataIds } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
if (
|
||||
!flowId ||
|
||||
!fromStepId ||
|
||||
!toStepId ||
|
||||
!dataIds ||
|
||||
!Array.isArray(dataIds)
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"flowId, fromStepId, toStepId, and dataIds (array) are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.flowDataMoveService.moveBatchData(
|
||||
flowId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
dataIds,
|
||||
userId
|
||||
);
|
||||
|
||||
const successCount = result.results.filter((r) => r.success).length;
|
||||
const failureCount = result.results.filter((r) => !r.success).length;
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `${successCount}건의 데이터를 성공적으로 이동했습니다`
|
||||
: `${successCount}건 성공, ${failureCount}건 실패`,
|
||||
data: {
|
||||
successCount,
|
||||
failureCount,
|
||||
total: dataIds.length,
|
||||
},
|
||||
results: result.results,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error moving batch data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to move batch data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터의 플로우 이력 조회
|
||||
*/
|
||||
getAuditLogs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, recordId } = req.params;
|
||||
|
||||
const logs = await this.flowDataMoveService.getAuditLogs(
|
||||
parseInt(flowId),
|
||||
recordId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting audit logs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get audit logs",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우의 모든 이력 조회
|
||||
*/
|
||||
getFlowAuditLogs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const { limit = 100 } = req.query;
|
||||
|
||||
const logs = await this.flowDataMoveService.getFlowAuditLogs(
|
||||
parseInt(flowId),
|
||||
parseInt(limit as string)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting flow audit logs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get flow audit logs",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스텝 데이터 업데이트 (인라인 편집)
|
||||
*/
|
||||
updateStepData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId, recordId } = req.params;
|
||||
const updateData = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
if (!flowId || !stepId || !recordId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, stepId, and recordId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updateData || Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "Update data is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.flowExecutionService.updateStepData(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId),
|
||||
recordId,
|
||||
updateData,
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data updated successfully",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating step data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update step data",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { FlowExternalDbConnectionService } from "../services/flowExternalDbConnectionService";
|
||||
import {
|
||||
CreateFlowExternalDbConnectionRequest,
|
||||
UpdateFlowExternalDbConnectionRequest,
|
||||
} from "../types/flow";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 플로우 전용 외부 DB 연결 컨트롤러
|
||||
*/
|
||||
export class FlowExternalDbConnectionController {
|
||||
private service: FlowExternalDbConnectionService;
|
||||
|
||||
constructor() {
|
||||
this.service = new FlowExternalDbConnectionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections
|
||||
* 모든 외부 DB 연결 목록 조회
|
||||
*/
|
||||
async getAll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const activeOnly = req.query.activeOnly === "true";
|
||||
const connections = await this.service.findAll(activeOnly);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connections,
|
||||
message: `${connections.length}개의 외부 DB 연결을 조회했습니다`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections/:id
|
||||
* 특정 외부 DB 연결 조회
|
||||
*/
|
||||
async getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.service.findById(id);
|
||||
|
||||
if (!connection) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flow/external-db-connections
|
||||
* 새 외부 DB 연결 생성
|
||||
*/
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const request: CreateFlowExternalDbConnectionRequest = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!request.name ||
|
||||
!request.dbType ||
|
||||
!request.host ||
|
||||
!request.port ||
|
||||
!request.databaseName ||
|
||||
!request.username ||
|
||||
!request.password
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const connection = await this.service.create(request, userId);
|
||||
|
||||
logger.info(
|
||||
`외부 DB 연결 생성: ${connection.name} (ID: ${connection.id})`
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: connection,
|
||||
message: "외부 DB 연결이 생성되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 생성 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/flow/external-db-connections/:id
|
||||
* 외부 DB 연결 수정
|
||||
*/
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const request: UpdateFlowExternalDbConnectionRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
const connection = await this.service.update(id, request, userId);
|
||||
|
||||
if (!connection) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`외부 DB 연결 수정: ${connection.name} (ID: ${id})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
message: "외부 DB 연결이 수정되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 수정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 수정 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/flow/external-db-connections/:id
|
||||
* 외부 DB 연결 삭제
|
||||
*/
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await this.service.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`외부 DB 연결 삭제: ID ${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "외부 DB 연결이 삭제되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flow/external-db-connections/:id/test
|
||||
* 외부 DB 연결 테스트
|
||||
*/
|
||||
async testConnection(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.service.testConnection(id);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`외부 DB 연결 테스트 성공: ID ${id}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`외부 DB 연결 테스트 실패: ID ${id} - ${result.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 연결 테스트 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 테스트 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections/:id/tables
|
||||
* 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
async getTables(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.service.getTables(id);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 테이블 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/flow/external-db-connections/:id/tables/:tableName/columns
|
||||
* 외부 DB 특정 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
async getTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const tableName = req.params.tableName;
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.service.getTableColumns(id, tableName);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 DB 컬럼 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailAccountFileService } from '../services/mailAccountFileService';
|
||||
|
||||
export class MailAccountFileController {
|
||||
async getAllAccounts(req: Request, res: Response) {
|
||||
try {
|
||||
const accounts = await mailAccountFileService.getAllAccounts();
|
||||
|
||||
// 비밀번호는 반환하지 않음
|
||||
const safeAccounts = accounts.map(({ smtpPassword, ...account }) => account);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: safeAccounts,
|
||||
total: safeAccounts.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const account = await mailAccountFileService.getAccountById(id);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호는 마스킹 처리
|
||||
const { smtpPassword, ...safeAccount } = account;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...safeAccount,
|
||||
smtpPassword: '••••••••', // 마스킹
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
email,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
dailyLimit,
|
||||
status,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !email || !smtpHost || !smtpPort || !smtpUsername || !smtpPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingAccount = await mailAccountFileService.getAccountByEmail(email);
|
||||
if (existingAccount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 등록된 이메일입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const account = await mailAccountFileService.createAccount({
|
||||
name,
|
||||
email,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure: smtpSecure || false,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
dailyLimit: dailyLimit || 1000,
|
||||
status: status || 'active',
|
||||
});
|
||||
|
||||
// 비밀번호 제외하고 반환
|
||||
const { smtpPassword: _, ...safeAccount } = account;
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: safeAccount,
|
||||
message: '메일 계정이 생성되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const account = await mailAccountFileService.updateAccount(id, updates);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 제외하고 반환
|
||||
const { smtpPassword: _, ...safeAccount } = account;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: safeAccount,
|
||||
message: '계정이 수정되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 수정 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await mailAccountFileService.deleteAccount(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '계정이 삭제되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 삭제 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const account = await mailAccountFileService.getAccountById(id);
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// mailSendSimpleService의 testConnection 사용
|
||||
const { mailSendSimpleService } = require('../services/mailSendSimpleService');
|
||||
const result = await mailSendSimpleService.testConnection(id);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '연결 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailAccountFileController = new MailAccountFileController();
|
||||
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
/**
|
||||
* 메일 수신 컨트롤러 (Step 2 - 기본 구현)
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { MailReceiveBasicService } from '../services/mailReceiveBasicService';
|
||||
|
||||
export class MailReceiveBasicController {
|
||||
private mailReceiveService: MailReceiveBasicService;
|
||||
|
||||
constructor() {
|
||||
this.mailReceiveService = new MailReceiveBasicService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/:accountId
|
||||
* 메일 목록 조회
|
||||
*/
|
||||
async getMailList(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('📬 메일 목록 조회 요청:', {
|
||||
// params: req.params,
|
||||
// path: req.path,
|
||||
// originalUrl: req.originalUrl
|
||||
// });
|
||||
|
||||
const { accountId } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
const mails = await this.mailReceiveService.fetchMailList(accountId, limit);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: mails,
|
||||
count: mails.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('메일 목록 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '메일 목록 조회 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/:accountId/:seqno
|
||||
* 메일 상세 조회
|
||||
*/
|
||||
async getMailDetail(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('🔍 메일 상세 조회 요청:', {
|
||||
// params: req.params,
|
||||
// path: req.path,
|
||||
// originalUrl: req.originalUrl
|
||||
// });
|
||||
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
if (isNaN(seqnoNumber)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 메일 번호입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const mailDetail = await this.mailReceiveService.getMailDetail(accountId, seqnoNumber);
|
||||
|
||||
if (!mailDetail) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '메일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: mailDetail,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('메일 상세 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '메일 상세 조회 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mail/receive/:accountId/:seqno/mark-read
|
||||
* 메일을 읽음으로 표시
|
||||
*/
|
||||
async markAsRead(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
if (isNaN(seqnoNumber)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 메일 번호입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.mailReceiveService.markAsRead(accountId, seqnoNumber);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error: unknown) {
|
||||
console.error('읽음 표시 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '읽음 표시 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/:accountId/:seqno/attachment/:index
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
async downloadAttachment(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
||||
const { accountId, seqno, index } = req.params;
|
||||
// console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
|
||||
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
const indexNumber = parseInt(index, 10);
|
||||
|
||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||
// console.log('❌ 유효하지 않은 파라미터');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 파라미터입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// console.log('📎 서비스 호출 시작...');
|
||||
const result = await this.mailReceiveService.downloadAttachment(
|
||||
accountId,
|
||||
seqnoNumber,
|
||||
indexNumber
|
||||
);
|
||||
// console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
||||
|
||||
if (!result) {
|
||||
// console.log('❌ 첨부파일을 찾을 수 없음');
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '첨부파일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
||||
// console.log(`📎 파일 경로: ${result.filePath}`);
|
||||
|
||||
// 파일 다운로드
|
||||
res.download(result.filePath, result.filename, (err) => {
|
||||
if (err) {
|
||||
console.error('파일 다운로드 오류:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '파일 다운로드 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return; // void 반환
|
||||
} catch (error: unknown) {
|
||||
console.error('첨부파일 다운로드 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '첨부파일 다운로드 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mail/receive/:accountId/test-imap
|
||||
* IMAP 연결 테스트
|
||||
*/
|
||||
async testImapConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const result = await this.mailReceiveService.testImapConnection(accountId);
|
||||
|
||||
return res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error: unknown) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'IMAP 연결 테스트 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/today-count
|
||||
* 오늘 수신 메일 수 조회
|
||||
*/
|
||||
async getTodayReceivedCount(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.query;
|
||||
const count = await this.mailReceiveService.getTodayReceivedCount(accountId as string);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { count }
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '오늘 수신 메일 수 조회에 실패했습니다.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/mail/receive/:accountId/:seqno
|
||||
* IMAP 서버에서 메일 삭제
|
||||
*/
|
||||
async deleteMail(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
if (isNaN(seqnoNumber)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 메일 번호입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.mailReceiveService.deleteMail(accountId, seqnoNumber);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error: unknown) {
|
||||
console.error('메일 삭제 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '메일 삭제 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const mailReceiveBasicController = new MailReceiveBasicController();
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
||||
|
||||
export class MailSendSimpleController {
|
||||
/**
|
||||
* 메일 발송 (단건 또는 소규모) - 첨부파일 지원
|
||||
*/
|
||||
async sendMail(req: Request, res: Response) {
|
||||
try {
|
||||
// console.log('📧 메일 발송 요청 수신:', {
|
||||
// accountId: req.body.accountId,
|
||||
// to: req.body.to,
|
||||
// cc: req.body.cc,
|
||||
// bcc: req.body.bcc,
|
||||
// subject: req.body.subject,
|
||||
// attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
||||
// });
|
||||
|
||||
// FormData에서 JSON 문자열 파싱
|
||||
const accountId = req.body.accountId;
|
||||
const templateId = req.body.templateId;
|
||||
const modifiedTemplateComponents = req.body.modifiedTemplateComponents
|
||||
? JSON.parse(req.body.modifiedTemplateComponents)
|
||||
: undefined; // 🎯 수정된 템플릿 컴포넌트
|
||||
const to = req.body.to ? JSON.parse(req.body.to) : [];
|
||||
const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined;
|
||||
const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined;
|
||||
const subject = req.body.subject;
|
||||
const variables = req.body.variables ? JSON.parse(req.body.variables) : undefined;
|
||||
const customHtml = req.body.customHtml;
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||
// console.log('❌ 필수 파라미터 누락');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '메일 제목이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 템플릿 또는 커스텀 HTML 중 하나는 있어야 함
|
||||
if (!templateId && !customHtml) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 또는 메일 내용이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 첨부파일 처리 (한글 파일명 지원)
|
||||
const attachments: Array<{ filename: string; path: string; contentType?: string }> = [];
|
||||
if (req.files && Array.isArray(req.files)) {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
// 프론트엔드에서 전송한 정규화된 파일명 사용 (한글-분석.txt 방식)
|
||||
let parsedFileNames: string[] = [];
|
||||
if (req.body.fileNames) {
|
||||
try {
|
||||
parsedFileNames = JSON.parse(req.body.fileNames);
|
||||
// console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
||||
} catch (e) {
|
||||
// console.warn('파일명 파싱 실패, multer originalname 사용');
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
// 클라이언트에서 전송한 파일명 우선 사용, 없으면 multer의 originalname 사용
|
||||
let originalName = parsedFileNames[index] || file.originalname;
|
||||
|
||||
// NFC 정규화 확실히 수행
|
||||
originalName = originalName.normalize('NFC');
|
||||
|
||||
attachments.push({
|
||||
filename: originalName,
|
||||
path: file.path,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
});
|
||||
|
||||
// console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
||||
// filename: a.filename,
|
||||
// path: a.path.split('/').pop()
|
||||
// })));
|
||||
}
|
||||
|
||||
// 메일 발송
|
||||
const result = await mailSendSimpleService.sendMail({
|
||||
accountId,
|
||||
templateId,
|
||||
modifiedTemplateComponents, // 🎯 수정된 템플릿 컴포넌트 전달
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
variables,
|
||||
customHtml,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '메일이 발송되었습니다.',
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.error || '메일 발송 실패',
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '메일 발송 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 메일 발송
|
||||
*/
|
||||
async sendBulkMail(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId, templateId, customHtml, subject, recipients } = req.body;
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!accountId || !subject || !recipients || !Array.isArray(recipients)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 파라미터가 누락되었습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 템플릿 또는 직접 작성 중 하나는 있어야 함
|
||||
if (!templateId && !customHtml) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 또는 메일 내용 중 하나는 필수입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '수신자가 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// console.log(`📧 대량 발송 요청: ${recipients.length}명`);
|
||||
|
||||
// 대량 발송 실행
|
||||
const result = await mailSendSimpleService.sendBulkMail({
|
||||
accountId,
|
||||
templateId, // 선택
|
||||
customHtml, // 선택
|
||||
subject,
|
||||
recipients,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `${result.success}/${result.total} 건 발송 완료`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('❌ 대량 발송 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '대량 발송 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
async testConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '계정 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await mailSendSimpleService.testConnection(accountId);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '연결 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleController = new MailSendSimpleController();
|
||||
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailSentHistoryService } from '../services/mailSentHistoryService';
|
||||
|
||||
export class MailSentHistoryController {
|
||||
/**
|
||||
* 발송 이력 목록 조회
|
||||
*/
|
||||
async getList(req: Request, res: Response) {
|
||||
try {
|
||||
const query = {
|
||||
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||
searchTerm: req.query.searchTerm as string | undefined,
|
||||
status: req.query.status as 'success' | 'failed' | 'draft' | 'all' | undefined,
|
||||
accountId: req.query.accountId as string | undefined,
|
||||
startDate: req.query.startDate as string | undefined,
|
||||
endDate: req.query.endDate as string | undefined,
|
||||
sortBy: req.query.sortBy as 'sentAt' | 'subject' | 'updatedAt' | undefined,
|
||||
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
|
||||
includeDeleted: req.query.includeDeleted === 'true',
|
||||
onlyDeleted: req.query.onlyDeleted === 'true',
|
||||
};
|
||||
|
||||
const result = await mailSentHistoryService.getSentMailList(query);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('발송 이력 목록 조회 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '발송 이력 조회 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 발송 이력 상세 조회
|
||||
*/
|
||||
async getById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '발송 이력 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const history = await mailSentHistoryService.getSentMailById(id);
|
||||
|
||||
if (!history) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '발송 이력을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: history,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('발송 이력 조회 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '발송 이력 조회 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 삭제
|
||||
*/
|
||||
async deleteById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '발송 이력 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const success = await mailSentHistoryService.deleteSentMail(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '발송 이력을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '발송 이력이 삭제되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('발송 이력 삭제 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '발송 이력 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 (Draft)
|
||||
*/
|
||||
async saveDraft(req: Request, res: Response) {
|
||||
try {
|
||||
const draft = await mailSentHistoryService.saveDraft(req.body);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: draft,
|
||||
message: '임시 저장되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('임시 저장 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '임시 저장 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 업데이트
|
||||
*/
|
||||
async updateDraft(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '임시 저장 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await mailSentHistoryService.updateDraft(id, req.body);
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '임시 저장을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: updated,
|
||||
message: '임시 저장이 업데이트되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('임시 저장 업데이트 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '임시 저장 업데이트 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 복구
|
||||
*/
|
||||
async restoreMail(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '메일 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const success = await mailSentHistoryService.restoreMail(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '복구할 메일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '메일이 복구되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('메일 복구 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '메일 복구 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 영구 삭제
|
||||
*/
|
||||
async permanentlyDelete(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '메일 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const success = await mailSentHistoryService.permanentlyDeleteMail(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '삭제할 메일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '메일이 영구 삭제되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('메일 영구 삭제 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '메일 영구 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
async getStatistics(req: Request, res: Response) {
|
||||
try {
|
||||
const accountId = req.query.accountId as string | undefined;
|
||||
const stats = await mailSentHistoryService.getStatistics(accountId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('통계 조회 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '통계 조회 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 삭제
|
||||
*/
|
||||
async bulkDelete(req: Request, res: Response) {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '삭제할 메일 ID 목록이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id: string) => mailSentHistoryService.deleteSentMail(id))
|
||||
);
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${successCount}개 메일 삭제 완료 (실패: ${failCount}개)`,
|
||||
data: { successCount, failCount },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('일괄 삭제 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '일괄 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 영구 삭제
|
||||
*/
|
||||
async bulkPermanentDelete(req: Request, res: Response) {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '영구 삭제할 메일 ID 목록이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id: string) => mailSentHistoryService.permanentlyDeleteMail(id))
|
||||
);
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${successCount}개 메일 영구 삭제 완료 (실패: ${failCount}개)`,
|
||||
data: { successCount, failCount },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('일괄 영구 삭제 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '일괄 영구 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 복구
|
||||
*/
|
||||
async bulkRestore(req: Request, res: Response) {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '복구할 메일 ID 목록이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id: string) => mailSentHistoryService.restoreMail(id))
|
||||
);
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${successCount}개 메일 복구 완료 (실패: ${failCount}개)`,
|
||||
data: { successCount, failCount },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('일괄 복구 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '일괄 복구 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSentHistoryController = new MailSentHistoryController();
|
||||
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailTemplateFileService } from '../services/mailTemplateFileService';
|
||||
|
||||
// 간단한 변수 치환 함수
|
||||
function replaceVariables(text: string, data: Record<string, any>): string {
|
||||
let result = text;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, String(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class MailTemplateFileController {
|
||||
// 모든 템플릿 조회
|
||||
async getAllTemplates(req: Request, res: Response) {
|
||||
try {
|
||||
const { category, search } = req.query;
|
||||
|
||||
let templates;
|
||||
if (search) {
|
||||
templates = await mailTemplateFileService.searchTemplates(search as string);
|
||||
} else if (category) {
|
||||
templates = await mailTemplateFileService.getTemplatesByCategory(category as string);
|
||||
} else {
|
||||
templates = await mailTemplateFileService.getAllTemplates();
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
total: templates.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 템플릿 조회
|
||||
async getTemplateById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 생성
|
||||
async createTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, subject, components, queryConfig, recipientConfig, category } = req.body;
|
||||
|
||||
if (!name || !subject || !Array.isArray(components)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 이름, 제목, 컴포넌트가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const template = await mailTemplateFileService.createTemplate({
|
||||
name,
|
||||
subject,
|
||||
components,
|
||||
queryConfig,
|
||||
recipientConfig,
|
||||
category,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: '템플릿이 생성되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 수정
|
||||
async updateTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.updateTemplate(id, updates);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: '템플릿이 수정되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 수정 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 삭제
|
||||
async deleteTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await mailTemplateFileService.deleteTemplate(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '템플릿이 삭제되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 삭제 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 미리보기 (HTML 렌더링)
|
||||
async previewTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sampleData } = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// HTML 렌더링
|
||||
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
|
||||
let subject = template.subject;
|
||||
|
||||
// 샘플 데이터가 있으면 변수 치환
|
||||
if (sampleData) {
|
||||
html = replaceVariables(html, sampleData);
|
||||
subject = replaceVariables(subject, sampleData);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
subject,
|
||||
html,
|
||||
sampleData,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '미리보기 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 + 쿼리 통합 미리보기
|
||||
async previewWithQuery(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { queryId, parameters } = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const query = template.queryConfig?.queries.find(q => q.id === queryId);
|
||||
if (!query) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '쿼리를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 쿼리 기능은 구현되지 않음
|
||||
return res.status(501).json({
|
||||
success: false,
|
||||
message: 'SQL 쿼리 연동 기능은 현재 지원하지 않습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '쿼리 미리보기 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailTemplateFileController = new MailTemplateFileController();
|
||||
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { MapDataService } from "../services/mapDataService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 지도 데이터 조회 컨트롤러
|
||||
* 외부 DB 연결에서 위도/경도 데이터를 가져와 지도에 표시할 수 있도록 변환
|
||||
*/
|
||||
export class MapDataController {
|
||||
private mapDataService: MapDataService;
|
||||
|
||||
constructor() {
|
||||
this.mapDataService = new MapDataService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
getMapData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { connectionId } = req.params;
|
||||
const {
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
labelColumn,
|
||||
statusColumn,
|
||||
additionalColumns,
|
||||
whereClause,
|
||||
} = req.query;
|
||||
|
||||
logger.info("🗺️ 지도 데이터 조회 요청:", {
|
||||
connectionId,
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!tableName || !latColumn || !lngColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, latColumn, lngColumn은 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = await this.mapDataService.getMapData({
|
||||
connectionId: parseInt(connectionId as string),
|
||||
tableName: tableName as string,
|
||||
latColumn: latColumn as string,
|
||||
lngColumn: lngColumn as string,
|
||||
labelColumn: labelColumn as string,
|
||||
statusColumn: statusColumn as string,
|
||||
additionalColumns: additionalColumns
|
||||
? (additionalColumns as string).split(",")
|
||||
: [],
|
||||
whereClause: whereClause as string,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
markers,
|
||||
count: markers.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 지도 데이터 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "지도 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 내부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
getInternalMapData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
labelColumn,
|
||||
statusColumn,
|
||||
additionalColumns,
|
||||
whereClause,
|
||||
} = req.query;
|
||||
|
||||
logger.info("🗺️ 내부 DB 지도 데이터 조회 요청:", {
|
||||
tableName,
|
||||
latColumn,
|
||||
lngColumn,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!tableName || !latColumn || !lngColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, latColumn, lngColumn은 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = await this.mapDataService.getInternalMapData({
|
||||
tableName: tableName as string,
|
||||
latColumn: latColumn as string,
|
||||
lngColumn: lngColumn as string,
|
||||
labelColumn: labelColumn as string,
|
||||
statusColumn: statusColumn as string,
|
||||
additionalColumns: additionalColumns
|
||||
? (additionalColumns as string).split(",")
|
||||
: [],
|
||||
whereClause: whereClause as string,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
markers,
|
||||
count: markers.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "지도 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -10,10 +10,7 @@ import {
|
|||
SaveLangTextsRequest,
|
||||
GetUserTextParams,
|
||||
BatchTranslationRequest,
|
||||
GenerateKeyRequest,
|
||||
CreateOverrideKeyRequest,
|
||||
ApiResponse,
|
||||
LangCategory,
|
||||
} from "../types/multilang";
|
||||
|
||||
/**
|
||||
|
|
@ -190,7 +187,7 @@ export const getLangKeys = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||
const { companyCode, menuCode, keyType, searchText } = req.query;
|
||||
logger.info("다국어 키 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
|
|
@ -202,7 +199,6 @@ export const getLangKeys = async (
|
|||
menuCode: menuCode as string,
|
||||
keyType: keyType as string,
|
||||
searchText: searchText as string,
|
||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -634,391 +630,6 @@ export const deleteLanguage = async (
|
|||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 카테고리 관련 API
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories
|
||||
* 카테고리 목록 조회 API (트리 구조)
|
||||
*/
|
||||
export const getCategories = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info("카테고리 목록 조회 요청", { user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const categories = await multiLangService.getCategories();
|
||||
|
||||
const response: ApiResponse<LangCategory[]> = {
|
||||
success: true,
|
||||
message: "카테고리 목록 조회 성공",
|
||||
data: categories,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories/:categoryId
|
||||
* 카테고리 상세 조회 API
|
||||
*/
|
||||
export const getCategoryById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const category = await multiLangService.getCategoryById(parseInt(categoryId));
|
||||
|
||||
if (!category) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "카테고리를 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_NOT_FOUND",
|
||||
details: `Category ID ${categoryId} not found`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse<LangCategory> = {
|
||||
success: true,
|
||||
message: "카테고리 상세 조회 성공",
|
||||
data: category,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 상세 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_DETAIL_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories/:categoryId/path
|
||||
* 카테고리 경로 조회 API (부모 포함)
|
||||
*/
|
||||
export const getCategoryPath = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
|
||||
|
||||
const response: ApiResponse<LangCategory[]> = {
|
||||
success: true,
|
||||
message: "카테고리 경로 조회 성공",
|
||||
data: path,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 경로 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_PATH_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 자동 생성 및 오버라이드 관련 API
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/generate
|
||||
* 키 자동 생성 API
|
||||
*/
|
||||
export const generateKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const generateData: GenerateKeyRequest = req.body;
|
||||
logger.info("키 자동 생성 요청", { generateData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "companyCode, categoryId, and keyMeaning are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Only super admin can create common keys",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 관리자는 자기 회사 키만 생성 가능
|
||||
if (generateData.companyCode !== "*" &&
|
||||
req.user?.companyCode !== "*" &&
|
||||
generateData.companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot create keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.generateKey({
|
||||
...generateData,
|
||||
createdBy: req.user?.userId || "system",
|
||||
});
|
||||
|
||||
const response: ApiResponse<number> = {
|
||||
success: true,
|
||||
message: "키가 성공적으로 생성되었습니다.",
|
||||
data: keyId,
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("키 자동 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "키 자동 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "KEY_GENERATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/preview
|
||||
* 키 미리보기 API
|
||||
*/
|
||||
export const previewKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId, keyMeaning, companyCode } = req.body;
|
||||
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
|
||||
|
||||
if (!categoryId || !keyMeaning || !companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "categoryId, keyMeaning, and companyCode are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const preview = await multiLangService.previewGeneratedKey(
|
||||
parseInt(categoryId),
|
||||
keyMeaning,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<{
|
||||
langKey: string;
|
||||
exists: boolean;
|
||||
isOverride: boolean;
|
||||
baseKeyId?: number;
|
||||
}> = {
|
||||
success: true,
|
||||
message: "키 미리보기 성공",
|
||||
data: preview,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("키 미리보기 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "키 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "KEY_PREVIEW_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/override
|
||||
* 오버라이드 키 생성 API
|
||||
*/
|
||||
export const createOverrideKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const overrideData: CreateOverrideKeyRequest = req.body;
|
||||
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
if (!overrideData.companyCode || !overrideData.baseKeyId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드와 원본 키 ID는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "companyCode and baseKeyId are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
|
||||
if (overrideData.companyCode === "*") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
|
||||
error: {
|
||||
code: "INVALID_OVERRIDE",
|
||||
details: "Cannot create override for common keys",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
|
||||
if (req.user?.companyCode !== "*" &&
|
||||
overrideData.companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot create override keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.createOverrideKey({
|
||||
...overrideData,
|
||||
createdBy: req.user?.userId || "system",
|
||||
});
|
||||
|
||||
const response: ApiResponse<number> = {
|
||||
success: true,
|
||||
message: "오버라이드 키가 성공적으로 생성되었습니다.",
|
||||
data: keyId,
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("오버라이드 키 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "OVERRIDE_KEY_CREATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/keys/overrides/:companyCode
|
||||
* 회사별 오버라이드 키 목록 조회 API
|
||||
*/
|
||||
export const getOverrideKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
|
||||
|
||||
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
|
||||
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot view override keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keys = await multiLangService.getOverrideKeys(companyCode);
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "오버라이드 키 목록 조회 성공",
|
||||
data: keys,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("오버라이드 키 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "OVERRIDE_KEYS_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/batch
|
||||
* 다국어 텍스트 배치 조회 API
|
||||
|
|
@ -1099,86 +710,3 @@ export const getBatchTranslations = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/screen-labels
|
||||
* 화면 라벨 다국어 키 자동 생성 API
|
||||
*/
|
||||
export const generateScreenLabelKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, menuObjId, labels } = req.body;
|
||||
|
||||
logger.info("화면 라벨 다국어 키 생성 요청", {
|
||||
screenId,
|
||||
menuObjId,
|
||||
labelCount: labels?.length,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!screenId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId는 필수입니다.",
|
||||
error: { code: "MISSING_SCREEN_ID" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!labels || !Array.isArray(labels) || labels.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "labels 배열이 필요합니다.",
|
||||
error: { code: "MISSING_LABELS" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
|
||||
const { queryOne } = await import("../database/db");
|
||||
const screenInfo = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
|
||||
|
||||
// 회사명 조회
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||
|
||||
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const results = await multiLangService.generateScreenLabelKeys({
|
||||
screenId: Number(screenId),
|
||||
companyCode,
|
||||
companyName,
|
||||
menuObjId,
|
||||
labels,
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof results> = {
|
||||
success: true,
|
||||
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
|
||||
data: results,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue