# 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 (κΈ°λ³Έ) // Secondary // Outline // Ghost // Destructive ``` **λ²„νŠΌ 크기:** - `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 제λͺ© μ„€λͺ… {/* λ‚΄μš© */} {/* μ•‘μ…˜ λ²„νŠΌλ“€ */} ``` ### 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 // 쀑첩 κΈˆμ§€! λ‚΄μš© // ❌ Border μ•ˆμ— Border
// 쀑첩 κΈˆμ§€! λ‚΄μš©
// ❌ λΆˆν•„μš”ν•œ 래퍼
// 쀑첩 κΈˆμ§€! λ‚΄μš©
``` **ν—ˆμš©λ˜λŠ” νŒ¨ν„΄:** ```tsx // βœ… 단일 Card 제λͺ© λ‚΄μš© // βœ… 의미적으둜 λ‹€λ₯Έ μ»΄ν¬λ„ŒνŠΈ μ‘°ν•© // DialogλŠ” 별도 UI λ ˆμ΄μ–΄ ... // βœ… κ·Έλ¦¬λ“œ/리슀트 λ‚΄λΆ€μ˜ Cardλ“€
ν•­λͺ© 1 ν•­λͺ© 2 ν•­λͺ© 3
``` **μ˜ˆμ™Έ 상황 (μ‚¬μš©μžκ°€ λͺ…μ‹œμ μœΌλ‘œ μš”μ²­ν•œ 경우만):** - λŒ€μ‹œλ³΄λ“œμ—μ„œ μ„Ήμ…˜λ³„ 그룹핑이 ν•„μš”ν•œ 경우 - λ³΅μž‘ν•œ 데이터 ꡬ쑰λ₯Ό μ‹œκ°μ μœΌλ‘œ ꡬ뢄해야 ν•˜λŠ” 경우 - λ“œλž˜κ·Έμ•€λ“œλ‘­ λ“± 특수 κΈ°λŠ₯을 μœ„ν•œ 경우 **원칙:** - μ‹¬ν”Œν•˜κ³  κΉ”λ”ν•œ λ””μžμΈ μœ μ§€ - λΆˆν•„μš”ν•œ μ‹œκ°μ  λ ˆμ΄μ–΄ 제거 - μ‚¬μš©μžκ°€ λͺ…μ‹œμ μœΌλ‘œ "λ°•μŠ€ μ•ˆμ— λ°•μŠ€", "μ€‘μ²©λœ μΉ΄λ“œ" 등을 μš”μ²­ν•˜μ§€ μ•ŠμœΌλ©΄ 단일 레벨 μœ μ§€ ### 17. ν‘œμ€€ λͺ¨λ‹¬(Dialog) λ””μžμΈ νŒ¨ν„΄ **ν”„λ‘œμ νŠΈ ν‘œμ€€ λͺ¨λ‹¬ ꡬ쑰 (ν”Œλ‘œμš° 관리 κΈ°μ€€):** ```tsx {/* 헀더: 제λͺ© + μ„€λͺ… */} λͺ¨λ‹¬ 제λͺ© λͺ¨λ‹¬μ— λŒ€ν•œ κ°„λ‹¨ν•œ μ„€λͺ… {/* 컨텐츠: 폼 ν•„λ“œλ“€ */}
{/* 각 μž…λ ₯ ν•„λ“œ */}

도움말 ν…μŠ€νŠΈ (선택사항)

