# ERP 화면 직접 구현 규칙 (Direct Implementation Mode)

이 문서는 **직접 구현** 방식으로 화면을 개발/리디자인할 때 참조하는 규칙입니다.
프리셋 HTML(Type A~F) 디자인 규격에 따라 shadcn/ui 기본 컴포넌트로 직접 조립하여 구현합니다.

> **적용 대상:** COMPANY_16(하이큐마그) 전체, 향후 신규 화면 개발
> **로우코드와의 차이:** 로우코드는 DataGrid/DynamicSearchFilter 등 공통 컴포넌트에 설정만 넘기는 방식. 직접 구현은 프리셋 규격에 맞춰 shadcn/ui 기본으로 직접 조립.

---

## 1. 프로젝트 정보

- **프로젝트:** erp-node (Next.js App Router + Node.js/Express + PostgreSQL)
- **프론트엔드:** /Users/gbpark/erp-node/frontend/
- **백엔드:** /Users/gbpark/erp-node/backend-node/
- **화면 경로:** frontend/app/(main)/{COMPANY}/{category}/{screen}/page.tsx
- **프리셋 HTML:** _local/erp-preset-type-{a~f}-*.html
- **공통 CSS:** _local/erp-preset-common.css
- **글로벌 CSS:** frontend/app/globals.css (테이블 Enhancement 포함)

---

## 2. 절대 금지 컴포넌트 (★ 최우선 규칙)

아래 공통 컴포넌트는 **절대 import/사용 금지**. shadcn/ui 기본으로 직접 구현:

| 금지 | 대체 방법 |
|------|-----------|
| `DataGrid` | `Table, TableBody, TableCell, TableHead, TableHeader, TableRow` 직접 조합 |
| `DynamicSearchFilter` | `Input, Select, Button`으로 검색 영역 직접 구현 |
| `SmartSelect` | `Select` 또는 `Popover + Command` 조합으로 직접 구현 |
| `FullscreenDialog` | `Dialog` (shadcn/ui)로 직접 구현 |
| `TableSettingsModal` | 필요 시 인라인으로 직접 구현 |
| `FormDatePicker` | `Input type="date"` 또는 `Calendar` 컴포넌트 직접 구현 |

### 사용 가능한 공통 컴포넌트 (예외)

```tsx
import { useConfirmDialog } from "@/components/common/ConfirmDialog";  // OK (로직 훅)
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; // OK (복잡한 유틸)
import { exportToExcel } from "@/lib/utils/excelExport"; // OK (유틸 함수)
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; // OK (도메인 모달)
import { TimelineScheduler } from "@/components/common/TimelineScheduler"; // OK (특수 컴포넌트)
```

---

## 3. 허용 import 패턴

### shadcn/ui 기본 (필수)
```tsx
"use client";

import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
```

### 아이콘 (lucide-react만)
```tsx
import {
  Plus, Trash2, Save, Loader2, Search, X, Pencil,
  ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
  RotateCcw, Download, Upload, FileSpreadsheet, Settings2,
  ChevronDown, ChevronUp, Package, Inbox,
} from "lucide-react";
```

### 유틸
```tsx
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
```

---

## 4. 디자인 규칙

### 4-1. 색상: CSS 변수만 사용 (하드코딩 절대 금지)
```
✅ 허용:
bg-primary, text-primary-foreground, bg-muted, text-muted-foreground
bg-destructive, text-destructive, bg-accent, text-accent-foreground
bg-card, text-card-foreground, bg-background, text-foreground
border-border, border-primary, border-destructive
hsl(var(--primary)), hsl(var(--border)), hsl(var(--muted))

❌ 금지 (하드코딩):
bg-gray-50, bg-gray-100, bg-white, bg-slate-*
text-black, text-gray-500, text-blue-500
border-gray-200, border-slate-*
#3b82f6, #ffffff, rgb(0,0,0)
```

### 4-2. 테마
- 라이트 모드 기본 + `.dark` 클래스로 다크 모드 자동 전환
- Primary: Vivid Blue (HSL 217 91% 60%)
- Dark 배경: Deep Navy (HSL 222 47% 6%)

