# Cursor Rules for ERP-node Project

## 🚨 비즈니스 로직 요청 양식 검증 (필수)

**사용자가 화면 개발 또는 비즈니스 로직 구현을 요청할 때, 아래 양식을 따르지 않으면 반드시 다음과 같이 응답하세요:**

```
안녕하세요. Oh My Master! 양식을 못 알아 듣겠습니다. 
다시 한번 작성해주십쇼.
=== 비즈니스 로직 요청서 ===

【화면 정보】
- 화면명: 
- 회사코드: 
- 메뉴ID (있으면): 

【테이블 정보】
- 메인 테이블: 
- 디테일 테이블 (있으면): 
- 관계 FK (있으면): 

【버튼 목록】
버튼1:
  - 버튼명: 
  - 동작 유형: (저장/삭제/수정/조회/기타)
  - 조건 (있으면): 
  - 대상 테이블: 
  - 추가 동작 (있으면): 

【추가 요구사항】
- 
```

**양식 미준수 판단 기준:**
1. "화면 만들어줘" 같이 테이블명/버튼 정보 없이 요청
2. "저장하면 저장해줘" 같이 구체적인 테이블/로직 설명 없음
3. "이전이랑 비슷하게" 같이 모호한 참조
4. 버튼별 조건/동작이 명시되지 않음

**양식 미준수 시 절대 작업 진행하지 말고, 위 양식을 보여주며 다시 작성하라고 요청하세요.**

**상세 가이드**: [화면개발_표준_가이드.md](docs/screen-implementation-guide/화면개발_표준_가이드.md)

---

## 🚨 최우선 보안 규칙: 멀티테넌시

**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**
- [멀티테넌시 필수 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc)

**AI 에이전트는 다음 상황에서 반드시 멀티테넌시 체크리스트를 확인해야 합니다:**
1. 데이터베이스 마이그레이션 작성 시
2. 백엔드 API (SELECT/INSERT/UPDATE/DELETE) 작성/수정 시
3. 프론트엔드 데이터 API 호출 작성/수정 시
4. 테스트 완료 시

**핵심 원칙:**
- ✅ 모든 테이블에 `company_code` 필수 (company_mng 제외)
- ✅ 모든 쿼리에 `company_code` 필터링 필수
- ✅ 프론트엔드 API 호출 시 `autoFilter` 전달 필수

---

## shadcn/ui 웹 스타일 가이드라인

모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다.

### 1. Color System (색상 시스템)

#### CSS Variables 사용
shadcn은 CSS Variables를 사용하여 테마를 관리하며, 모든 색상은 HSL 형식으로 정의됩니다.

**기본 색상 토큰 (항상 사용):**
- `--background`: 페이지 배경
- `--foreground`: 기본 텍스트
- `--primary`: 메인 액션 (Indigo 계열)
- `--primary-foreground`: Primary 위 텍스트
- `--secondary`: 보조 액션
- `--muted`: 약한 배경
- `--muted-foreground`: 보조 텍스트
- `--destructive`: 삭제/에러 (Rose 계열)
- `--border`: 테두리
- `--ring`: 포커스 링

**Tailwind 클래스로 사용:**
```tsx
bg-primary text-primary-foreground
bg-secondary text-secondary-foreground
bg-muted text-muted-foreground
bg-destructive text-destructive-foreground
border-border
```

**추가 시맨틱 컬러:**
- Success: `--success` (Emerald-600 계열)
- Warning: `--warning` (Amber-500 계열)
- Info: `--info` (Cyan-500 계열)

### 2. Spacing System (간격)

**Tailwind Scale (4px 기준):**
- 0.5 = 2px, 1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 6 = 24px, 8 = 32px

**컴포넌트별 권장 간격:**
- 카드 패딩: `p-6` (24px)
- 카드 간 마진: `gap-6` (24px)
- 폼 필드 간격: `space-y-4` (16px)
- 버튼 내부 패딩: `px-4 py-2`
- 섹션 간격: `space-y-8` 또는 `space-y-12`

### 3. Typography (타이포그래피)

**용도별 타이포그래피 (필수):**
- 페이지 제목: `text-3xl font-bold`
- 섹션 제목: `text-2xl font-semibold`
- 카드 제목: `text-xl font-semibold`
- 서브 제목: `text-lg font-medium`
- 본문 텍스트: `text-sm text-muted-foreground`
- 작은 텍스트: `text-xs text-muted-foreground`
- 버튼 텍스트: `text-sm font-medium`
- 폼 라벨: `text-sm font-medium`

### 4. Button Variants (버튼 스타일)

**필수 사용 패턴:**
```tsx
// Primary (기본)
<Button variant="default">저장</Button>

// Secondary
<Button variant="secondary">취소</Button>

// Outline
<Button variant="outline">편집</Button>

// Ghost
<Button variant="ghost">닫기</Button>

// Destructive
<Button variant="destructive">삭제</Button>
```