{/* ν‘Έν„°: μ•‘μ…˜ λ²„νŠΌλ“€ */}
``` **ν•„μˆ˜ 적용 사항:** 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) - 각 ν•„λ“œλŠ” `
` 둜 감싸기 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 μž‘μ—… 확인 μ •λ§λ‘œ 이 μž‘μ—…μ„ μˆ˜ν–‰ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?
이 μž‘μ—…μ€ 되돌릴 수 μ—†μŠ΅λ‹ˆλ‹€.
``` **원칙:** - λͺ¨λ“  λͺ¨λ‹¬μ€ λͺ¨λ°”일 μš°μ„  λ°˜μ‘ν˜• λ””μžμΈ - μΌκ΄€λœ 크기, 간격, 폰트 크기 μ‚¬μš© - μ‚¬μš©μžκ°€ λ‹€λ₯Έ 크기λ₯Ό λͺ…μ‹œν•˜μ§€ μ•ŠμœΌλ©΄ `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(""); // λ Œλ”λ§ ν•­λͺ©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. {items.map((item) => ( { setValue(currentValue === value ? "" : currentValue); setOpen(false); }} className="text-xs sm:text-sm" > {item.label} ))} ``` **λ³΅μž‘ν•œ 데이터 ν‘œμ‹œ (라벨 + μ„€λͺ…):** ```tsx { setValue(currentValue); setOpen(false); }} className="text-xs sm:text-sm" >
{item.label} {item.description && ( {item.description} )}
``` **ν•„μˆ˜ 적용 사항:** 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 ``` **일반 Select vs Combobox 선택 κΈ°μ€€:** | 상황 | μ»΄ν¬λ„ŒνŠΈ | 이유 | |------|----------|------| | ν•­λͺ© 5개 μ΄ν•˜ | `` | λΉ λ₯Έ 선택 | **원칙:** - μ‚¬μš©μžκ°€ λͺ…μ‹œμ μœΌλ‘œ μš”μ²­ν•˜μ§€ μ•ŠμœΌλ©΄ 일반 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

8자 이상 μž…λ ₯ν•΄μ£Όμ„Έμš”

// Error Message

이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€

// Success Message

μ‚¬μš© κ°€λŠ₯ν•œ μ΄λ©”μΌμž…λ‹ˆλ‹€

``` **Form Label (폼 라벨):** ```tsx // κΈ°λ³Έ 라벨 // ν•„μˆ˜ ν•­λͺ© ν‘œμ‹œ ``` **전체 폼 ν•„λ“œ ꡬ쑰:** ```tsx
{error && (

{errorMessage}

)} {!error && helperText && (

{helperText}

)}
``` **μ‹€μ‹œκ°„ 검증 ν”Όλ“œλ°±:** ```tsx // λ‘œλ”© 쀑 (검증 μ§„ν–‰)
// 성곡
// μ‹€νŒ¨
``` ### 20. Loading States (λ‘œλ”© μƒνƒœ) **Spinner (μŠ€ν”Όλ„ˆ) 크기별:** ```tsx // Small // Default // Large ``` **Spinner 색상별:** ```tsx // Primary // Muted // White (닀크 배경용) ``` **Button Loading:** ```tsx ``` **Skeleton UI:** ```tsx // ν…μŠ€νŠΈ μŠ€μΌˆλ ˆν†€
// μΉ΄λ“œ μŠ€μΌˆλ ˆν†€
``` **Progress Bar (μ§„ν–‰λ₯ ):** ```tsx // κΈ°λ³Έ Progress Bar
// 라벨 포함
μ—…λ‘œλ“œ 쀑... {progress}%
``` **Full Page Loading:** ```tsx

λ‘œλ”© 쀑...

``` ### 21. Empty States (빈 μƒνƒœ) **κΈ°λ³Έ Empty State:** ```tsx

데이터가 μ—†μŠ΅λ‹ˆλ‹€

아직 μƒμ„±λœ ν•­λͺ©μ΄ μ—†μŠ΅λ‹ˆλ‹€. μƒˆλ‘œμš΄ ν•­λͺ©μ„ μΆ”κ°€ν•΄λ³΄μ„Έμš”.

``` **검색 κ²°κ³Ό μ—†μŒ:** ```tsx

검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€

"{searchQuery}"에 λŒ€ν•œ κ²°κ³Όλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. λ‹€λ₯Έ κ²€μƒ‰μ–΄λ‘œ μ‹œλ„ν•΄λ³΄μ„Έμš”.

``` **μ—λŸ¬ μƒνƒœ:** ```tsx

데이터λ₯Ό 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€

μΌμ‹œμ μΈ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.

``` **μ•„μ΄μ½˜ κ°€μ΄λ“œ:** - 데이터 μ—†μŒ: 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 = "*"λŠ” 곡톡 데이터가 μ•„λ‹Œ 졜고 κ΄€λ¦¬μž μ „μš© λ°μ΄ν„°μž…λ‹ˆλ‹€!**