### 4-3. 컴포넌트 규칙
- shadcn/ui 컴포넌트만 사용 (`@/components/ui/*`)
- 아이콘: lucide-react만 사용
- Card-in-Card 중첩 금지
- 인라인 콘텐츠에 `max-h` / `overflow-y-auto` 금지
- 새 UI 라이브러리 설치 금지

### 4-4. 텍스트 톤
- Toss 스타일 **~해요 체** 사용
  - 빈 상태: "수주를 등록해주세요", "좌측에서 거래처를 선택해주세요"
  - 확인 다이얼로그: "삭제하시겠어요?", "저장되었어요"
  - 플레이스홀더: "품목명을 입력해주세요"

### 4-5. 사이즈 표준
- 입력필드 높이: `h-9` (36px)
- 버튼 기본: `h-9`, 소형: `h-8` (`size="sm"`)
- 폰트: `text-sm` (14px) 기본, `text-xs` (12px) 보조

---

## 5. 프리셋 6종 — 화면 유형별 레이아웃

### ★ 테이블 공통 스타일 (globals.css에서 전역 적용)

globals.css에 아래 스타일이 전역 적용되므로, 각 페이지에서는 구조만 맞추면 됨:
- **데이터 셀**: padding 12px 16px, font-size 14px, line-height 1.5
- **헤더 셀**: padding 12px 16px, font-size 14px, font-weight 700
- **짝수 행 stripe**: 미세 배경색 (라이트: muted/0.35, 다크: muted/0.18)
- **hover**: bg-accent
- **다크모드 체크박스**: 밝은 테두리 (흰색 계열)

### ★ 테이블 헤더 통일 규격 (★ 반드시 준수)

모든 테이블의 TableHead 데이터 컬럼에 아래 className을 적용:
```tsx
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">컬럼명</TableHead>

// 숫자 컬럼 (우측 정렬)
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>

// 너비 지정 시 앞에 추가
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>

// TableHeader 행
<TableHeader className="sticky top-0 z-10">
  <TableRow className="bg-muted hover:bg-muted">
    ...
  </TableRow>
</TableHeader>
```

### ★ 선택 행 스타일 통일
```tsx
<TableRow
  className={cn(
    "cursor-pointer border-l-[3px] border-l-transparent transition-all",
    isSelected ? "border-l-primary bg-primary/5" : "hover:bg-accent"
  )}
>
```

---

### Type A: 단일 테이블형
- 프리셋: `_local/erp-preset-type-a-single-table.html`
- 적용 페이지: sales/order, purchase/order, master-data/item-info, logistics/packaging, production/work-instruction, quality/item-inspection, design/change-management, design/design-request
- 구조:
```
page-container(flex flex-col h-full gap-3 p-4)
  ├─ 브레드크럼 (text-xs text-muted-foreground)
  ├─ 검색 필터 (card: rounded-lg border bg-card px-5 py-4)
  │    ├─ grid(grid-cols-5 gap-3 items-end)
  │    │    ├─ field-group(flex flex-col gap-1) × N
  │    │    │    ├─ label(text-[11px] font-semibold uppercase tracking-wide text-muted-foreground)
  │    │    │    └─ Input(h-9) / Select(h-9) / date range
  │    │    └─ 조회/초기화 Button (h-9)
  ├─ 액션 바 (flex justify-between)
  │    ├─ 타이틀(text-lg font-bold) + 건수(font-mono text-[11px] text-primary bg-primary/5 rounded-full)
  │    └─ 버튼 그룹 (등록/수정/삭제 | 엑셀업로드/다운로드 | 설정)
  ├─ 테이블 카드 (flex-1 overflow-hidden rounded-lg border bg-card)
  │    └─ Table (★ 테이블 공통 스타일 적용)
  │         ├─ TableHeader(sticky top-0 z-10) → TableRow(bg-muted hover:bg-muted)
  │         │    └─ TableHead(text-[11px] font-bold uppercase tracking-wide text-muted-foreground)
  │         └─ TableBody → 데이터 행 (선택 행 스타일, hover)
  └─ 등록/수정 모달 (Dialog: max-w-[95vw] max-h-[92vh])
       ├─ DialogHeader (타이틀 + 설명)
       ├─ 4열 그리드 폼 (grid grid-cols-4 gap-4)
       │    └─ Label(text-[11px] font-semibold uppercase) + Input(h-9) / Select / date
       ├─ 디테일 리피터 (마스터-디테일인 경우)
       └─ DialogFooter (취소 + 저장)
```