**버튼 크기:**
- `size="sm"`: 작은 버튼 (h-9 px-3)
- `size="default"`: 기본 버튼 (h-10 px-4 py-2)
- `size="lg"`: 큰 버튼 (h-11 px-8)
- `size="icon"`: 아이콘 전용 (h-10 w-10)

### 5. Input States (입력 필드 상태)

**필수 적용 상태:**
```tsx
// Default
className="border-input"

// Focus (모든 입력 필드 필수)
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"

// Error
className="border-destructive focus-visible:ring-destructive"

// Disabled
className="disabled:opacity-50 disabled:cursor-not-allowed"
```

### 6. Card Structure (카드 구조)

**표준 카드 구조 (필수):**
```tsx
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
  <CardHeader className="flex flex-col space-y-1.5 p-6">
    <CardTitle className="text-2xl font-semibold leading-none tracking-tight">제목</CardTitle>
    <CardDescription className="text-sm text-muted-foreground">설명</CardDescription>
  </CardHeader>
  <CardContent className="p-6 pt-0">
    {/* 내용 */}
  </CardContent>
  <CardFooter className="flex items-center p-6 pt-0">
    {/* 액션 버튼들 */}
  </CardFooter>
</Card>
```

### 7. Border & Radius (테두리 및 둥근 모서리)

**컴포넌트별 권장:**
- 버튼: `rounded-md` (6px)
- 입력 필드: `rounded-md` (6px)
- 카드: `rounded-lg` (8px)
- 배지: `rounded-full`
- 모달/대화상자: `rounded-lg` (8px)
- 드롭다운: `rounded-md` (6px)

### 8. Shadow (그림자)

**용도별 권장:**
- 카드: `shadow-sm`
- 드롭다운: `shadow-md`
- 모달: `shadow-lg`
- 팝오버: `shadow-md`
- 버튼 호버: `shadow-sm`

### 9. Interactive States (상호작용 상태)

**필수 적용 패턴:**
```tsx
// Hover
hover:bg-primary/90          // 버튼
hover:bg-accent              // Ghost 버튼
hover:underline              // 링크
hover:shadow-md transition-shadow  // 카드

// Focus (모든 인터랙티브 요소 필수)
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2

// Active
active:scale-95 transition-transform  // 버튼

// Disabled
disabled:opacity-50 disabled:cursor-not-allowed
```

### 10. Animation (애니메이션)

**권장 Duration:**
- 빠른 피드백: `duration-75`
- 기본: `duration-150`
- 부드러운 전환: `duration-300`

**권장 패턴:**
- 버튼 클릭: `transition-transform duration-150 active:scale-95`
- 색상 전환: `transition-colors duration-150`
- 드롭다운 열기: `transition-all duration-200`

### 11. Responsive (반응형)

**Breakpoints:**
- `sm`: 640px (모바일 가로)
- `md`: 768px (태블릿)
- `lg`: 1024px (노트북)
- `xl`: 1280px (데스크톱)

**반응형 패턴:**
```tsx
// 모바일 우선 접근
className="flex-col md:flex-row"
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
className="text-2xl md:text-3xl lg:text-4xl"
className="p-4 md:p-6 lg:p-8"
```

### 12. Accessibility (접근성)

**필수 적용 사항:**
1. 포커스 표시: 모든 인터랙티브 요소에 `focus-visible:ring-2` 적용
2. ARIA 레이블: 적절한 `aria-label`, `aria-describedby` 사용
3. 키보드 네비게이션: Tab, Enter, Space, Esc 지원
4. 색상 대비: 최소 4.5:1 (일반 텍스트), 3:1 (큰 텍스트)

### 13. Class 순서 (일관성 유지)

**항상 이 순서로 작성:**
1. Layout: `flex`, `grid`, `block`
2. Sizing: `w-full`, `h-10`
3. Spacing: `p-4`, `m-2`, `gap-4`
4. Typography: `text-sm`, `font-medium`
5. Colors: `bg-primary`, `text-white`
6. Border: `border`, `rounded-md`
7. Effects: `shadow-sm`, `opacity-50`
8. States: `hover:`, `focus:`, `disabled:`
9. Responsive: `md:`, `lg:`

### 14. 실무 적용 규칙

1. **shadcn 컴포넌트 우선 사용**: 커스텀 스타일보다 shadcn 기본 컴포넌트 활용
2. **cn 유틸리티 사용**: 조건부 클래스는 `cn()` 함수로 결합
3. **테마 변수 사용**: 하드코딩된 색상 대신 CSS 변수 사용
4. **다크모드 고려**: 모든 컴포넌트는 다크모드 호환 필수
5. **일관성 유지**: 같은 용도의 컴포넌트는 같은 스타일 사용

### 15. 금지 사항

