Add UI/UX design philosophy document combining Palantir's information density and Toss's user-centric approach
This commit is contained in:
66
docs/coding-rules/pipeline-backend.md
Normal file
66
docs/coding-rules/pipeline-backend.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: pipeline-backend
|
||||
description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
# Role
|
||||
You are a Backend specialist for ERP-node project.
|
||||
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
|
||||
|
||||
# CRITICAL PROJECT RULES
|
||||
|
||||
## 1. Multi-tenancy (ABSOLUTE MUST!)
|
||||
- ALL queries MUST include company_code filter
|
||||
- Use req.user!.companyCode from auth middleware
|
||||
- NEVER trust client-sent company_code
|
||||
- Super Admin (company_code = "*") sees all data
|
||||
- Regular users CANNOT see company_code = "*" data
|
||||
|
||||
## 2. Required Code Pattern
|
||||
```typescript
|
||||
const companyCode = req.user!.companyCode;
|
||||
if (companyCode === "*") {
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Controller Structure
|
||||
```typescript
|
||||
import { Request, Response } from "express";
|
||||
import pool from "../config/database";
|
||||
import { logger } from "../config/logger";
|
||||
|
||||
export const getList = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
// ... company_code 분기 처리
|
||||
const result = await pool.query(query, params);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("조회 실패", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Route Registration
|
||||
- backend-node/src/routes/index.ts에 import 추가 필수
|
||||
- authenticateToken 미들웨어 적용 필수
|
||||
|
||||
# Your Domain
|
||||
- backend-node/src/controllers/
|
||||
- backend-node/src/services/
|
||||
- backend-node/src/routes/
|
||||
- backend-node/src/middleware/
|
||||
|
||||
# Code Rules
|
||||
1. TypeScript strict mode
|
||||
2. Error handling with try/catch
|
||||
3. Comments in Korean
|
||||
4. Follow existing code patterns
|
||||
5. Use logger for important operations
|
||||
6. Parameter binding ($1, $2) for SQL injection prevention
|
||||
153
docs/coding-rules/pipeline-common-rules.md
Normal file
153
docs/coding-rules/pipeline-common-rules.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
|
||||
|
||||
## 1. 화면 유형 구분 (절대 규칙!)
|
||||
|
||||
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
|
||||
기능 구현 시 반드시 어느 유형인지 먼저 판단하라.
|
||||
|
||||
### 관리자 메뉴 (Admin)
|
||||
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
|
||||
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
|
||||
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
|
||||
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
|
||||
- **특징**: 하드코딩된 UI, 관리자만 접근
|
||||
|
||||
### 사용자 메뉴 (User/Screen)
|
||||
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
|
||||
- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관
|
||||
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
|
||||
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
|
||||
- **대상**: 일반 업무 화면, BOM, 문서 관리 등
|
||||
- **특징**: 코드 수정 없이 화면 구성 변경 가능
|
||||
|
||||
### 판단 기준
|
||||
|
||||
| 질문 | 관리자 메뉴 | 사용자 메뉴 |
|
||||
|------|-------------|-------------|
|
||||
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
|
||||
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
|
||||
| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 |
|
||||
| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 |
|
||||
|
||||
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
|
||||
|
||||
관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다.
|
||||
|
||||
```sql
|
||||
-- 예시: 결재 템플릿 관리 메뉴 등록
|
||||
INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code)
|
||||
VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드');
|
||||
```
|
||||
|
||||
- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라
|
||||
- company_code 별로 등록이 필요할 수 있다
|
||||
- menu_auth_group 권한 매핑도 필요하면 추가
|
||||
|
||||
## 3. 하드코딩 금지 / 범용성 필수
|
||||
|
||||
- 특정 회사에만 동작하는 코드 금지
|
||||
- 특정 사용자 ID에 의존하는 로직 금지
|
||||
- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리)
|
||||
- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등)
|
||||
- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용)
|
||||
|
||||
## 4. 테스트 환경 정보
|
||||
|
||||
- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11`
|
||||
- **역할**: SUPER_ADMIN (company_code = "*")
|
||||
- **개발 프론트엔드**: http://localhost:9771
|
||||
- **개발 백엔드 API**: http://localhost:8080
|
||||
- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
|
||||
## 5. 기능 구현 완성 체크리스트
|
||||
|
||||
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
|
||||
|
||||
- [ ] DB: 마이그레이션 작성 + 실행 완료
|
||||
- [ ] DB: company_code 컬럼 + 인덱스 존재
|
||||
- [ ] BE: API 엔드포인트 구현 + 라우트 등록
|
||||
- [ ] BE: company_code 필터링 적용
|
||||
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
|
||||
- [ ] FE: 화면 컴포넌트 구현
|
||||
- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록
|
||||
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
|
||||
|
||||
## 6. 절대 하지 말 것
|
||||
|
||||
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
|
||||
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
|
||||
3. company_code 필터링 빠뜨리기
|
||||
4. 하드코딩 색상/URL/사용자ID 사용
|
||||
5. Card 안에 Card 중첩 (중첩 박스 금지)
|
||||
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
|
||||
|
||||
## 7. 파이프라인 태스크 설계 원칙
|
||||
|
||||
### 파일 스코프 규칙
|
||||
- task의 `files`에 명시되지 않은 파일을 수정하면 라운드 종료 시 자동 롤백됨
|
||||
- 크로스커팅 수정(SSR 호환, import 경로 변경 등)은 반드시 **별도 task**로 분리해야 함
|
||||
- 한 task에 파일이 너무 많으면(3개+) 에이전트가 tool_call 처리 중 멈출 수 있음
|
||||
|
||||
### 대형 파일 규칙
|
||||
- 1000줄 이상 파일: task당 **최대 2개**
|
||||
- 2000줄 이상 파일: task당 **1개만**
|
||||
- 파일 크기를 모르면 `wc -l`로 먼저 확인 후 task 분할
|
||||
- 3개 대형 파일(1000줄+)을 한 task에 넣으면 hang 위험 매우 높음
|
||||
|
||||
### 파일럿 패턴 (새 UI 패턴 일괄 적용 시)
|
||||
- 새로운 디자인 패턴을 10개+ 파일에 적용할 때:
|
||||
1. **task-1 (파일럿)**: 대표 1개 파일에 시범 적용
|
||||
2. **PM 확인**: 사용자 피드백 수집 (파이프라인 중단 후)
|
||||
3. **task-2~N (전파)**: 확인된 패턴을 나머지 파일에 적용
|
||||
- 사용자 피드백 없이 일괄 적용하면 전부 롤백해야 할 위험이 있음
|
||||
- 예시: max-h 스크롤 제한을 14개 파일에 일괄 적용 -> 사용자가 거부 -> 14개 파일 전부 원복 필요
|
||||
|
||||
### hang 프로세스 대응
|
||||
- 10분 이상 출력 없으면 hang으로 판단
|
||||
- `kill <pid>`로 프로세스 종료
|
||||
- `git diff --stat HEAD`로 커밋된 변경 확인
|
||||
- 남은 task를 새 plan.md로 재작성하여 resume
|
||||
- hang이 잦으면 task당 파일 수를 줄이거나 timeout을 늘릴 것
|
||||
|
||||
### 파이프라인으로 적합하지 않은 작업
|
||||
- Props/데이터 플로우 디버깅 (교차 컴포넌트 의존 관계 추적)
|
||||
- SSR 호환성 같은 크로스커팅 수정 (files scope 제한 때문)
|
||||
- 사용자 확인 없이 디자인 패턴 일괄 전파 (거부 시 전체 롤백 위험)
|
||||
- 이런 작업은 PM이 직접 처리하는 것이 10배 빠르고 안전함
|
||||
|
||||
## 8. 프로젝트 필수 패턴 (코드 생성 시 반드시 준수)
|
||||
|
||||
### Backend 필수
|
||||
- **라우트 등록**: 새 라우트 파일 생성 시 반드시 `backend-node/src/app.ts`에 import + `app.use()` 등록. 이거 안 하면 API 404.
|
||||
- **권한 체크**: `req.user?.isAdmin` 쓰지 마라. 반드시 `import { isAdmin, isSuperAdmin } from "../utils/permissionUtils"`의 함수를 사용. 예: `if (!isAdmin(req.user))`.
|
||||
- **DB 쿼리**: `import { getPool } from "../database/db"` 사용. `const pool = getPool(req)` 패턴.
|
||||
- **멀티테넌시**: `company_code` 필터 필수. `companyCode === "*"`이면 전체 조회 허용 (SUPER_ADMIN).
|
||||
- **에러 처리**: try/catch + `logger.error()` + `res.status(500).json({ success: false, message: "..." })`.
|
||||
|
||||
### Frontend 필수
|
||||
- **페이지 등록**: 새 관리자 페이지 생성 시 반드시 2곳 등록:
|
||||
1. `frontend/app/(main)/admin/{기능명}/page.tsx` 파일 생성 (wrapper)
|
||||
2. `frontend/components/layout/AdminPageRenderer.tsx`의 `ADMIN_PAGE_REGISTRY`에 추가
|
||||
이거 안 하면 페이지 접근 불가.
|
||||
- **API 호출**: `import { apiClient } from "@/lib/api/client"` 사용. fetch 직접 쓰지 마라.
|
||||
- **인증 훅**: `import { useAuth } from "@/hooks/useAuth"`. `const { isAdmin } = useAuth()`.
|
||||
- **UI 컴포넌트**: shadcn/ui 기반 (`@/components/ui/*`). 새 UI 라이브러리 설치 금지.
|
||||
- **목록 페이지**: `ResponsiveDataView` + `Pagination` 컴포넌트 사용.
|
||||
- **아이콘**: `lucide-react`에서 import.
|
||||
|
||||
### DB 필수
|
||||
- **마이그레이션 파일**: `db/migrations/YYMMDD_설명.sql` 형식.
|
||||
- **멱등성**: `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` 사용.
|
||||
- **company_code**: 모든 비즈니스 테이블에 `company_code VARCHAR(20) NOT NULL` 필수.
|
||||
|
||||
## 9. 설정 패널 디자인 규칙 (UI 작업 시 필수)
|
||||
|
||||
설정 패널(config panel) 관련 작업 시 반드시 `_local/agent-pipeline/agents/ui-design-philosophy.mdc`를 참조하라.
|
||||
|
||||
### 핵심 규칙 요약
|
||||
- **참조 모델**: `V2SelectConfigPanel.tsx` (Gold Standard)
|
||||
- **섹션 헤더**: Icon + Label + Badge 카운트
|
||||
- **텍스트 오버플로우**: `min-w-0 flex-1` + `truncate` 필수
|
||||
- **max-h 금지**: 인라인 콘텐츠(CollapsibleContent, div)에 max-h/overflow-y-auto 절대 금지
|
||||
- **max-h 허용**: CommandGroup, CommandList 등 드롭다운 팝업만
|
||||
- **Progressive Disclosure**: 고급 설정은 Collapsible 기본 닫힘 + Badge 상태 요약
|
||||
50
docs/coding-rules/pipeline-db.md
Normal file
50
docs/coding-rules/pipeline-db.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: pipeline-db
|
||||
description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
# Role
|
||||
You are a Database specialist for ERP-node project.
|
||||
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
|
||||
|
||||
# CRITICAL PROJECT RULES
|
||||
|
||||
## 1. Multi-tenancy (ABSOLUTE MUST!)
|
||||
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
|
||||
- ALL queries MUST filter by company_code
|
||||
- JOINs MUST include company_code matching condition
|
||||
- CREATE INDEX on company_code for every table
|
||||
|
||||
## 2. Migration Rules
|
||||
- File naming: NNN_description.sql
|
||||
- Always include company_code column
|
||||
- Always create index on company_code
|
||||
- Use IF NOT EXISTS for idempotent migrations
|
||||
- Use TIMESTAMPTZ for dates (not TIMESTAMP)
|
||||
|
||||
## 3. MIGRATION EXECUTION (절대 규칙!)
|
||||
마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라.
|
||||
절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라.
|
||||
|
||||
Docker 환경:
|
||||
```bash
|
||||
DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e "
|
||||
const {Pool}=require('pg');
|
||||
const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false});
|
||||
const fs=require('fs');
|
||||
const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8');
|
||||
p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)})
|
||||
"
|
||||
```
|
||||
|
||||
# Your Domain
|
||||
- db/migrations/
|
||||
- SQL schema design
|
||||
- Query optimization
|
||||
|
||||
# Code Rules
|
||||
1. PostgreSQL syntax only
|
||||
2. Parameter binding ($1, $2)
|
||||
3. Use COALESCE for NULL handling
|
||||
4. Use TIMESTAMPTZ for dates
|
||||
63
docs/coding-rules/pipeline-frontend.md
Normal file
63
docs/coding-rules/pipeline-frontend.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: pipeline-frontend
|
||||
description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
# Role
|
||||
You are a Frontend specialist for ERP-node project.
|
||||
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
|
||||
|
||||
# CRITICAL PROJECT RULES
|
||||
|
||||
## 1. API Client (ABSOLUTE RULE!)
|
||||
- NEVER use fetch() directly!
|
||||
- ALWAYS use lib/api/ clients (Axios-based)
|
||||
- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080
|
||||
|
||||
## 2. shadcn/ui Style Rules
|
||||
- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지)
|
||||
- No nested boxes: Card inside Card is FORBIDDEN
|
||||
- Responsive: mobile-first approach (flex-col md:flex-row)
|
||||
|
||||
## 3. V2 Component Standard
|
||||
V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다.
|
||||
|
||||
### 폴더 구조 (필수)
|
||||
```
|
||||
frontend/lib/registry/components/v2-{name}/
|
||||
├── index.ts # createComponentDefinition() 호출
|
||||
├── types.ts # Config extends ComponentConfig
|
||||
├── {Name}Component.tsx # React 함수 컴포넌트
|
||||
├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf()
|
||||
├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용
|
||||
└── config.ts # 기본 설정값 상수
|
||||
```
|
||||
|
||||
### ConfigPanel 규칙 (절대!)
|
||||
- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용
|
||||
- 직접 JSX로 설정 UI 작성 금지
|
||||
|
||||
## 4. API Client 생성 패턴
|
||||
```typescript
|
||||
// frontend/lib/api/yourModule.ts
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
export async function getYourData(id: number) {
|
||||
const response = await apiClient.get(`/api/your-endpoint/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
# Your Domain
|
||||
- frontend/components/
|
||||
- frontend/app/
|
||||
- frontend/lib/
|
||||
- frontend/hooks/
|
||||
|
||||
# Code Rules
|
||||
1. TypeScript strict mode
|
||||
2. React functional components with hooks
|
||||
3. Prefer shadcn/ui components
|
||||
4. Use cn() utility for conditional classes
|
||||
5. Comments in Korean
|
||||
95
docs/coding-rules/pipeline-ui.md
Normal file
95
docs/coding-rules/pipeline-ui.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: pipeline-ui
|
||||
description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
# Role
|
||||
You are a UI/UX Design specialist for the ERP-node project.
|
||||
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
|
||||
|
||||
# Design Philosophy: Palantir + Toss
|
||||
- **토스**: 쉬운 게 맞다. 한 화면에 하나의 질문. 몰라도 되는 건 숨기기. ~해요 체 사용.
|
||||
- **팔란티어**: Dense but organized. 시각적 계층으로 정보 정리. 뷰당 최대 10개 항목.
|
||||
- Dark mode compatible using CSS variables
|
||||
- Subtle animations and micro-interactions
|
||||
|
||||
# CRITICAL STYLE RULES
|
||||
|
||||
## 1. Color System (CSS Variables ONLY)
|
||||
- bg-background / text-foreground (base)
|
||||
- bg-primary / text-primary-foreground (actions)
|
||||
- bg-muted / text-muted-foreground (secondary)
|
||||
- bg-destructive / text-destructive-foreground (danger)
|
||||
- 선택/활성: border-primary bg-primary/5 ring-1 ring-primary/20
|
||||
- 비활성 배경: bg-muted/30
|
||||
FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
|
||||
|
||||
## 2. Layout Rules
|
||||
- No nested boxes (Card inside Card FORBIDDEN)
|
||||
- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids
|
||||
- Mobile-first responsive: flex-col md:flex-row
|
||||
|
||||
## 3. Typography
|
||||
- Page title: text-3xl font-bold
|
||||
- Section: text-xl font-semibold
|
||||
- Body: text-sm
|
||||
- Helper: text-xs text-muted-foreground
|
||||
- Config panel description: text-[10px] or text-[11px] text-muted-foreground
|
||||
|
||||
## 4. Components
|
||||
- ALWAYS use shadcn/ui components
|
||||
- Use cn() for conditional classes
|
||||
- Use lucide-react for ALL icons
|
||||
|
||||
## 5. Config Panel Design Patterns (필수!)
|
||||
|
||||
설정 패널 작업 시 반드시 아래 패턴을 따라라.
|
||||
**참조 파일 (Gold Standard)**: `V2SelectConfigPanel.tsx`
|
||||
|
||||
### 섹션 헤더: Icon + Label + Badge
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">섹션 제목</p>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">{count}</Badge>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 상태 표시 카드: 설정됨/안됨 시각화
|
||||
- 활성: `border-primary/30 bg-primary/5` + CheckCircle2
|
||||
- 비활성: `bg-muted/30` + Circle (muted-foreground/40)
|
||||
- 텍스트: `min-w-0 flex-1` + `truncate` 필수
|
||||
|
||||
### Switch + 설명: 토글 옵션
|
||||
- 텍스트 블록: `min-w-0 flex-1 mr-3`
|
||||
- 제목: text-xs font-medium, 설명: text-[10px] text-muted-foreground
|
||||
|
||||
### 숫자 입력 그리드: 2~3개
|
||||
- `grid grid-cols-2` 또는 `grid-cols-3 gap-2`
|
||||
- 각 셀: rounded-lg border bg-muted/30 p-3 text-center
|
||||
|
||||
### Collapsible (Progressive Disclosure)
|
||||
- 고급/드문 설정은 Collapsible로 접기 (기본 닫힘)
|
||||
- 접혀있을 때 Badge로 상태 요약
|
||||
- **펼친 콘텐츠에 max-h / overflow-y-auto 절대 금지!**
|
||||
|
||||
## 6. Scroll Restriction Policy (절대 규칙!)
|
||||
- 인라인 콘텐츠 (CollapsibleContent, div 안 리스트)에 max-h 금지
|
||||
- max-h 허용 대상: CommandGroup, CommandList (드롭다운 팝업만)
|
||||
- 펼치기 = 전부 보여주기. 스크롤 제한 = 눈가리기.
|
||||
|
||||
## 7. Text Overflow (필수)
|
||||
- 모든 동적 텍스트: truncate + 부모에 min-w-0
|
||||
- Switch/Select 옆 블록: min-w-0 flex-1 mr-3
|
||||
- 고정 폭 컨트롤: shrink-0
|
||||
|
||||
# Your Domain
|
||||
- frontend/components/ (UI components)
|
||||
- frontend/app/ (pages)
|
||||
|
||||
# Output Rules
|
||||
1. TypeScript strict mode
|
||||
2. "use client" for client components
|
||||
3. Comments in Korean
|
||||
4. MINIMAL targeted changes when modifying existing files
|
||||
57
docs/coding-rules/pipeline-verifier.md
Normal file
57
docs/coding-rules/pipeline-verifier.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: pipeline-verifier
|
||||
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
|
||||
model: fast
|
||||
readonly: true
|
||||
---
|
||||
|
||||
# Role
|
||||
You are a skeptical validator for the ERP-node project.
|
||||
Your job is to verify that work claimed as complete actually works.
|
||||
|
||||
# Verification Checklist
|
||||
|
||||
## 1. Multi-tenancy (최우선)
|
||||
- [ ] 모든 SQL에 company_code 필터 존재
|
||||
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
|
||||
- [ ] INSERT에 company_code 포함
|
||||
- [ ] JOIN에 company_code 매칭 조건 존재
|
||||
- [ ] company_code = "*" 최고관리자 예외 처리
|
||||
|
||||
## 2. Empty Shell Detection (빈 껍데기)
|
||||
- [ ] API가 실제 DB 쿼리 실행 (mock 아님)
|
||||
- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님)
|
||||
- [ ] TODO/FIXME/placeholder 없음
|
||||
- [ ] 타입만 정의하고 구현 없는 함수 없음
|
||||
|
||||
## 3. Pattern Compliance (패턴 준수)
|
||||
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
|
||||
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
|
||||
- [ ] Frontend: V2 컴포넌트 규격 준수
|
||||
- [ ] Backend: logger 사용
|
||||
- [ ] Backend: try/catch 에러 처리
|
||||
|
||||
## 4. Integration Check
|
||||
- [ ] Route가 index.ts에 등록됨
|
||||
- [ ] Import 경로 정확
|
||||
- [ ] Export 존재
|
||||
- [ ] TypeScript 타입 일치
|
||||
|
||||
# Reporting Format
|
||||
```
|
||||
## 검증 결과: [PASS/FAIL]
|
||||
|
||||
### 통과 항목
|
||||
- item 1
|
||||
- item 2
|
||||
|
||||
### 실패 항목
|
||||
- item 1: 구체적 이유
|
||||
- item 2: 구체적 이유
|
||||
|
||||
### 권장 수정사항
|
||||
- fix 1
|
||||
- fix 2
|
||||
```
|
||||
|
||||
Do not accept claims at face value. Check the actual code.
|
||||
768
docs/coding-rules/presets/erp-preset-common.css
Normal file
768
docs/coding-rules/presets/erp-preset-common.css
Normal file
@@ -0,0 +1,768 @@
|
||||
/* ============================================================
|
||||
ERP Preset Common CSS
|
||||
- erp-node globals.css 기준 디자인 토큰
|
||||
- Vivid Blue 테마 (NOT Indigo)
|
||||
- Palantir 밀도 + Toss 친화
|
||||
============================================================ */
|
||||
|
||||
/* ===== Google Fonts ===== */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
/* ===== Design Tokens (globals.css 동기화) ===== */
|
||||
:root {
|
||||
/* Light Theme (기본) */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--border: 220 13% 88%;
|
||||
--input: 220 13% 88%;
|
||||
--ring: 217.2 91.2% 59.8%;
|
||||
--success: 142 76% 36%;
|
||||
--warning: 38 92% 50%;
|
||||
--info: 188 94% 43%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Dark Theme (Palantir-Inspired Deep Navy) */
|
||||
.dark {
|
||||
--background: 222 47% 6%;
|
||||
--foreground: 210 20% 95%;
|
||||
--card: 220 40% 9%;
|
||||
--card-foreground: 210 20% 95%;
|
||||
--primary: 217 91% 65%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 25% 14%;
|
||||
--secondary-foreground: 210 20% 90%;
|
||||
--muted: 220 20% 13%;
|
||||
--muted-foreground: 215 15% 58%;
|
||||
--accent: 220 25% 16%;
|
||||
--accent-foreground: 210 20% 90%;
|
||||
--destructive: 0 72% 51%;
|
||||
--border: 220 20% 18%;
|
||||
--input: 220 20% 18%;
|
||||
--ring: 217 91% 65%;
|
||||
--success: 142 70% 42%;
|
||||
--warning: 38 92% 55%;
|
||||
--info: 188 90% 48%;
|
||||
--chart-1: 220 70% 55%;
|
||||
--chart-2: 160 60% 48%;
|
||||
--chart-3: 30 80% 58%;
|
||||
--chart-4: 280 65% 63%;
|
||||
--chart-5: 340 75% 58%;
|
||||
}
|
||||
|
||||
/* ===== Reset & Base ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===== 인터랙티브 요소 트랜지션 ===== */
|
||||
button, a, input, textarea, select {
|
||||
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
/* ===== 스크롤바 ===== */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: hsl(var(--muted)); }
|
||||
::-webkit-scrollbar-thumb { background: hsl(var(--muted-foreground) / 0.3); border-radius: 5px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
|
||||
* { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--muted)); }
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ===== 검색 필터 ===== */
|
||||
.search-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-filter .field-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-filter label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-filter .actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ===== 입력 필드 ===== */
|
||||
input[type="text"], input[type="number"], input[type="date"],
|
||||
input[type="search"], select, textarea {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: hsl(var(--ring));
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.15);
|
||||
}
|
||||
|
||||
input::placeholder { color: hsl(var(--muted-foreground) / 0.6); }
|
||||
|
||||
textarea { height: auto; padding: 8px 10px; min-height: 60px; resize: vertical; }
|
||||
|
||||
/* ===== 버튼 ===== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sm { height: 30px; padding: 0 10px; font-size: 12px; }
|
||||
.btn-xs { height: 26px; padding: 0 8px; font-size: 11px; }
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 2px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: hsl(var(--muted));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.btn-destructive {
|
||||
background: hsl(var(--destructive) / 0.08);
|
||||
color: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive) / 0.2);
|
||||
}
|
||||
.btn-destructive:hover {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: none;
|
||||
}
|
||||
.btn-ghost:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn svg { width: 16px; height: 16px; }
|
||||
.btn-sm svg { width: 14px; height: 14px; }
|
||||
|
||||
/* ===== 카드 / 패널 ===== */
|
||||
.card {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ===== 뱃지 ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-count {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.badge-primary { background: hsl(var(--primary) / 0.1); color: hsl(var(--primary)); }
|
||||
.badge-success { background: hsl(var(--success) / 0.1); color: hsl(var(--success)); }
|
||||
.badge-warning { background: hsl(var(--warning) / 0.1); color: hsl(var(--warning)); }
|
||||
.badge-danger { background: hsl(var(--destructive) / 0.1); color: hsl(var(--destructive)); }
|
||||
.badge-info { background: hsl(var(--info) / 0.1); color: hsl(var(--info)); }
|
||||
.badge-muted { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
|
||||
|
||||
/* ===== 데이터 테이블 ===== */
|
||||
.data-table-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.data-table tbody tr.selected {
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
border-left: 3px solid hsl(var(--primary));
|
||||
}
|
||||
|
||||
.data-table tbody tr.selected td:first-child {
|
||||
padding-left: 9px; /* 3px border 보상 */
|
||||
}
|
||||
|
||||
/* 숫자 / 코드 셀 */
|
||||
.cell-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cell-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 체크박스 */
|
||||
.data-table input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===== 리사이즈 핸들 (좌우 분할) ===== */
|
||||
.resize-handle {
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: hsl(var(--border));
|
||||
transition: background 150ms ease;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.active {
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
border-radius: 1px;
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* ===== 탭 ===== */
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
border-bottom-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.tab-btn .tab-badge {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* ===== 모달 / 다이얼로그 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1040;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.modal-overlay.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateY(8px);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.modal-overlay.open .modal-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 다크모드 모달 그림자 강화 */
|
||||
.dark .modal-content {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-sm { width: min(480px, 92vw); }
|
||||
.modal-md { width: min(640px, 92vw); }
|
||||
.modal-lg { width: min(900px, 95vw); }
|
||||
.modal-xl { width: min(1200px, 95vw); }
|
||||
.modal-full { width: 95vw; height: 90vh; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* 닫기 버튼 */
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ===== 확인 다이얼로그 ===== */
|
||||
.confirm-dialog .modal-content {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.confirm-icon.warn {
|
||||
background: hsl(var(--warning) / 0.1);
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.confirm-icon.danger {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.confirm-desc {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ===== 빈 상태 ===== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 12px;
|
||||
border: 2px dashed hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
.empty-state .empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.empty-state .empty-desc {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground) / 0.7);
|
||||
}
|
||||
|
||||
/* ===== 폼 그리드 ===== */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
.form-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.form-grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.form-label .required {
|
||||
color: hsl(var(--destructive));
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ===== 페이지네이션 ===== */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.pagination-info { font-family: var(--font-mono); }
|
||||
|
||||
.pagination-buttons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-btn:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
|
||||
.page-btn.active { background: hsl(var(--primary)); color: white; border-color: hsl(var(--primary)); }
|
||||
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
/* ===== 상태 뱃지 (한글) ===== */
|
||||
.status-확정, .status-사용, .status-정상, .status-완료 { background: hsl(var(--success) / 0.1); color: hsl(var(--success)); }
|
||||
.status-진행중, .status-진행, .status-점검중 { background: hsl(var(--primary) / 0.1); color: hsl(var(--primary)); }
|
||||
.status-대기, .status-교정예정 { background: hsl(var(--warning) / 0.1); color: hsl(var(--warning)); }
|
||||
.status-취소, .status-미사용, .status-폐기, .status-수리중 { background: hsl(var(--destructive) / 0.1); color: hsl(var(--destructive)); }
|
||||
|
||||
/* ===== 유틸리티 ===== */
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.text-right { text-align: right; }
|
||||
.text-center { text-align: center; }
|
||||
.flex-1 { flex: 1; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ===== 테마 토글 ===== */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 9999;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ===== 프로그레스 바 ===== */
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
.progress-green { background: hsl(var(--success)); }
|
||||
.progress-yellow { background: hsl(var(--warning)); }
|
||||
.progress-red { background: hsl(var(--destructive)); }
|
||||
|
||||
/* ===== 트리뷰 ===== */
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 100ms;
|
||||
}
|
||||
.tree-node:hover { background: hsl(var(--muted) / 0.5); }
|
||||
.tree-node.selected {
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
border-left: 3px solid hsl(var(--primary));
|
||||
}
|
||||
.tree-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: transform 150ms;
|
||||
}
|
||||
.tree-toggle.expanded { transform: rotate(90deg); }
|
||||
.tree-type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ===== 애니메이션 ===== */
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideDown { from { opacity: 0; max-height: 0; } to { opacity: 1; max-height: 500px; } }
|
||||
|
||||
.animate-fadeIn { animation: fadeIn 200ms ease; }
|
||||
.animate-fadeInUp { animation: fadeInUp 200ms ease; }
|
||||
1163
docs/coding-rules/presets/erp-preset-type-a-single-table.html
Normal file
1163
docs/coding-rules/presets/erp-preset-type-a-single-table.html
Normal file
File diff suppressed because it is too large
Load Diff
1116
docs/coding-rules/presets/erp-preset-type-b-master-detail.html
Normal file
1116
docs/coding-rules/presets/erp-preset-type-b-master-detail.html
Normal file
File diff suppressed because it is too large
Load Diff
1309
docs/coding-rules/presets/erp-preset-type-c-tree-detail.html
Normal file
1309
docs/coding-rules/presets/erp-preset-type-c-tree-detail.html
Normal file
File diff suppressed because it is too large
Load Diff
1274
docs/coding-rules/presets/erp-preset-type-d-tab-multiview.html
Normal file
1274
docs/coding-rules/presets/erp-preset-type-d-tab-multiview.html
Normal file
File diff suppressed because it is too large
Load Diff
1345
docs/coding-rules/presets/erp-preset-type-e-card-list.html
Normal file
1345
docs/coding-rules/presets/erp-preset-type-e-card-list.html
Normal file
File diff suppressed because it is too large
Load Diff
1272
docs/coding-rules/presets/erp-preset-type-f-report.html
Normal file
1272
docs/coding-rules/presets/erp-preset-type-f-report.html
Normal file
File diff suppressed because it is too large
Load Diff
411
docs/coding-rules/ui-design-philosophy.mdc
Normal file
411
docs/coding-rules/ui-design-philosophy.mdc
Normal file
@@ -0,0 +1,411 @@
|
||||
# UI/UX 디자인 절대 철학 (Palantir + Toss)
|
||||
|
||||
## 핵심 철학
|
||||
|
||||
이 프로젝트의 UI는 **팔란티어의 정보 밀도**와 **토스의 사용자 중심 철학**을 결합한다.
|
||||
|
||||
### 토스 철학 (사용성)
|
||||
- **쉬운 게 맞다**: 사용자가 고민하지 않아도 되게 만들어라
|
||||
- **한 화면에 하나의 질문**: 설정을 나열하지 말고 단계별로 안내해라
|
||||
- **선택지 최소화**: 10개 옵션 대신 가장 많이 쓰는 2-3개만 보여주고 나머지는 숨겨라
|
||||
- **몰라도 되는 건 숨기기**: 고급 설정은 기본적으로 접혀있어야 한다
|
||||
- **말하듯이 설명**: 전문 용어 대신 자연스러운 한국어로 안내해라 ("Z-Index" -> "앞/뒤 순서")
|
||||
- **기본값이 최선**: 대부분의 사용자가 설정을 안 바꿔도 잘 동작해야 한다
|
||||
|
||||
### 팔란티어 철학 (정보 밀도)
|
||||
- **Dense but organized**: 정보를 빽빽하게 넣되, 시각적 계층으로 정리해라
|
||||
- **F-shaped hierarchy**: 왼쪽에서 오른쪽으로 읽는 자연스러운 흐름
|
||||
- **Composition**: 작은 원자적 조각을 조합하여 복잡한 UI를 구성해라
|
||||
- **뷰당 최대 10개 항목**: 한 섹션에 10개 이상 보이지 않게 분리해라
|
||||
|
||||
---
|
||||
|
||||
## 절대 금지 사항 (위반 시 즉시 수정)
|
||||
|
||||
### 1. 텍스트/요소 밀림 금지
|
||||
카드, 버튼, 라벨 등에서 텍스트가 의도한 위치에서 밀리거나 어긋나면 안 된다.
|
||||
- 카드 그리드에서 텍스트가 세로로 정렬되지 않는 경우 -> flex + 고정폭 또는 text-center로 해결
|
||||
- 아이콘과 텍스트가 같은 줄에 있어야 하는데 밀리는 경우 -> items-center + gap으로 해결
|
||||
|
||||
```tsx
|
||||
// 금지: 텍스트가 밀리는 카드
|
||||
<button className="flex flex-col items-start p-3">
|
||||
<Icon /><span>직접 입력</span> // 아이콘과 텍스트가 밀림
|
||||
</button>
|
||||
|
||||
// 필수: 정렬된 카드
|
||||
<button className="flex flex-col items-center justify-center p-3 text-center min-h-[80px]">
|
||||
<Icon className="h-5 w-5 mb-1.5" />
|
||||
<span className="text-xs font-medium leading-tight">직접 입력</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">옵션을 직접 추가해요</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2. 입력 폭 불일치 금지
|
||||
같은 영역에 있는 Input, Select 등 폼 컨트롤은 반드시 동일한 폭을 가져야 한다.
|
||||
- 같은 섹션의 Input이 서로 다른 너비를 가지면 안 된다
|
||||
- 나란히 배치된 필드(너비/높이 등)는 정확히 같은 폭이어야 한다
|
||||
|
||||
```tsx
|
||||
// 금지: 폭이 다른 입력 필드
|
||||
<Input className="w-[120px]" /> // Z-Index
|
||||
<Input className="w-[180px]" /> // 높이 <- 폭이 다름!
|
||||
|
||||
// 필수: 폭 일관성
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1"><Label>너비</Label><Input className="h-7 w-full" /></div>
|
||||
<div className="flex-1"><Label>높이</Label><Input className="h-7 w-full" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Z-Index</Label>
|
||||
<Input className="h-7 w-full" /> // 같은 w-full
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. 미작동 옵션 표시 금지
|
||||
실제로 동작하지 않는 설정 옵션을 사용자에게 보여주면 안 된다.
|
||||
- 기능이 구현되지 않은 옵션은 숨기거나 "준비 중" 표시
|
||||
- 특정 조건에서만 동작하는 옵션은 해당 조건이 아닐 때 비활성화(disabled) 처리
|
||||
|
||||
---
|
||||
|
||||
## 설정 패널 디자인 패턴
|
||||
|
||||
### 참조 모델 (Gold Standard)
|
||||
`V2SelectConfigPanel.tsx`가 모든 설정 패널의 기준이다.
|
||||
새 패널을 만들거나 기존 패널을 수정할 때 이 파일의 구조를 따라라.
|
||||
|
||||
```
|
||||
구조: 카드 선택 (1단계) -> 소스별 상세 (2단계) -> 고급 설정 (Collapsible)
|
||||
간격: space-y-4
|
||||
카드: grid grid-cols-2 gap-2, rounded-lg border p-3
|
||||
선택됨: border-primary bg-primary/5 ring-1 ring-primary/20
|
||||
Collapsible: 접었을 때 Badge로 상태 요약
|
||||
설명: text-[10px] text-muted-foreground
|
||||
```
|
||||
|
||||
### 섹션 헤더 패턴 (Icon + Label + Badge)
|
||||
모든 설정 섹션의 헤더는 아이콘, 제목, Badge 카운트를 포함한다.
|
||||
|
||||
```tsx
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">필드 매핑</p>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">
|
||||
{mappedCount}/{totalCount}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground pl-6">
|
||||
상위 폼의 필드 중 렉 생성에 사용할 필드를 선택해요
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
- 아이콘: `h-4 w-4 text-primary` (lucide-react)
|
||||
- 제목: `text-sm font-medium`
|
||||
- Badge: `variant="secondary" text-[10px]`, `ml-auto`로 오른쪽 정렬
|
||||
- 설명: `text-[11px] text-muted-foreground pl-6` (아이콘 너비만큼 들여쓰기)
|
||||
|
||||
### 상태 표시 카드 리스트 패턴 (CheckCircle / Circle)
|
||||
필드 매핑처럼 "설정됨/안됨"을 시각적으로 구분하는 리스트.
|
||||
|
||||
```tsx
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const isActive = !!item.value;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors",
|
||||
isActive ? "border-primary/30 bg-primary/5" : "bg-muted/30"
|
||||
)}
|
||||
>
|
||||
{isActive
|
||||
? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
: <Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium truncate">{item.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{item.description}</p>
|
||||
</div>
|
||||
<Select className="h-7 w-[120px] shrink-0 text-xs">...</Select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
- 활성 상태: `border-primary/30 bg-primary/5` + `CheckCircle2` (primary)
|
||||
- 비활성 상태: `bg-muted/30` + `Circle` (muted-foreground/40)
|
||||
- 아이콘: `shrink-0`으로 줄지 않게
|
||||
- 텍스트 블록: `min-w-0 flex-1`로 overflow 대비 + `truncate`
|
||||
- 우측 컨트롤: `shrink-0`으로 고정 폭
|
||||
|
||||
### 컴팩트 숫자 입력 그리드 패턴
|
||||
관련 숫자 입력(2~3개)은 가로 그리드로 배치한다. 세로 나열보다 공간 효율이 좋다.
|
||||
|
||||
```tsx
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
|
||||
<p className="text-[10px] text-muted-foreground">최대 조건</p>
|
||||
<Input type="number" className="h-7 text-xs text-center" value={10} />
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
|
||||
<p className="text-[10px] text-muted-foreground">최대 열</p>
|
||||
<Input type="number" className="h-7 text-xs text-center" value={99} />
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
|
||||
<p className="text-[10px] text-muted-foreground">최대 단</p>
|
||||
<Input type="number" className="h-7 text-xs text-center" value={20} />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
- 입력 2개: `grid-cols-2`, 입력 3개: `grid-cols-3`
|
||||
- 4개 이상은 세로 나열 또는 2행 그리드 사용
|
||||
- 각 셀: `rounded-lg border bg-muted/30 p-3 text-center`
|
||||
- 라벨: `text-[10px] text-muted-foreground`
|
||||
- Input: `text-center`로 숫자 중앙 정렬
|
||||
|
||||
### 카드 선택 패턴 (타입/소스 선택)
|
||||
드롭다운 대신 시각적 카드로 선택하게 한다. 사용자가 뭘 선택하는지 한눈에 보인다.
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">이 필드는 어떤 데이터를 선택하나요?</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{cards.map(card => (
|
||||
<button
|
||||
key={card.value}
|
||||
onClick={() => updateConfig("source", card.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<card.icon className="h-5 w-5 mb-1.5 text-primary" />
|
||||
<span className="text-xs font-medium leading-tight">{card.title}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**카드 필수 규칙:**
|
||||
- 모든 카드는 동일한 높이 (`min-h-[80px]`)
|
||||
- 텍스트는 center 정렬
|
||||
- 아이콘은 텍스트 위에
|
||||
- 설명은 `text-[10px] text-muted-foreground`
|
||||
- 선택된 카드: `border-primary bg-primary/5 ring-1 ring-primary/20`
|
||||
|
||||
### 고급 설정 패턴 (Progressive Disclosure)
|
||||
자주 안 쓰는 설정은 Collapsible로 숨긴다. 기본은 접혀있다.
|
||||
|
||||
```tsx
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">고급 설정</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{count > 0 && <Badge variant="secondary" className="text-[10px]">{count}개</Badge>}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-180" />
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||
{/* 고급 옵션들 */}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
```
|
||||
|
||||
**Collapsible 규칙:**
|
||||
- 펼쳐진 콘텐츠에 `max-h`나 `overflow-y-auto` 절대 금지 (펼치면 전부 보여야 함)
|
||||
- 접혀있을 때 Badge로 현재 상태 요약 (예: "3개 설정됨")
|
||||
- ChevronDown에 `[[data-state=open]>&]:rotate-180` 트랜지션
|
||||
|
||||
### 토글 옵션 패턴 (Switch + 설명)
|
||||
각 토글 옵션에 제목과 설명을 함께 보여준다. 사용자가 뭘 켜는 건지 이해할 수 있다.
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<p className="text-xs font-medium">여러 개 선택</p>
|
||||
<p className="text-[10px] text-muted-foreground">한 번에 여러 값을 선택할 수 있어요</p>
|
||||
</div>
|
||||
<Switch checked={value} onCheckedChange={onChange} />
|
||||
</div>
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
- Checkbox가 아닌 **Switch** 사용 (토스 스타일)
|
||||
- 제목: `text-xs font-medium` (Collapsible 내부) 또는 `text-sm` (독립 영역)
|
||||
- 설명: `text-[10px] text-muted-foreground` 또는 `text-[11px]`
|
||||
- 텍스트 블록: `min-w-0 flex-1 mr-3` (Switch와 겹치지 않게)
|
||||
- Switch는 오른쪽 정렬
|
||||
|
||||
### 소스별 설정 영역 패턴
|
||||
선택한 소스에 맞는 설정만 보여준다. 배경으로 구분한다.
|
||||
|
||||
```tsx
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
{/* 소스별 설정 내용 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 빈 상태 패턴
|
||||
데이터가 없을 때 친절하게 안내한다.
|
||||
|
||||
```tsx
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Icon className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">아직 옵션이 없어요</p>
|
||||
<p className="text-xs mt-0.5">위의 추가 버튼으로 옵션을 만들어보세요</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Property Row 패턴 (라벨 + 컨트롤)
|
||||
간단한 설정은 수평 배치한다.
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
||||
<Select className="h-8 w-[160px]">...</Select>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 스크롤 제한 정책 (max-h / overflow)
|
||||
|
||||
### 인라인 콘텐츠에 max-h 금지 (절대 규칙!)
|
||||
|
||||
Collapsible이 펼쳐졌을 때 콘텐츠가 스크롤에 잘려서 보이지 않으면 안 된다.
|
||||
"펼치기"의 의미는 전부 보여주는 것이다. 스크롤을 걸면 눈가리기일 뿐이다.
|
||||
|
||||
```tsx
|
||||
// 금지: 펼친 콘텐츠에 스크롤 제한
|
||||
<CollapsibleContent className="max-h-[250px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-1">
|
||||
|
||||
// 허용: 드롭다운/팝업 오버레이에만 max-h 사용
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
<PopoverContent><CommandList className="max-h-[200px]">
|
||||
```
|
||||
|
||||
**허용 대상** (팝업 오버레이):
|
||||
- `CommandGroup`, `CommandList` (Combobox 드롭다운)
|
||||
- `SelectContent` (Select 드롭다운)
|
||||
- `PopoverContent` 내부 리스트
|
||||
|
||||
**금지 대상** (인라인 콘텐츠):
|
||||
- `CollapsibleContent` 내부
|
||||
- 일반 `div` 안의 리스트
|
||||
- 설정 패널 본문의 아이템 목록
|
||||
|
||||
**대안**: Collapsible 접기/펴기로 영역을 줄이되, 펼쳤을 때는 전체를 보여준다.
|
||||
|
||||
---
|
||||
|
||||
## 텍스트 오버플로우 처리 (필수!)
|
||||
|
||||
모든 동적 텍스트(필드명, 테이블명, 컬럼명 등)에 overflow 방지 처리가 필수.
|
||||
|
||||
```tsx
|
||||
// 필수: 모든 동적 텍스트에 truncate
|
||||
<p className="text-xs font-medium truncate">{fieldName}</p>
|
||||
|
||||
// 필수: flex 안의 텍스트 블록에 min-w-0
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate">{longText}</p>
|
||||
</div>
|
||||
<Switch /> {/* 고정 폭 컨트롤 */}
|
||||
</div>
|
||||
|
||||
// 필수: Switch/Select 옆의 설명 블록
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<p className="text-xs font-medium">라벨</p>
|
||||
<p className="text-[10px] text-muted-foreground">설명</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 규칙:**
|
||||
- `truncate` 단독 사용은 부모에 `min-w-0`이 없으면 무의미
|
||||
- flex 컨테이너의 자식 중 텍스트 영역: `min-w-0 flex-1`
|
||||
- 고정 크기 컨트롤 (Switch, Button, Select): `shrink-0`
|
||||
|
||||
---
|
||||
|
||||
## 컨트롤 사이즈 표준
|
||||
|
||||
| 컨텍스트 | 높이 | 텍스트 |
|
||||
|---------|------|--------|
|
||||
| 설정 패널 내부 | `h-7` (28px) 또는 `h-8` (32px) | `text-xs` 또는 `text-sm` |
|
||||
| 모달/폼 | `h-8` (32px) 또는 `h-10` (40px) | `text-sm` |
|
||||
| 메인 화면 | `h-10` (40px) | `text-sm` |
|
||||
|
||||
설정 패널은 공간이 좁으므로 `h-7` ~ `h-8` 사용.
|
||||
|
||||
---
|
||||
|
||||
## 설명 텍스트 톤앤매너
|
||||
|
||||
- **~해요** 체 사용 (토스 스타일)
|
||||
- 짧고 명확하게
|
||||
- 전문 용어 피하기
|
||||
|
||||
```
|
||||
// 좋은 예
|
||||
"옵션이 많을 때 검색으로 찾을 수 있어요"
|
||||
"한 번에 여러 값을 선택할 수 있어요"
|
||||
"선택한 값을 지울 수 있는 X 버튼이 표시돼요"
|
||||
|
||||
// 나쁜 예
|
||||
"Searchable 모드를 활성화합니다"
|
||||
"Multiple selection을 허용합니다"
|
||||
"allowClear 옵션을 설정합니다"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 색상 규칙
|
||||
|
||||
- 선택/활성 강조: `border-primary bg-primary/5 ring-1 ring-primary/20`
|
||||
- 비활성 배경: `bg-muted/30`
|
||||
- 정보 텍스트: `text-muted-foreground`
|
||||
- 경고: `text-amber-600`
|
||||
- 성공: `text-emerald-600`
|
||||
- CSS 변수 필수, 하드코딩 색상 금지
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트 (UI 작업 완료 시 확인)
|
||||
|
||||
- [ ] 같은 영역의 Input/Select 폭이 일치하는가?
|
||||
- [ ] 카드/버튼의 텍스트가 밀리지 않고 정렬되어 있는가?
|
||||
- [ ] 미작동 옵션이 표시되고 있지는 않은가?
|
||||
- [ ] 고급 설정이 기본으로 접혀있는가?
|
||||
- [ ] Switch에 설명 텍스트가 있는가?
|
||||
- [ ] 빈 상태에 안내 메시지가 있는가?
|
||||
- [ ] 전문 용어 대신 쉬운 한국어를 사용했는가?
|
||||
- [ ] 다크 모드에서 정상적으로 보이는가?
|
||||
- [ ] 섹션 헤더에 아이콘 + Badge 카운트가 있는가?
|
||||
- [ ] 동적 텍스트에 `truncate` + 부모에 `min-w-0`이 있는가?
|
||||
- [ ] 인라인 콘텐츠에 `max-h overflow-y-auto`를 사용하지 않았는가?
|
||||
- [ ] Switch/Select 옆 텍스트 블록에 `min-w-0 flex-1 mr-3`이 있는가?
|
||||
Reference in New Issue
Block a user