### Type B: 마스터-디테일형 (좌우 분할)
- 프리셋: `_local/erp-preset-type-b-master-detail.html`
- 적용 페이지: sales/customer, sales/sales-item, sales/claim, sales/shipping-*, purchase/purchase-item, purchase/supplier, logistics/inventory, logistics/warehouse, logistics/outbound, logistics/receiving, logistics/material-status, master-data/department, equipment/info, outsourcing/*, design/task-management, mold/info, production/plan-management
- 구조:
```
page-container(flex flex-col h-full gap-3 p-4)
  ├─ 검색 필터 (Type A와 동일 구조)
  └─ ResizablePanelGroup(direction="horizontal" className="flex-1")
       ├─ ResizablePanel(defaultSize={55} minSize={30})  ← 좌측: 마스터
       │    └─ card(flex flex-col h-full border rounded-lg bg-card)
       │         ├─ 패널 헤더(flex justify-between p-3 border-b bg-muted)
       │         │    ├─ 타이틀(text-[13px] font-bold) + 건수 Badge
       │         │    └─ 버튼 (등록/수정/삭제/설정)
       │         └─ Table (★ 테이블 공통 스타일 적용)
       ├─ ResizableHandle(withHandle)
       └─ ResizablePanel(defaultSize={45} minSize={25})  ← 우측: 상세
            ├─ 미선택 시: empty-state
            │    └─ border-2 border-dashed rounded-lg + 아이콘 + "좌측에서 ~를 선택해주세요"
            └─ 선택 시: 상세 콘텐츠 또는 Tabs
                 └─ Tabs (탭별 상세 테이블/폼)
```

### Type C: 트리+디테일형
- 프리셋: `_local/erp-preset-type-c-tree-detail.html`
- 적용 페이지: production/bom, master-data/company, design/project
- Type B와 동일하되 우측에 **트리뷰 + 상세카드**
- 트리 노드: 재귀 컴포넌트로 직접 구현 (라이브러리 금지)
- 트리 indent: `pl-{level * 6}`
- expand/collapse: `expandedNodes: Set<string>` 상태 관리
- 트리 노드 UI:
```tsx
<div className={cn("flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded hover:bg-muted/50",
  selected && "bg-primary/5 border-l-[3px] border-l-primary"
)} style={{ paddingLeft: `${level * 24}px` }}>
  <ChevronRight className={cn("h-4 w-4 transition-transform", expanded && "rotate-90")} />
  <span className="text-sm">{node.name}</span>
</div>
```

### Type D: 탭 멀티뷰형
- 프리셋: `_local/erp-preset-type-d-tab-multiview.html`
- 적용 페이지: equipment/plc-settings, logistics/info, quality/inspection
- 구조:
```
page-container(flex flex-col h-full gap-3 p-4)
  └─ card(flex-1 flex flex-col border rounded-lg)
       ├─ Tabs (shadcn)
       │    ├─ TabsList
       │    │    └─ TabsTrigger × N (탭 이름 + 건수 Badge)
       │    └─ TabsContent × N
       │         ├─ 패널 헤더 (타이틀 + 버튼)
       │         └─ Table (★ 테이블 공통 스타일 적용)
       └─ 등록/수정 모달
```

### Type E: 카드 리스트형
- 프리셋: `_local/erp-preset-type-e-card-list.html`
- 적용 페이지: design/my-work (칸반/리스트/타임시트 뷰)
- Type B에서 좌측이 Table 대신 카드 리스트
- 카드: `border rounded-lg p-4 cursor-pointer hover:border-primary`

### Type F: 리포트형
- 프리셋: `_local/erp-preset-type-f-report.html`
- 적용 페이지: 현재 없음 (향후 대시보드/리포트 화면용)
- 선언적 config 객체 + ReportEngine 사용
- 차트 + 요약 카드 + 상세 테이블 구조

---

## 6. 공통 API 패턴

### 6-1. 범용 CRUD (table-management)

```tsx
// ★ 목록 조회 (POST)
const fetchData = async () => {
  setLoading(true);
  try {
    const filters: any[] = [];
    if (searchValue) {
      filters.push({ columnName: "column_name", operator: "contains", value: searchValue });
    }
    if (searchStatus !== "all") {
      filters.push({ columnName: "status", operator: "equals", value: searchStatus });
    }

    const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
      page: currentPage,
      size: pageSize,
      dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
      autoFilter: true,
      sort: { columnName: "created_date", order: "desc" },
    });

    const rows = res.data?.data?.data || res.data?.data?.rows || [];
    const total = res.data?.data?.totalCount || res.data?.data?.total || 0;
    setData(rows);
    setTotalCount(total);
  } catch (err) {
    toast.error("조회에 실패했어요");
  } finally {
    setLoading(false);
  }
};

// ★ 등록 (POST /add)
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
  field1: value1,
  field2: value2,
  // 시스템 필드 제외: id, created_date, updated_date, writer, company_code, created_by, updated_by
});

// ★ 수정 (PUT /edit)
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
  originalData: { id: selectedId },
  updatedData: { field1: newValue1, field2: newValue2 },
});

// ★ 삭제 (DELETE /delete)
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
  data: selectedIds.map(id => ({ id })),
});
```

### 6-2. 카테고리 옵션 로드

```tsx
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${COLUMN}/values`);

const flatten = (items: any[]): { code: string; label: string }[] => {
  return items.reduce((acc, item) => {
    acc.push({ code: item.code || item.value, label: item.label || item.name });
    if (item.children?.length > 0) {
      acc.push(...flatten(item.children));
    }
    return acc;
  }, [] as any[]);
};

const options = res.data?.data?.length > 0 ? flatten(res.data.data) : [];
```

### 6-3. 코드→라벨 변환
```tsx
const resolveLabel = (code: string, optionKey: string): string => {
  const opts = categoryOptions[optionKey] || [];
  return opts.find(o => o.code === code)?.label || code || "-";
};
```

### 6-4. 마스터-디테일 저장 패턴
```tsx
// 신규: 마스터 add → 디테일 각 행 add
// 수정: 마스터 edit → 기존 디테일 delete → 디테일 재 add
// 시스템 필드 제외: id, created_date, updated_date, writer, company_code, created_by, updated_by
```

---

## 7. 테이블 구현 상세

### 7-1. 기본 테이블 구조 (★ 모든 테이블에 이 패턴 적용)
```tsx
<div className="flex-1 overflow-auto">
  <Table>
    <TableHeader className="sticky top-0 z-10">
      <TableRow className="bg-muted hover:bg-muted">
        <TableHead className="w-10 pl-4">
          <Checkbox checked={allChecked} onCheckedChange={handleCheckAll} />
        </TableHead>
        {/* ★ 모든 데이터 컬럼에 동일한 스타일 적용 */}
        <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
        <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
        <TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      {loading ? (
        <TableRow>
          <TableCell colSpan={colCount} className="py-16 text-center">
            <Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
          </TableCell>
        </TableRow>
      ) : data.length === 0 ? (
        <TableRow>
          <TableCell colSpan={colCount} className="py-16 text-center">
            <div className="flex flex-col items-center gap-2 text-muted-foreground">
              <Inbox className="h-8 w-8 opacity-30" />
              <span className="text-sm">등록된 데이터가 없어요</span>
            </div>
          </TableCell>
        </TableRow>
      ) : (
        data.map(row => (
          <TableRow
            key={row.id}
            className={cn(
              "cursor-pointer border-l-[3px] border-l-transparent transition-all",
              isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent"
            )}
          >
            <TableCell className="pl-4">
              <Checkbox checked={isChecked} onCheckedChange={() => toggleCheck(row.id)} />
            </TableCell>
            <TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
            <TableCell className="text-[13px]">{row.part_name}</TableCell>
            <TableCell className="text-right font-mono text-[13px]">{Number(row.qty).toLocaleString()}</TableCell>
          </TableRow>
        ))
      )}
    </TableBody>
  </Table>