1. ❌ 하드코딩된 색상 값 사용 (예: `bg-blue-500` 대신 `bg-primary`)
2. ❌ 인라인 스타일로 색상 지정 (예: `style={{ color: '#3b82f6' }}`)
3. ❌ 포커스 스타일 제거 (`outline-none`만 단독 사용)
4. ❌ 접근성 무시 (ARIA 레이블 누락)
5. ❌ 반응형 무시 (데스크톱 전용 스타일)
6. ❌ **중첩 박스 금지**: 사용자가 명시적으로 요청하지 않는 한 Card 안에 Card, Border 안에 Border 같은 중첩된 컨테이너 구조를 만들지 않음

### 16. 중첩 박스 금지 상세 규칙

**금지되는 패턴 (사용자 요청 없이):**
```tsx
// ❌ Card 안에 Card
<Card>
  <CardContent>
    <Card>  // 중첩 금지!
      <CardContent>내용</CardContent>
    </Card>
  </CardContent>
</Card>

// ❌ Border 안에 Border
<div className="border rounded-lg p-4">
  <div className="border rounded-lg p-4">  // 중첩 금지!
    내용
  </div>
</div>

// ❌ 불필요한 래퍼
<div className="border p-4">
  <div className="bg-card border rounded-lg">  // 중첩 금지!
    내용
  </div>
</div>
```

**허용되는 패턴:**
```tsx
// ✅ 단일 Card
<Card>
  <CardHeader>
    <CardTitle>제목</CardTitle>
  </CardHeader>
  <CardContent>
    내용
  </CardContent>
</Card>

// ✅ 의미적으로 다른 컴포넌트 조합
<Card>
  <CardContent>
    <Dialog>  // Dialog는 별도 UI 레이어
      <DialogContent>...</DialogContent>
    </Dialog>
  </CardContent>
</Card>

// ✅ 그리드/리스트 내부의 Card들
<div className="grid grid-cols-3 gap-4">
  <Card>항목 1</Card>
  <Card>항목 2</Card>
  <Card>항목 3</Card>
</div>
```

**예외 상황 (사용자가 명시적으로 요청한 경우만):**
- 대시보드에서 섹션별 그룹핑이 필요한 경우
- 복잡한 데이터 구조를 시각적으로 구분해야 하는 경우
- 드래그앤드롭 등 특수 기능을 위한 경우

**원칙:**
- 심플하고 깔끔한 디자인 유지
- 불필요한 시각적 레이어 제거
- 사용자가 명시적으로 "박스 안에 박스", "중첩된 카드" 등을 요청하지 않으면 단일 레벨 유지

### 17. 표준 모달(Dialog) 디자인 패턴

**프로젝트 표준 모달 구조 (플로우 관리 기준):**

```tsx
<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
    {/* 헤더: 제목 + 설명 */}
    <DialogHeader>
      <DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
      <DialogDescription className="text-xs sm:text-sm">
        모달에 대한 간단한 설명
      </DialogDescription>
    </DialogHeader>

    {/* 컨텐츠: 폼 필드들 */}
    <div className="space-y-3 sm:space-y-4">
      {/* 각 입력 필드 */}
      <div>
        <Label htmlFor="fieldName" className="text-xs sm:text-sm">
          필드 라벨 *
        </Label>
        <Input
          id="fieldName"
          value={value}
          onChange={handleChange}
          placeholder="힌트 텍스트"
          className="h-8 text-xs sm:h-10 sm:text-sm"
        />
        <p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
          도움말 텍스트 (선택사항)
        </p>
      </div>
    </div>

    {/* 푸터: 액션 버튼들 */}
    <DialogFooter className="gap-2 sm:gap-0">
      <Button
        variant="outline"
        onClick={handleCancel}
        className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
      >
        취소
      </Button>
      <Button
        onClick={handleSubmit}
        className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
      >
        확인
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

**필수 적용 사항:**

1. **반응형 크기**
   - 모바일: `max-w-[95vw]` (화면 너비의 95%)
   - 데스크톱: `sm:max-w-[500px]` (고정 500px)

2. **헤더 구조**
   - DialogTitle: `text-base sm:text-lg` (16px → 18px)
   - DialogDescription: `text-xs sm:text-sm` (12px → 14px)
   - 항상 제목과 설명 모두 포함

3. **컨텐츠 간격**
   - 필드 간 간격: `space-y-3 sm:space-y-4` (12px → 16px)
   - 각 필드는 `<div>` 로 감싸기

4. **입력 필드 패턴**
   - Label: `text-xs sm:text-sm` + 필수 필드는 `*` 표시
   - Input/Select: `h-8 text-xs sm:h-10 sm:text-sm` (32px → 40px)
   - 도움말: `text-muted-foreground mt-1 text-[10px] sm:text-xs`

5. **푸터 버튼**
   - 컨테이너: `gap-2 sm:gap-0` (모바일에서 간격, 데스크톱에서 자동)
   - 버튼: `h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm`
   - 모바일: 같은 크기 (`flex-1`)
   - 데스크톱: 자동 크기 (`flex-none`)
   - 순서: 취소(outline) → 확인(default)

6. **접근성**
   - Label의 `htmlFor`와 Input의 `id` 매칭
   - Button에 적절한 `onClick` 핸들러
   - Dialog의 `open`과 `onOpenChange` 필수

**확인 모달 (간단한 경고/확인):**

```tsx
<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
    <DialogHeader>
      <DialogTitle className="text-base sm:text-lg">작업 확인</DialogTitle>
      <DialogDescription className="text-xs sm:text-sm">
        정말로 이 작업을 수행하시겠습니까?
        <br />이 작업은 되돌릴 수 없습니다.
      </DialogDescription>
    </DialogHeader>

    <DialogFooter className="gap-2 sm:gap-0">
      <Button
        variant="outline"
        onClick={handleCancel}
        className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
      >
        취소
      </Button>
      <Button
        variant="destructive"
        onClick={handleConfirm}
        className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
      >
        삭제
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

**원칙:**
- 모든 모달은 모바일 우선 반응형 디자인
- 일관된 크기, 간격, 폰트 크기 사용
- 사용자가 다른 크기를 명시하지 않으면 `sm:max-w-[500px]` 사용
- 삭제/위험한 작업은 `variant="destructive"` 사용

### 18. 검색 가능한 Select 박스 (Combobox 패턴)

**적용 조건**: 사용자가 "검색 기능이 있는 Select 박스" 또는 "Combobox"를 명시적으로 요청한 경우만 사용

**표준 Combobox 구조 (플로우 관리 기준):**

```tsx
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";

// 상태 관리
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");

// 렌더링
<Popover open={open} onOpenChange={setOpen}>
  <PopoverTrigger asChild>
    <Button
      variant="outline"
      role="combobox"
      aria-expanded={open}
      className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
    >
      {value
        ? items.find((item) => item.value === value)?.label
        : "항목 선택"}
      <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
    </Button>
  </PopoverTrigger>
  <PopoverContent 
    className="p-0" 
    style={{ width: "var(--radix-popover-trigger-width)" }} 
    align="start"
  >
    <Command>
      <CommandInput 
        placeholder="검색..." 
        className="text-xs sm:text-sm" 
      />
      <CommandList>
        <CommandEmpty className="text-xs sm:text-sm">
          항목을 찾을 수 없습니다.
        </CommandEmpty>
        <CommandGroup>
          {items.map((item) => (
            <CommandItem
              key={item.value}
              value={item.value}
              onSelect={(currentValue) => {
                setValue(currentValue === value ? "" : currentValue);
                setOpen(false);
              }}
              className="text-xs sm:text-sm"
            >
              <Check
                className={cn(
                  "mr-2 h-4 w-4",
                  value === item.value ? "opacity-100" : "opacity-0"
                )}
              />
              {item.label}
            </CommandItem>
          ))}
        </CommandGroup>
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>
```

**복잡한 데이터 표시 (라벨 + 설명):**

```tsx
<CommandItem
  key={item.value}
  value={item.value}
  onSelect={(currentValue) => {
    setValue(currentValue);
    setOpen(false);
  }}
  className="text-xs sm:text-sm"
>
  <Check
    className={cn(
      "mr-2 h-4 w-4",
      value === item.value ? "opacity-100" : "opacity-0"
    )}
  />
  <div className="flex flex-col">
    <span className="font-medium">{item.label}</span>
    {item.description && (
      <span className="text-[10px] text-gray-500">{item.description}</span>
    )}
  </div>
</CommandItem>
```

**필수 적용 사항:**

1. **반응형 크기**
   - 버튼 높이: `h-8 sm:h-10` (32px → 40px)
   - 텍스트 크기: `text-xs sm:text-sm` (12px → 14px)
   - PopoverContent 너비: `width: "var(--radix-popover-trigger-width)"` (트리거와 동일)

2. **필수 컴포넌트**
   - Popover: 드롭다운 컨테이너
   - Command: 검색 및 필터링 기능
   - CommandInput: 검색 입력 필드
   - CommandList: 항목 목록 컨테이너
   - CommandEmpty: 검색 결과 없음 메시지
   - CommandGroup: 항목 그룹
   - CommandItem: 개별 항목

3. **아이콘 사용**
   - ChevronsUpDown: 드롭다운 표시 아이콘 (오른쪽)
   - Check: 선택된 항목 표시 (왼쪽)

4. **접근성**
   - `role="combobox"`: ARIA 역할 명시
   - `aria-expanded={open}`: 열림/닫힘 상태
   - PopoverTrigger에 `asChild` 사용

5. **로딩 상태**
   ```tsx
   <Button
     variant="outline"
     role="combobox"
     disabled={loading}
     className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
   >
     {loading ? "로딩 중..." : value ? "선택됨" : "항목 선택"}
     <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
   </Button>
   ```

**일반 Select vs Combobox 선택 기준:**