</div>
```

### 7-2. 데이터 셀 className 패턴
```tsx
// 코드/번호 → font-mono
<TableCell className="font-mono text-[13px]">{row.order_no}</TableCell>

// 일반 텍스트
<TableCell className="text-[13px]">{row.part_name}</TableCell>

// 숫자 → text-right font-mono
<TableCell className="text-right font-mono text-[13px]">{Number(row.qty).toLocaleString()}</TableCell>

// 금액 → text-right font-mono font-semibold
<TableCell className="text-right font-mono text-[13px] font-semibold">{Number(row.amount).toLocaleString()}</TableCell>

// 보조 정보 (규격, 메모 등) → text-muted-foreground
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>

// 긴 텍스트 → truncate
<TableCell className="text-[13px] max-w-[150px]">
  <span className="block truncate" title={row.item_name}>{row.item_name}</span>
</TableCell>

// 날짜
<TableCell className="text-[13px]">{row.due_date}</TableCell>

// 뱃지
<TableCell><Badge variant={getVariant(row.status)}>{resolveLabel(row.status, "status")}</Badge></TableCell>
```

---

## 8. 검색 필터 구현

```tsx
<div className="rounded-lg border bg-card px-5 py-4">
  <div className="grid grid-cols-5 gap-3 items-end">
    <div className="flex flex-col gap-1">
      <label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">수주번호</label>
      <Input value={search} onChange={e => setSearch(e.target.value)}
        onKeyDown={e => e.key === "Enter" && fetchData()} placeholder="SO-2026-0001" className="h-9" />
    </div>
    <div className="flex flex-col gap-1">
      <label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">기간</label>
      <div className="flex items-center gap-1.5">
        <Input type="date" className="h-9 flex-1" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
        <span className="text-xs text-muted-foreground/50">~</span>
        <Input type="date" className="h-9 flex-1" value={dateTo} onChange={e => setDateTo(e.target.value)} />
      </div>
    </div>
    <div className="flex items-end gap-2">
      <Button variant="ghost" size="sm" className="h-9" onClick={handleReset}>
        <RotateCcw className="w-4 h-4" /> 초기화
      </Button>
      <Button size="sm" className="h-9 flex-1" onClick={fetchData}>
        <Search className="w-4 h-4" /> 조회
      </Button>
    </div>
  </div>