| 상황 | 컴포넌트 | 이유 |
|------|----------|------|
| 항목 5개 이하 | `<Select>` | 검색 불필요 |
| 항목 5개 초과 | 사용자 요청 시 `<Combobox>` | 검색 필요 시 |
| 테이블/데이터베이스 선택 | `<Combobox>` | 많은 항목 + 검색 필수 |
| 간단한 상태 선택 | `<Select>` | 빠른 선택 |

**원칙:**
- 사용자가 명시적으로 요청하지 않으면 일반 Select 사용
- 많은 항목(10개 이상)을 다룰 때는 Combobox 권장
- 일관된 반응형 크기 유지
- 검색 플레이스홀더는 구체적으로 작성

### 19. Form Validation (폼 검증)

**입력 필드 상태별 스타일:**

```tsx
// Default (기본)
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm 
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

// Error (에러)
className="flex h-10 w-full rounded-md border border-destructive bg-background px-3 py-2 text-sm 
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"

// Success (성공)
className="flex h-10 w-full rounded-md border border-success bg-background px-3 py-2 text-sm 
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-success"

// Disabled (비활성)
className="flex h-10 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm 
opacity-50 cursor-not-allowed"
```

**Helper Text (도움말 텍스트):**

```tsx
// 기본 Helper Text
<p className="text-xs text-muted-foreground mt-1.5">
  8자 이상 입력해주세요
</p>

// Error Message
<p className="text-xs text-destructive mt-1.5 flex items-center gap-1">
  <AlertCircle className="h-3 w-3" />
  이메일 형식이 올바르지 않습니다
</p>

// Success Message
<p className="text-xs text-success mt-1.5 flex items-center gap-1">
  <CheckCircle className="h-3 w-3" />
  사용 가능한 이메일입니다
</p>
```

**Form Label (폼 라벨):**

```tsx
// 기본 라벨
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
  이메일
</label>

// 필수 항목 표시
<label className="text-sm font-medium leading-none">
  이메일 <span className="text-destructive">*</span>
</label>
```

**전체 폼 필드 구조:**

```tsx
<div className="space-y-2">
  <label className="text-sm font-medium">
    이메일 <span className="text-destructive">*</span>
  </label>
  <input
    type="email"
    className={cn(
      "flex h-10 w-full rounded-md border px-3 py-2 text-sm",
      error ? "border-destructive" : "border-input",
      "focus-visible:outline-none focus-visible:ring-2",
      error ? "focus-visible:ring-destructive" : "focus-visible:ring-ring"
    )}
  />
  {error && (
    <p className="text-xs text-destructive flex items-center gap-1">
      <AlertCircle className="h-3 w-3" />
      {errorMessage}
    </p>
  )}
  {!error && helperText && (
    <p className="text-xs text-muted-foreground">{helperText}</p>
  )}
</div>
```

**실시간 검증 피드백:**

```tsx
// 로딩 중 (검증 진행)
<div className="flex items-center gap-2">
  <input className="..." />
  <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>

// 성공
<div className="flex items-center gap-2">
  <input className="border-success ..." />
  <CheckCircle className="h-4 w-4 text-success" />
</div>

// 실패
<div className="flex items-center gap-2">
  <input className="border-destructive ..." />
  <XCircle className="h-4 w-4 text-destructive" />
</div>
```

### 20. Loading States (로딩 상태)

**Spinner (스피너) 크기별:**

```tsx
// Small
<Loader2 className="h-4 w-4 animate-spin" />

// Default
<Loader2 className="h-6 w-6 animate-spin" />

// Large
<Loader2 className="h-8 w-8 animate-spin" />
```

**Spinner 색상별:**

```tsx
// Primary
<Loader2 className="h-6 w-6 animate-spin text-primary" />

// Muted
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />

// White (다크 배경용)
<Loader2 className="h-6 w-6 animate-spin text-primary-foreground" />
```

**Button Loading:**

```tsx
<button 
  disabled 
  className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4"
>
  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
  처리 중...
</button>
```

**Skeleton UI:**

```tsx
// 텍스트 스켈레톤
<div className="space-y-2">
  <div className="h-4 w-full bg-muted rounded animate-pulse" />
  <div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
  <div className="h-4 w-1/2 bg-muted rounded animate-pulse" />
</div>

// 카드 스켈레톤
<div className="rounded-lg border bg-card p-6 space-y-4">
  <div className="flex items-center gap-4">
    <div className="h-12 w-12 bg-muted rounded-full animate-pulse" />
    <div className="space-y-2 flex-1">
      <div className="h-4 w-1/3 bg-muted rounded animate-pulse" />
      <div className="h-3 w-1/2 bg-muted rounded animate-pulse" />
    </div>
  </div>
  <div className="space-y-2">
    <div className="h-3 w-full bg-muted rounded animate-pulse" />
    <div className="h-3 w-full bg-muted rounded animate-pulse" />
    <div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
  </div>
</div>
```

**Progress Bar (진행률):**

```tsx
// 기본 Progress Bar
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
  <div 
    className="h-full bg-primary transition-all duration-300"
    style={{ width: `${progress}%` }}
  />
</div>

// 라벨 포함
<div className="space-y-2">
  <div className="flex justify-between text-sm">
    <span className="text-muted-foreground">업로드 중...</span>
    <span className="font-medium">{progress}%</span>
  </div>
  <div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
    <div 
      className="h-full bg-primary transition-all duration-300"
      style={{ width: `${progress}%` }}
    />
  </div>
</div>
```

**Full Page Loading:**

```tsx
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50">
  <div className="flex flex-col items-center gap-4">
    <Loader2 className="h-8 w-8 animate-spin text-primary" />
    <p className="text-sm text-muted-foreground">로딩 중...</p>
  </div>
</div>
```

### 21. Empty States (빈 상태)

**기본 Empty State:**

```tsx
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
  <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
    <Inbox className="h-8 w-8 text-muted-foreground" />
  </div>
  <h3 className="text-lg font-semibold mb-2">데이터가 없습니다</h3>
  <p className="text-sm text-muted-foreground mb-4 max-w-sm">
    아직 생성된 항목이 없습니다. 새로운 항목을 추가해보세요.
  </p>
  <button className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4">
    <Plus className="mr-2 h-4 w-4" />
    새 항목 추가
  </button>
</div>
```

**검색 결과 없음:**

```tsx
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
  <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
    <Search className="h-8 w-8 text-muted-foreground" />
  </div>
  <h3 className="text-lg font-semibold mb-2">검색 결과가 없습니다</h3>
  <p className="text-sm text-muted-foreground mb-4 max-w-sm">
    "{searchQuery}"에 대한 결과를 찾을 수 없습니다. 다른 검색어로 시도해보세요.
  </p>
  <button className="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-4">
    검색어 초기화
  </button>
</div>
```

**에러 상태:**

```tsx
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
  <div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
    <AlertCircle className="h-8 w-8 text-destructive" />
  </div>
  <h3 className="text-lg font-semibold mb-2">데이터를 불러올 수 없습니다</h3>
  <p className="text-sm text-muted-foreground mb-4 max-w-sm">
    일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
  </p>
  <button className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4">
    <RefreshCw className="mr-2 h-4 w-4" />
    다시 시도
  </button>
</div>
```

**아이콘 가이드:**
- 데이터 없음: Inbox, Package, FileText
- 검색 결과 없음: Search, SearchX
- 필터 결과 없음: Filter, FilterX
- 에러: AlertCircle, XCircle
- 네트워크 오류: WifiOff, CloudOff
- 권한 없음: Lock, ShieldOff

---

## 추가 프로젝트 규칙

- 백엔드 재실행 금지
- 항상 한글로 답변
- 이모지 사용 금지 (명시적 요청 없이)
- 심플하고 깔끔한 디자인 유지

---

## 사용자 관리 필수 규칙

### 최고 관리자(SUPER_ADMIN) 가시성 제한

**핵심 원칙**: 회사 관리자(COMPANY_ADMIN)와 일반 사용자(USER)는 **절대로** 최고 관리자(company_code = "*")를 볼 수 없어야 합니다.

#### 백엔드 구현 필수사항

모든 사용자 관련 API에서 다음 필터링 로직을 **반드시** 적용해야 합니다:

```typescript
// 최고 관리자 필터링 (필수)
if (req.user && req.user.companyCode !== "*") {
  // 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외
  whereConditions.push(`company_code != '*'`);
  logger.info("최고 관리자 필터링 적용", { userCompanyCode: req.user.companyCode });
}
```

**SQL 쿼리 예시:**
```sql
SELECT * FROM user_info 
WHERE 1=1
  AND company_code != '*'  -- 최고 관리자 제외
  AND company_code = $1    -- 회사별 필터링
```

#### 적용 대상 API (필수)

다음 사용자 관련 API에 최고 관리자 필터링을 **반드시** 적용해야 합니다:

1. **사용자 목록 조회** (`GET /api/admin/users`)
   - 사용자 관리 페이지
   - 권한 그룹 멤버 선택 (Dual List Box)
   - 검색/필터 결과

2. **사용자 검색** (`GET /api/admin/users/search`)
   - 자동완성/타입어헤드
   - 드롭다운 선택

3. **부서별 사용자 조회** (`GET /api/admin/users/by-department`)
   - 부서 필터링 시

4. **사용자 상세 조회** (`GET /api/admin/users/:userId`)
   - 최고 관리자의 상세 정보는 최고 관리자만 볼 수 있음

#### 프론트엔드 추가 보호 (권장)

백엔드에서 이미 필터링되지만, 프론트엔드에서도 추가 체크를 권장합니다:

```typescript
// 컴포넌트에서 최고 관리자 제외
const visibleUsers = users.filter(user => {
  // 최고 관리자만 최고 관리자를 볼 수 있음
  if (user.companyCode === "*" && !isSuperAdmin) {
    return false;
  }
  return true;
});
```