</div>
```

---

## 9. 모달(Dialog) 구현

```tsx
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
  <DialogContent className="flex flex-col gap-0 p-0 max-w-[95vw] w-full" style={{ maxHeight: "92vh" }}>
    <DialogHeader className="shrink-0 border-b px-6 py-5">
      <DialogTitle className="text-[17px] font-bold">{isEditMode ? "수정" : "등록"}</DialogTitle>
      <DialogDescription>정보를 입력해주세요</DialogDescription>
    </DialogHeader>
    <div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
      {/* 섹션 구분 */}
      <div className="space-y-3">
        <div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
          기본 정보 <div className="flex-1 h-px bg-border" />
        </div>
        <div className="grid grid-cols-4 gap-3.5">
          <div className="space-y-1">
            <Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
              필드명 <span className="text-destructive">*</span>
            </Label>
            <Input className="h-9" value={form.field || ""} onChange={e => setForm(p => ({ ...p, field: e.target.value }))} />
          </div>
        </div>
      </div>
    </div>
    <DialogFooter className="shrink-0 border-t px-6 py-4">
      <Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
      <Button onClick={handleSave} disabled={saving}>
        {saving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />} 저장
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

---

## 10. 빈 상태 구현

```tsx
{/* 테이블 빈 상태 */}
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground py-16">
  <Inbox className="h-8 w-8 opacity-30" />
  <span className="text-sm">등록된 데이터가 없어요</span>
</div>

{/* 마스터-디테일 우측 빈 상태 */}
<div className="flex flex-col items-center justify-center gap-3 h-full border-2 border-dashed rounded-lg">
  <Package className="h-10 w-10 text-muted-foreground/40" />
  <p className="text-sm font-medium text-muted-foreground">좌측에서 항목을 선택해주세요</p>
  <p className="text-xs text-muted-foreground/70">상세 정보가 여기에 표시돼요</p>
</div>
```