#### 예외 사항

- **최고 관리자(company_code = "*")** 는 모든 사용자(다른 최고 관리자 포함)를 볼 수 있습니다.
- 최고 관리자는 다른 회사의 데이터도 조회할 수 있습니다.

#### 체크리스트

새로운 사용자 관련 기능 개발 시 다음을 확인하세요:

- [ ] `req.user.companyCode !== "*"` 체크 추가
- [ ] `company_code != '*'` WHERE 조건 추가
- [ ] 로깅으로 필터링 적용 여부 확인
- [ ] 최고 관리자로 로그인하여 정상 작동 확인
- [ ] 회사 관리자로 로그인하여 최고 관리자가 안 보이는지 확인

#### 관련 파일

- `backend-node/src/controllers/adminController.ts` - `getUserList()` 함수 참고
- `backend-node/src/middleware/authMiddleware.ts` - 권한 체크
- `frontend/components/admin/UserManagement.tsx` - 사용자 목록 UI
- `frontend/components/admin/RoleDetailManagement.tsx` - 멤버 선택 UI

#### 보안 주의사항

- 클라이언트 측 필터링만으로는 부족합니다 (우회 가능).
- 반드시 백엔드 SQL 쿼리에서 필터링해야 합니다.
- API 응답에 최고 관리자 정보가 절대 포함되어서는 안 됩니다.
- 로그에 필터링 여부를 기록하여 감사 추적을 남기세요.

---

## 멀티테넌시(Multi-Tenancy) 필수 규칙

### 핵심 원칙

**모든 데이터 조회/생성/수정/삭제 로직은 반드시 회사별(company_code)로 격리되어야 합니다.**

이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다.

### 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_info(company_code)
);

-- 성능을 위한 인덱스 (필수)
CREATE INDEX idx_example_company_code ON example_table(company_code);
```

#### 예외 테이블

다음 테이블들만 `company_code` 없이 전역 데이터를 저장할 수 있습니다:

- `company_info` (회사 마스터 데이터)
- `user_info` (사용자는 company_code 보유)
- 시스템 설정 테이블 (`system_config` 등)
- 감사 로그 테이블 (`audit_log` 등)

### 2. 백엔드 API 구현 필수 사항

#### 조회(SELECT) 쿼리

**모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다:**

```typescript
// ✅ 올바른 방법
async function getDataList(req: Request, res: Response) {
  const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드
  
  const query = `
    SELECT * FROM example_table
    WHERE company_code = $1
    ORDER BY created_at DESC
  `;
  
  const result = await pool.query(query, [companyCode]);
  
  logger.info("데이터 조회", { 
    companyCode, 
    rowCount: result.rowCount 
  });
  
  return res.json({ success: true, data: result.rows });
}

// ❌ 잘못된 방법 - 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 });
}
```

#### 생성(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] });
}

// ❌ 잘못된 방법 - company_code 누락
async function createData(req: Request, res: Response) {
  const { name, description } = req.body;
  
  const query = `
    INSERT INTO example_table (name, description)
    VALUES ($1, $2)
  `; // company_code 누락! 다른 회사 데이터와 섞임
  
  const result = await pool.query(query, [name, description]);
  return res.json({ success: true, data: result.rows[0] });
}
```

#### 수정(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;
  
  const query = `
    UPDATE example_table
    SET name = $1, description = $2, updated_at = NOW()
    WHERE id = $3 AND company_code = $4
    RETURNING *
  `;
  
  const result = await pool.query(query, [name, description, id, companyCode]);
  
  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] });
}

// ❌ 잘못된 방법 - 다른 회사 데이터도 수정 가능
async function updateData(req: Request, res: Response) {
  const { id } = req.params;
  const { name, description } = req.body;
  
  const query = `
    UPDATE example_table
    SET name = $1, description = $2
    WHERE id = $3
  `; // 다른 회사의 같은 ID 데이터도 수정됨!
  
  const result = await pool.query(query, [name, description, id]);
  return res.json({ success: true, data: result.rows[0] });
}
```

#### 삭제(DELETE) 쿼리

**WHERE 절에 company_code를 반드시 포함해야 합니다:**

```typescript
// ✅ 올바른 방법
async function deleteData(req: Request, res: Response) {
  const companyCode = req.user!.companyCode;
  const { id } = req.params;
  
  const query = `
    DELETE FROM example_table
    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, id });
  
  return res.json({ success: true });
}

// ❌ 잘못된 방법 - 다른 회사 데이터도 삭제 가능
async function deleteData(req: Request, res: Response) {
  const { id } = req.params;
  
  const query = `DELETE FROM example_table WHERE id = $1`;
  const result = await pool.query(query, [id]);
  return res.json({ success: true });
}
```

### 3. company_code = "*" 의미

**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.

- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터

**회사별 데이터 격리 원칙**:
- 회사 A (`company_code = "COMPANY_A"`): 회사 A 데이터만 조회/수정/삭제 가능
- 회사 B (`company_code = "COMPANY_B"`): 회사 B 데이터만 조회/수정/삭제 가능
- 최고 관리자 (`company_code = "*"`): 모든 회사 데이터 + 최고 관리자 전용 데이터 조회 가능

### 4. 최고 관리자(SUPER_ADMIN) 예외 처리

**최고 관리자(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 company_code, created_at DESC
    `;
    params = [];
    logger.info("최고 관리자 전체 데이터 조회");
  } else {
    // 일반 회사: 자신의 회사 데이터만 조회 (company_code = "*" 데이터는 제외)
    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 });
}
```

**핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다!

### 5. 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
`;

// ❌ 잘못된 방법 - 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
`;
```

### 6. 서비스 계층 패턴

**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다:**

```typescript
// ✅ 올바른 서비스 패턴
class ExampleService {
  async findAll(companyCode: string, filters?: any) {
    const query = `
      SELECT * FROM example_table
      WHERE company_code = $1
    `;
    return await pool.query(query, [companyCode]);
  }
  
  async findById(companyCode: string, id: number) {
    const query = `
      SELECT * FROM example_table
      WHERE id = $1 AND company_code = $2
    `;
    const result = await pool.query(query, [id, companyCode]);
    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. 프론트엔드 고려사항

프론트엔드에서는 직접 company_code를 다루지 않습니다. 백엔드 API가 자동으로 처리합니다.

```typescript
// ✅ 프론트엔드 - company_code 불필요
async function fetchData() {
  const response = await apiClient.get("/api/example/list");
  // 백엔드에서 자동으로 현재 사용자의 company_code로 필터링됨
  return response.data;
}

// ❌ 프론트엔드에서 company_code를 수동으로 전달하지 않음
async function fetchData(companyCode: string) {
  const response = await apiClient.get(`/api/example/list?companyCode=${companyCode}`);
  return response.data;
}
```

### 8. 마이그레이션 체크리스트

새로운 테이블이나 기능을 추가할 때 반드시 확인하세요:

#### 데이터베이스

- [ ] 테이블에 `company_code VARCHAR(20) NOT NULL` 컬럼 추가
- [ ] `company_info` 테이블에 대한 외래키 제약조건 추가
- [ ] `company_code`에 인덱스 생성
- [ ] 샘플 데이터에 올바른 `company_code` 값 포함

#### 백엔드 API

- [ ] SELECT 쿼리에 `WHERE company_code = $1` 추가
- [ ] INSERT 쿼리에 `company_code` 컬럼 포함
- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 추가
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 추가
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리
- [ ] 로그에 `companyCode` 정보 포함

#### 테스트

- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인
- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인
- [ ] 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인
- [ ] 직접 SQL 인젝션 시도하여 다른 회사 데이터 접근 불가능 확인

### 9. 보안 주의사항

#### 클라이언트 입력 검증

```typescript
// ❌ 위험 - 클라이언트가 company_code를 지정할 수 있음
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]);
}

// ✅ 안전 - 인증된 사용자의 company_code만 사용
async function createData(req: Request, res: Response) {
  const companyCode = req.user!.companyCode; // 서버에서 확정
  const { name } = req.body;
  const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
  await pool.query(query, [companyCode, name]);
}
```

#### 감사 로그

모든 중요한 작업에 회사 정보를 로깅하세요:

```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: "다른 회사의 데이터 접근 시도",
});
```

### 10. 일반적인 실수와 해결방법

#### 실수 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: EXISTS 서브쿼리

```typescript
// ❌ 잘못된 방법
const query = `
  SELECT * FROM example_table a
  WHERE EXISTS (
    SELECT 1 FROM related_table b WHERE b.example_id = a.id
  )
  AND a.company_code = $1
`;

// ✅ 올바른 방법
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
`;
```

### 11. 참고 자료

- 마이그레이션 파일: `db/migrations/033_add_company_code_to_code_tables.sql`
- 멀티테넌시 분석 문서: `docs/멀티테넌시_구현_현황_분석_보고서.md`
- 사용자 관리 컨트롤러: `backend-node/src/controllers/adminController.ts`
- 인증 미들웨어: `backend-node/src/middleware/authMiddleware.ts`

### 12. 요약

**모든 비즈니스 로직에서 회사별 데이터 격리는 필수입니다:**

1. 모든 테이블에 `company_code` 컬럼 추가
2. 모든 쿼리에 `company_code` 필터링 적용
3. 인증된 사용자의 `req.user.companyCode` 사용
4. 클라이언트 입력으로 `company_code`를 받지 않음
5. 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능
6. **일반 회사는 `company_code = "*"` 데이터를 볼 수 없음** (최고 관리자 전용)
7. JOIN, 서브쿼리, 집계 함수에도 동일하게 적용
8. 모든 작업을 로깅하여 감사 추적 가능

**절대 잊지 마세요: 멀티테넌시는 보안의 핵심입니다!**

**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**