---

## 11. 콤보박스 (Popover + Command)

```tsx
<Popover open={comboOpen} onOpenChange={setComboOpen}>
  <PopoverTrigger asChild>
    <Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
      {selectedValue ? options.find(o => o.code === selectedValue)?.label : "선택해주세요"}
      <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
    </Button>
  </PopoverTrigger>
  <PopoverContent className="w-[300px] p-0">
    <Command>
      <CommandInput placeholder="검색..." />
      <CommandList>
        <CommandEmpty>결과가 없어요</CommandEmpty>
        <CommandGroup>
          {options.map(opt => (
            <CommandItem key={opt.code} value={opt.label} onSelect={() => { setSelectedValue(opt.code); setComboOpen(false); }}>
              {opt.label}
            </CommandItem>
          ))}
        </CommandGroup>
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>
```

---

## 12. 유틸 함수

```tsx
// 천단위 포맷
const formatNumber = (val: string | number) => {
  const num = String(val).replace(/[^\d.-]/g, "");
  if (!num) return "";
  const parts = num.split(".");
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
```

---

## 13. 메뉴 등록 규칙 (신규 화면 필수)

### 13-1. AdminPageRenderer 레지스트리 등록
```tsx
"/{COMPANY}/{category}/{screen}": dynamic(
  () => import("@/app/(main)/{COMPANY}/{category}/{screen}/page"),
  { ssr: false, loading: LoadingFallback }
),
```

### 13-2. menu_info 테이블 업데이트
- `menu_url`: 새 경로 / `screen_code`: NULL / `screen_group_id`: NULL

### 13-3. screen_menu_assignments 비활성화
해당 메뉴의 모든 할당을 `is_active = 'N'`으로 변경.

**메뉴 표시 우선순위:**
1. `screen_menu_assignments` (is_active='Y') → 로우코드 화면
2. `menu_info.screen_group_id` → 로우코드 화면
3. `menu_info.menu_url` → 직접 구현 화면 (AdminPageRenderer)
→ 1, 2를 비활성화해야 3번이 동작함

---

## 14. UI 리디자인 규칙 (기존 화면 수정 시)

1. 기존 return문(JSX)을 **전부 삭제**하고 프리셋 HTML 기준으로 **새로 작성**
2. `useState`, `useEffect`, `useCallback`, API 호출 함수는 **그대로 유지**
3. DataGrid 등 공통 컴포넌트를 쓰고 있으면 **제거하고 shadcn/ui 기본으로 교체**
4. "현재 코드가 이미 충족한다"고 판단하지 말 것. **무조건 프리셋 기준으로 새로 작성**
5. git diff 기준 **최소 100줄 이상 변경** 필수

---

## 15. 절대 금지 사항

1. `fetch()` 직접 사용 (반드시 `apiClient` 사용)
2. 하드코딩 색상 (`bg-gray-*`, `bg-white`, `text-black`, `#hex`)
3. 새 라이브러리 설치
4. `console.log` 남기기
5. `any` 타입 남발 (최소한의 타입 정의)
6. 비즈니스 로직 변경 (UI 리디자인 태스크일 때)
7. 다른 화면 UI를 그대로 복사 (패턴 참고만, 컬럼/필드는 독립 설계)
8. DataGrid, DynamicSearchFilter, SmartSelect, FullscreenDialog, FormDatePicker import
9. 테이블 헤더 스타일을 페이지마다 다르게 적용 (★ 통일 규격 준수)
