feat: TK 안전관리 플랫폼 초기 구현
위험성평가, 안전 RAG Q&A, 안전점검 체크리스트를 통합한 안전관리자 전용 웹 플랫폼 전체 구현. - Next.js 15 (App Router) + TypeScript + Tailwind + shadcn/ui - Drizzle ORM + PostgreSQL 16 (12개 테이블) - 위험성평가 CRUD + 5x5 위험성 매트릭스 + 인쇄 내보내기 - 체크리스트 템플릿/점검/NCR 추적 - RAG 문서 파이프라인 (Tika + bge-m3 + Qdrant) - SSE 스트리밍 RAG 채팅 (qwen3.5:35b-a3b) - AI 어시스트 (위험요인 추천, 감소대책, 점검항목 생성) - 대시보드 통계/차트 (recharts) - 단일 사용자 인증 (HMAC 쿠키 세션) - 다크모드 지원 - Docker 멀티스테이지 빌드 (standalone) - 프로젝트 가이드 문서 (docs/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
uploads
|
||||
drizzle/migrations
|
||||
*.md
|
||||
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Database
|
||||
DATABASE_URL=postgres://tksafety:tksafety_password@localhost:5433/tksafety
|
||||
|
||||
# Auth (single user)
|
||||
ADMIN_PASSWORD=changeme
|
||||
SESSION_SECRET=generate-a-random-32-char-string-here
|
||||
|
||||
# AI - Ollama (맥미니 로컬)
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
OLLAMA_MODEL=qwen3.5:35b-a3b
|
||||
|
||||
# AI - Embeddings (조립컴 GPU)
|
||||
EMBEDDING_BASE_URL=http://100.111.160.84:11434
|
||||
EMBEDDING_MODEL=bge-m3
|
||||
|
||||
# Qdrant (맥미니 로컬)
|
||||
QDRANT_URL=http://host.docker.internal:6333
|
||||
QDRANT_COLLECTION=safety-docs
|
||||
|
||||
# Tika (맥미니 로컬)
|
||||
TIKA_URL=http://host.docker.internal:9998
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3100
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -30,8 +30,13 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# env files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# uploads
|
||||
/uploads/*
|
||||
!/uploads/.gitkeep
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# --- deps ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# --- build ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# --- runner ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
RUN mkdir -p uploads && chown nextjs:nodejs uploads
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
23
components.json
Normal file
23
components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: tk-safety
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgres://tksafety:tksafety_password@db:5432/tksafety
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
|
||||
- SESSION_SECRET=${SESSION_SECRET:-tk-safety-session-secret-change-me}
|
||||
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
- OLLAMA_MODEL=qwen3.5:35b-a3b
|
||||
- EMBEDDING_BASE_URL=http://100.111.160.84:11434
|
||||
- EMBEDDING_MODEL=bge-m3
|
||||
- QDRANT_URL=http://host.docker.internal:6333
|
||||
- QDRANT_COLLECTION=safety-docs
|
||||
- TIKA_URL=http://host.docker.internal:9998
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: tk-safety-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=tksafety
|
||||
- POSTGRES_PASSWORD=tksafety_password
|
||||
- POSTGRES_DB=tksafety
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tksafety"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
107
docs/architecture.md
Normal file
107
docs/architecture.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# TK 안전관리 플랫폼 - 아키텍처 가이드
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 분류 | 기술 |
|
||||
|------|------|
|
||||
| 프레임워크 | Next.js 15 (App Router, TypeScript, Server Actions) |
|
||||
| UI | Tailwind CSS + shadcn/ui + Lucide icons + recharts |
|
||||
| ORM | Drizzle ORM |
|
||||
| DB | PostgreSQL 16 (컨테이너 `tk-safety-db`) |
|
||||
| AI 생성 | 맥미니 Ollama qwen3.5:35b-a3b |
|
||||
| AI 임베딩 | 조립컴 Ollama bge-m3 (1024d) |
|
||||
| 벡터DB | Qdrant (컬렉션 `safety-docs`) |
|
||||
| OCR | Apache Tika (port 9998) |
|
||||
| 인증 | 단일 사용자 (환경변수 비밀번호 + HMAC 쿠키 세션) |
|
||||
|
||||
## 포트 매핑
|
||||
|
||||
| 서비스 | 호스트:컨테이너 |
|
||||
|--------|------------------|
|
||||
| tk-safety (Next.js) | 3100:3000 |
|
||||
| tk-safety-db (PostgreSQL) | 127.0.0.1:5433:5432 |
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
tk-safety/
|
||||
├── docker-compose.yml # 서비스 정의
|
||||
├── Dockerfile # multi-stage 빌드 (standalone)
|
||||
├── .env / .env.example # 환경변수
|
||||
├── drizzle.config.ts # Drizzle ORM 설정
|
||||
├── drizzle/migrations/ # DB 마이그레이션 (자동 생성)
|
||||
├── uploads/ # 업로드 파일 (documents, photos)
|
||||
├── docs/ # 프로젝트 가이드 문서
|
||||
└── src/
|
||||
├── app/
|
||||
│ ├── layout.tsx # 루트 레이아웃 (다크모드 초기화)
|
||||
│ ├── page.tsx # 인증 리다이렉트
|
||||
│ ├── login/page.tsx # 로그인 페이지
|
||||
│ ├── middleware.ts # 인증 미들웨어
|
||||
│ ├── (authenticated)/ # 인증 필요 라우트 그룹
|
||||
│ │ ├── layout.tsx # 사이드바 + 헤더
|
||||
│ │ ├── dashboard/ # 대시보드
|
||||
│ │ ├── risk-assessment/ # 위험성평가
|
||||
│ │ ├── rag/ # 안전 Q&A
|
||||
│ │ ├── checklists/ # 점검 체크리스트
|
||||
│ │ └── settings/ # 설정
|
||||
│ └── api/
|
||||
│ ├── auth/ # 로그인/로그아웃 API
|
||||
│ ├── ai/ # AI 엔드포인트 (SSE 스트리밍)
|
||||
│ └── health/ # 서비스 헬스체크
|
||||
├── actions/ # Server Actions (CRUD)
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui 컴포넌트
|
||||
│ ├── layout/ # 사이드바, 헤더, 모바일 내비
|
||||
│ ├── dashboard/ # 통계, 차트, 퀵액션
|
||||
│ ├── risk-assessment/ # 평가 폼, 매트릭스, AI 패널
|
||||
│ ├── rag/ # 채팅, 문서 관리
|
||||
│ └── checklists/ # 템플릿, 점검, NCR
|
||||
├── lib/
|
||||
│ ├── db/ # schema.ts, index.ts
|
||||
│ ├── ai/ # ollama, qdrant, tika, embeddings, prompts
|
||||
│ ├── auth.ts # 인증 유틸
|
||||
│ ├── constants.ts # 상수 정의
|
||||
│ └── utils.ts # shadcn 유틸
|
||||
├── hooks/ # use-streaming.ts (SSE)
|
||||
└── types/ # 타입 정의
|
||||
```
|
||||
|
||||
## DB 스키마 (12개 테이블)
|
||||
|
||||
### 위험성평가
|
||||
- `risk_assessments` — 평가 마스터 (제목, 상태, 버전)
|
||||
- `risk_assessment_hazards` — 유해위험요인 (S×L=위험성)
|
||||
|
||||
### RAG 문서
|
||||
- `rag_documents` — 업로드 문서 (처리 상태 포함)
|
||||
- `rag_chunks` — 문서 청크 (Qdrant pointId 매핑)
|
||||
- `rag_conversations` — 대화 세션
|
||||
- `rag_messages` — 메시지 (질문/답변 + 출처)
|
||||
|
||||
### 체크리스트
|
||||
- `checklist_templates` — 점검 템플릿
|
||||
- `checklist_template_items` — 템플릿 항목
|
||||
- `inspections` — 점검 실행 기록
|
||||
- `inspection_results` — 항목별 결과
|
||||
- `non_conformances` — 부적합 사항 (NCR)
|
||||
|
||||
### 기타
|
||||
- `app_settings` — 앱 설정 (key-value)
|
||||
|
||||
## AI 연동 흐름
|
||||
|
||||
### RAG Q&A
|
||||
```
|
||||
질문 → bge-m3 임베딩(조립컴) → Qdrant 검색(top-5) → 컨텍스트 + 질문 → qwen3.5 생성(맥미니) → SSE 스트리밍
|
||||
```
|
||||
|
||||
### AI 위험요인 추천
|
||||
```
|
||||
작업 설명 → bge-m3 임베딩 → Qdrant 검색(top-3) → 컨텍스트 + 프롬프트 → qwen3.5 JSON 생성 → 구조화 응답
|
||||
```
|
||||
|
||||
### 문서 처리 파이프라인
|
||||
```
|
||||
파일 업로드 → Tika 텍스트 추출 → 1500자/200 overlap 청킹 → bge-m3 배치 임베딩(10개씩) → Qdrant 저장
|
||||
```
|
||||
107
docs/deployment.md
Normal file
107
docs/deployment.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# TK 안전관리 - 배포 가이드
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
맥미니 서버 (192.168.1.122)에 다음 서비스가 실행 중이어야 합니다:
|
||||
- Ollama (qwen3.5:35b-a3b) — port 11434
|
||||
- Qdrant — port 6333
|
||||
- Apache Tika — port 9998
|
||||
- Docker Desktop
|
||||
|
||||
조립컴 (192.168.1.186 / Tailscale 100.111.160.84):
|
||||
- Ollama (bge-m3) — port 11434
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1. 환경변수 설정
|
||||
```bash
|
||||
cd ~/docker/tk-safety
|
||||
cp .env.example .env
|
||||
# .env 파일에서 ADMIN_PASSWORD, SESSION_SECRET 변경
|
||||
```
|
||||
|
||||
### 2. Docker 빌드 및 실행
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 3. DB 마이그레이션
|
||||
```bash
|
||||
# 로컬에서 마이그레이션 생성
|
||||
npx drizzle-kit generate
|
||||
|
||||
# 마이그레이션 적용
|
||||
npx drizzle-kit push
|
||||
```
|
||||
|
||||
또는 DB URL을 직접 지정:
|
||||
```bash
|
||||
DATABASE_URL=postgres://tksafety:tksafety_password@localhost:5433/tksafety npx drizzle-kit push
|
||||
```
|
||||
|
||||
### 4. 접속 확인
|
||||
- http://192.168.1.122:3100
|
||||
- 설정한 비밀번호로 로그인
|
||||
|
||||
## 환경변수
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|------|------|--------|
|
||||
| DATABASE_URL | PostgreSQL 연결 문자열 | (필수) |
|
||||
| ADMIN_PASSWORD | 로그인 비밀번호 | changeme |
|
||||
| SESSION_SECRET | 세션 서명 키 (32자 이상) | (필수) |
|
||||
| OLLAMA_BASE_URL | Ollama 생성 AI URL | http://host.docker.internal:11434 |
|
||||
| OLLAMA_MODEL | 생성 모델명 | qwen3.5:35b-a3b |
|
||||
| EMBEDDING_BASE_URL | 임베딩 서버 URL | http://100.111.160.84:11434 |
|
||||
| EMBEDDING_MODEL | 임베딩 모델명 | bge-m3 |
|
||||
| QDRANT_URL | Qdrant URL | http://host.docker.internal:6333 |
|
||||
| QDRANT_COLLECTION | Qdrant 컬렉션명 | safety-docs |
|
||||
| TIKA_URL | Tika URL | http://host.docker.internal:9998 |
|
||||
|
||||
## 업데이트
|
||||
|
||||
```bash
|
||||
cd ~/docker/tk-safety
|
||||
git pull
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## 데이터 백업
|
||||
|
||||
### PostgreSQL
|
||||
```bash
|
||||
docker exec tk-safety-db pg_dump -U tksafety tksafety > backup.sql
|
||||
```
|
||||
|
||||
### 복원
|
||||
```bash
|
||||
docker exec -i tk-safety-db psql -U tksafety tksafety < backup.sql
|
||||
```
|
||||
|
||||
### 업로드 파일
|
||||
`~/docker/tk-safety/uploads/` 디렉토리를 백업하세요.
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### DB 연결 실패
|
||||
```bash
|
||||
# DB 컨테이너 상태 확인
|
||||
docker logs tk-safety-db
|
||||
|
||||
# DB 직접 접속
|
||||
docker exec -it tk-safety-db psql -U tksafety tksafety
|
||||
```
|
||||
|
||||
### AI 서비스 연결 실패
|
||||
설정 페이지 (Settings)에서 서비스 연결 상태를 확인하세요.
|
||||
- Ollama: `curl http://localhost:11434/api/tags`
|
||||
- Qdrant: `curl http://localhost:6333/collections`
|
||||
- Tika: `curl http://localhost:9998/tika`
|
||||
|
||||
### 빌드 실패
|
||||
```bash
|
||||
# 캐시 정리 후 재빌드
|
||||
docker-compose down
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
```
|
||||
80
docs/development.md
Normal file
80
docs/development.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# TK 안전관리 - 개발 가이드
|
||||
|
||||
## 로컬 개발 환경 설정
|
||||
|
||||
### 1. 의존성 설치
|
||||
```bash
|
||||
cd ~/docker/tk-safety
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. DB 실행 (Docker)
|
||||
```bash
|
||||
docker-compose up db -d
|
||||
```
|
||||
|
||||
### 3. DB 마이그레이션
|
||||
```bash
|
||||
npx drizzle-kit push
|
||||
```
|
||||
|
||||
### 4. 개발 서버 실행
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
→ http://localhost:3000
|
||||
|
||||
## 주요 패턴
|
||||
|
||||
### Server Actions
|
||||
모든 CRUD 작업은 `src/actions/` 디렉토리의 Server Actions로 구현됩니다.
|
||||
- `risk-assessment.ts` — 위험성평가 + 유해위험요인
|
||||
- `checklists.ts` — 템플릿 + 점검 + NCR
|
||||
- `rag.ts` — 문서 업로드/처리 + 대화
|
||||
|
||||
### AI API 라우트
|
||||
SSE 스트리밍이 필요한 AI 기능은 `src/app/api/ai/` 라우트로 구현됩니다.
|
||||
- `chat/route.ts` — RAG Q&A (SSE)
|
||||
- `suggest-hazards/route.ts` — 위험요인 추천 (JSON)
|
||||
- `suggest-controls/route.ts` — 감소대책 제안 (SSE)
|
||||
- `suggest-checklist/route.ts` — 점검항목 생성 (JSON)
|
||||
|
||||
### SSE 스트리밍
|
||||
클라이언트에서는 `src/hooks/use-streaming.ts` 훅을 사용합니다.
|
||||
```tsx
|
||||
const { isStreaming, content, sources, error, streamChat, reset } = useStreaming();
|
||||
await streamChat("/api/ai/chat", { question, conversationId });
|
||||
```
|
||||
|
||||
### 인증 시스템
|
||||
- HMAC 기반 쿠키 세션 (7일 유효)
|
||||
- `src/lib/auth.ts` — 세션 생성/검증
|
||||
- `src/middleware.ts` — 라우트 보호
|
||||
- `src/app/(authenticated)/layout.tsx` — 서버사이드 인증 확인
|
||||
|
||||
## DB 스키마 수정
|
||||
|
||||
1. `src/lib/db/schema.ts` 수정
|
||||
2. 마이그레이션 생성: `npx drizzle-kit generate`
|
||||
3. 마이그레이션 적용: `npx drizzle-kit push`
|
||||
|
||||
## 새 컴포넌트 추가
|
||||
|
||||
### shadcn/ui 컴포넌트
|
||||
```bash
|
||||
npx shadcn@latest add [component-name]
|
||||
```
|
||||
|
||||
### 커스텀 컴포넌트
|
||||
기능별 디렉토리에 배치:
|
||||
- `src/components/risk-assessment/` — 위험성평가 관련
|
||||
- `src/components/rag/` — RAG/채팅 관련
|
||||
- `src/components/checklists/` — 체크리스트 관련
|
||||
- `src/components/dashboard/` — 대시보드 관련
|
||||
|
||||
## 코드 컨벤션
|
||||
|
||||
- TypeScript strict 모드
|
||||
- Drizzle ORM 타입 추론 (`InferSelectModel<typeof table>`)
|
||||
- Server Actions에서 `revalidatePath()` 호출
|
||||
- 한국어 UI 텍스트 (상수는 `src/lib/constants.ts`에 정의)
|
||||
71
docs/features.md
Normal file
71
docs/features.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# TK 안전관리 - 기능 가이드
|
||||
|
||||
## 1. 위험성평가 (Risk Assessment)
|
||||
|
||||
### 평가 작성
|
||||
1. 대시보드 또는 위험성평가 페이지에서 "새 평가" 클릭
|
||||
2. 기본 정보 입력 (제목, 부서, 장소, 평가자)
|
||||
3. 평가 생성 후 유해위험요인 추가
|
||||
|
||||
### 유해위험요인 등록
|
||||
- **수동 입력**: "위험요인 추가" 버튼 → 분류, 작업, 위험요인, 중대성(1-5) × 가능성(1-5)
|
||||
- **AI 추천**: "AI 추천" 버튼 → 작업 설명 입력 → AI가 위험요인 분석 → "추가" 클릭
|
||||
|
||||
### 위험성 매트릭스
|
||||
- 5×5 매트릭스 자동 시각화
|
||||
- 위험등급: 저위험(1-4), 보통(5-9), 고위험(10-16), 매우위험(17-25)
|
||||
|
||||
### AI 감소대책 제안
|
||||
- 각 위험요인 행의 "대책 제안" 클릭
|
||||
- 제거 → 대체 → 공학적 → 관리적 → PPE 계층별 제안
|
||||
|
||||
### 상태 워크플로
|
||||
초안 → 검토 → 승인 → 보관
|
||||
|
||||
### 인쇄/내보내기
|
||||
- "인쇄" 버튼으로 고용노동부 양식 기반 인쇄 페이지 열기
|
||||
- 브라우저 인쇄(Ctrl+P)로 PDF 저장 가능
|
||||
|
||||
## 2. 안전 Q&A (RAG)
|
||||
|
||||
### 문서 업로드
|
||||
1. "문서 관리" 탭 선택
|
||||
2. 카테고리 선택 (산업안전보건법, KOSHA 가이드, 사내규정, 안전작업절차서, 기타)
|
||||
3. 파일 선택 (PDF, HWP, DOCX 등)
|
||||
4. 자동 처리: Tika 추출 → 청킹 → 임베딩 → Qdrant 저장
|
||||
|
||||
### 채팅
|
||||
1. "채팅" 탭에서 "새 대화" 클릭
|
||||
2. 질문 입력 (Enter 전송, Shift+Enter 줄바꿈)
|
||||
3. AI가 업로드된 문서 기반으로 답변 (SSE 스트리밍)
|
||||
4. 출처 배지로 참고 문서 확인
|
||||
|
||||
## 3. 점검 체크리스트
|
||||
|
||||
### 템플릿 관리
|
||||
1. "새 템플릿" 클릭 → 이름, 유형(일상/정기/특별/장비), 설명 입력
|
||||
2. 점검 항목 추가 (수동 또는 AI 생성)
|
||||
3. **AI 항목 생성**: "AI 항목 생성" → 작업환경 설명 → AI가 항목 제안 → 개별 또는 전체 추가
|
||||
|
||||
### 점검 실행
|
||||
1. "점검 실행" → 템플릿 선택, 점검자, 장소 입력
|
||||
2. 각 항목별 합격/불합격/해당없음 선택 + 메모
|
||||
3. 불합격 항목에서 NCR(부적합) 등록 가능
|
||||
4. "점검 완료" 클릭
|
||||
|
||||
### 부적합 사항 (NCR) 추적
|
||||
- 상태 흐름: 미조치 → 시정조치 중 → 확인 중 → 종결
|
||||
- "부적합" 탭에서 전체 NCR 현황 관리
|
||||
|
||||
## 4. 대시보드
|
||||
|
||||
- **통계 카드**: 총 평가 수, 점검 수, 미조치 NCR, RAG 문서 수
|
||||
- **위험등급 분포 차트**: 전체 위험요인의 등급별 분포 (막대 차트)
|
||||
- **부적합 현황 차트**: NCR 상태별 분포 (파이 차트)
|
||||
- **빠른 실행**: 새 위험성평가, 안전 Q&A, 점검 실행 바로가기
|
||||
|
||||
## 5. 설정
|
||||
|
||||
- **테마**: 라이트/다크 모드 전환
|
||||
- **서비스 연결 상태**: Ollama, Embedding, Qdrant, Tika 연결 확인
|
||||
- **시스템 정보**: 버전, 프레임워크, 모델 정보
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
out: "./drizzle/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
6818
package-lock.json
generated
6818
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -9,18 +9,34 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"next": "16.1.6",
|
||||
"postgres": "^3.4.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
224
src/actions/checklists.ts
Normal file
224
src/actions/checklists.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
checklistTemplates,
|
||||
checklistTemplateItems,
|
||||
inspections,
|
||||
inspectionResults,
|
||||
nonConformances,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, desc, asc } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
// ─── Templates ───
|
||||
|
||||
export async function getTemplates() {
|
||||
return db.query.checklistTemplates.findMany({
|
||||
orderBy: [desc(checklistTemplates.createdAt)],
|
||||
with: { items: { orderBy: [asc(checklistTemplateItems.sortOrder)] } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTemplate(id: string) {
|
||||
return db.query.checklistTemplates.findFirst({
|
||||
where: eq(checklistTemplates.id, id),
|
||||
with: { items: { orderBy: [asc(checklistTemplateItems.sortOrder)] } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTemplate(data: {
|
||||
name: string;
|
||||
type: "daily" | "regular" | "special" | "equipment";
|
||||
description?: string;
|
||||
}) {
|
||||
const [result] = await db
|
||||
.insert(checklistTemplates)
|
||||
.values(data)
|
||||
.returning();
|
||||
revalidatePath("/checklists");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateTemplate(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
type: "daily" | "regular" | "special" | "equipment";
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}>
|
||||
) {
|
||||
const [result] = await db
|
||||
.update(checklistTemplates)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(checklistTemplates.id, id))
|
||||
.returning();
|
||||
revalidatePath("/checklists");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string) {
|
||||
await db.delete(checklistTemplates).where(eq(checklistTemplates.id, id));
|
||||
revalidatePath("/checklists");
|
||||
}
|
||||
|
||||
export async function addTemplateItem(
|
||||
templateId: string,
|
||||
data: { content: string; category?: string; standard?: string }
|
||||
) {
|
||||
const existing = await db.query.checklistTemplateItems.findMany({
|
||||
where: eq(checklistTemplateItems.templateId, templateId),
|
||||
columns: { sortOrder: true },
|
||||
});
|
||||
const maxOrder = existing.reduce((max, i) => Math.max(max, i.sortOrder), -1);
|
||||
|
||||
const [result] = await db
|
||||
.insert(checklistTemplateItems)
|
||||
.values({ templateId, sortOrder: maxOrder + 1, ...data })
|
||||
.returning();
|
||||
revalidatePath("/checklists");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateTemplateItem(
|
||||
itemId: string,
|
||||
data: Partial<{ content: string; category: string; standard: string; sortOrder: number }>
|
||||
) {
|
||||
const [result] = await db
|
||||
.update(checklistTemplateItems)
|
||||
.set(data)
|
||||
.where(eq(checklistTemplateItems.id, itemId))
|
||||
.returning();
|
||||
revalidatePath("/checklists");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteTemplateItem(itemId: string) {
|
||||
await db
|
||||
.delete(checklistTemplateItems)
|
||||
.where(eq(checklistTemplateItems.id, itemId));
|
||||
revalidatePath("/checklists");
|
||||
}
|
||||
|
||||
// ─── Inspections ───
|
||||
|
||||
export async function getInspections() {
|
||||
return db.query.inspections.findMany({
|
||||
orderBy: [desc(inspections.createdAt)],
|
||||
with: {
|
||||
template: true,
|
||||
results: true,
|
||||
nonConformances: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInspection(id: string) {
|
||||
return db.query.inspections.findFirst({
|
||||
where: eq(inspections.id, id),
|
||||
with: {
|
||||
template: { with: { items: { orderBy: [asc(checklistTemplateItems.sortOrder)] } } },
|
||||
results: true,
|
||||
nonConformances: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInspection(data: {
|
||||
templateId: string;
|
||||
inspector?: string;
|
||||
location?: string;
|
||||
}) {
|
||||
const [result] = await db.insert(inspections).values(data).returning();
|
||||
revalidatePath("/checklists/inspections");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveInspectionResults(
|
||||
inspectionId: string,
|
||||
results: Array<{
|
||||
templateItemId: string;
|
||||
result: string;
|
||||
memo?: string;
|
||||
}>
|
||||
) {
|
||||
// Delete existing results for this inspection
|
||||
await db
|
||||
.delete(inspectionResults)
|
||||
.where(eq(inspectionResults.inspectionId, inspectionId));
|
||||
|
||||
if (results.length > 0) {
|
||||
await db.insert(inspectionResults).values(
|
||||
results.map((r) => ({
|
||||
inspectionId,
|
||||
templateItemId: r.templateItemId,
|
||||
result: r.result,
|
||||
memo: r.memo,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
revalidatePath(`/checklists/inspections/${inspectionId}`);
|
||||
}
|
||||
|
||||
export async function completeInspection(inspectionId: string) {
|
||||
await db
|
||||
.update(inspections)
|
||||
.set({ completedAt: new Date() })
|
||||
.where(eq(inspections.id, inspectionId));
|
||||
revalidatePath("/checklists/inspections");
|
||||
}
|
||||
|
||||
// ─── Non-conformances ───
|
||||
|
||||
export async function getNonConformances() {
|
||||
return db.query.nonConformances.findMany({
|
||||
orderBy: [desc(nonConformances.createdAt)],
|
||||
with: { inspection: { with: { template: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNonConformance(data: {
|
||||
inspectionId: string;
|
||||
inspectionResultId?: string;
|
||||
description: string;
|
||||
severity?: string;
|
||||
responsiblePerson?: string;
|
||||
dueDate?: string;
|
||||
}) {
|
||||
const [result] = await db
|
||||
.insert(nonConformances)
|
||||
.values({
|
||||
...data,
|
||||
dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
|
||||
})
|
||||
.returning();
|
||||
revalidatePath("/checklists");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateNonConformance(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
status: "open" | "corrective_action" | "verification" | "closed";
|
||||
correctiveAction: string;
|
||||
responsiblePerson: string;
|
||||
closedBy: string;
|
||||
}>
|
||||
) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (data.status === "closed") {
|
||||
updateData.closedAt = new Date();
|
||||
}
|
||||
const [result] = await db
|
||||
.update(nonConformances)
|
||||
.set(updateData)
|
||||
.where(eq(nonConformances.id, id))
|
||||
.returning();
|
||||
revalidatePath("/checklists");
|
||||
return result;
|
||||
}
|
||||
212
src/actions/rag.ts
Normal file
212
src/actions/rag.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
ragDocuments,
|
||||
ragChunks,
|
||||
ragConversations,
|
||||
ragMessages,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { extractText } from "@/lib/ai/tika";
|
||||
import { chunkText } from "@/lib/ai/chunking";
|
||||
import { getEmbeddings } from "@/lib/ai/embeddings";
|
||||
import { ensureCollection, upsertPoints, deletePointsByFilter } from "@/lib/ai/qdrant";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// ─── Documents ───
|
||||
|
||||
export async function getDocuments() {
|
||||
return db.query.ragDocuments.findMany({
|
||||
orderBy: [desc(ragDocuments.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadDocument(formData: FormData) {
|
||||
const file = formData.get("file") as File;
|
||||
const category = (formData.get("category") as string) || "other";
|
||||
|
||||
if (!file) throw new Error("No file provided");
|
||||
|
||||
// Save file to uploads directory
|
||||
const ext = path.extname(file.name);
|
||||
const filename = `${uuidv4()}${ext}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads", "documents");
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Create DB record
|
||||
const [doc] = await db
|
||||
.insert(ragDocuments)
|
||||
.values({
|
||||
filename,
|
||||
originalName: file.name,
|
||||
category: category as "law" | "kosha_guide" | "internal" | "procedure" | "other",
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath("/rag");
|
||||
|
||||
// Process in background (non-blocking)
|
||||
processDocument(doc.id, buffer, file.type).catch(async (err) => {
|
||||
console.error(`Document processing failed for ${doc.id}:`, err);
|
||||
await db
|
||||
.update(ragDocuments)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(ragDocuments.id, doc.id));
|
||||
});
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function processDocument(
|
||||
docId: string,
|
||||
buffer: Buffer,
|
||||
mimeType: string
|
||||
) {
|
||||
// Update status to processing
|
||||
await db
|
||||
.update(ragDocuments)
|
||||
.set({ status: "processing", updatedAt: new Date() })
|
||||
.where(eq(ragDocuments.id, docId));
|
||||
|
||||
// Extract text via Tika
|
||||
const text = await extractText(new Uint8Array(buffer), mimeType);
|
||||
if (!text || text.trim().length < 10) {
|
||||
throw new Error("텍스트 추출 실패: 내용이 비어있습니다.");
|
||||
}
|
||||
|
||||
// Chunk text
|
||||
const chunks = chunkText(text);
|
||||
|
||||
// Ensure Qdrant collection exists
|
||||
await ensureCollection(1024); // bge-m3 dimension
|
||||
|
||||
// Get embeddings in batches
|
||||
const embeddings = await getEmbeddings(chunks);
|
||||
|
||||
// Save chunks to DB and Qdrant
|
||||
const points = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunkId = uuidv4();
|
||||
await db.insert(ragChunks).values({
|
||||
id: chunkId,
|
||||
documentId: docId,
|
||||
chunkIndex: i,
|
||||
content: chunks[i],
|
||||
qdrantPointId: chunkId,
|
||||
metadata: { pageEstimate: Math.floor(i / 3) + 1 },
|
||||
});
|
||||
|
||||
points.push({
|
||||
id: chunkId,
|
||||
vector: embeddings[i],
|
||||
payload: {
|
||||
documentId: docId,
|
||||
chunkIndex: i,
|
||||
content: chunks[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Upsert to Qdrant in batches of 50
|
||||
for (let i = 0; i < points.length; i += 50) {
|
||||
await upsertPoints(points.slice(i, i + 50));
|
||||
}
|
||||
|
||||
// Update document status
|
||||
await db
|
||||
.update(ragDocuments)
|
||||
.set({
|
||||
status: "completed",
|
||||
chunkCount: chunks.length,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(ragDocuments.id, docId));
|
||||
}
|
||||
|
||||
export async function deleteDocument(docId: string) {
|
||||
// Delete from Qdrant
|
||||
try {
|
||||
await deletePointsByFilter({
|
||||
must: [{ key: "documentId", match: { value: docId } }],
|
||||
});
|
||||
} catch {
|
||||
// Qdrant might not be available
|
||||
}
|
||||
|
||||
// Delete from DB (cascades to chunks)
|
||||
await db.delete(ragDocuments).where(eq(ragDocuments.id, docId));
|
||||
revalidatePath("/rag");
|
||||
}
|
||||
|
||||
// ─── Conversations ───
|
||||
|
||||
export async function getConversations() {
|
||||
return db.query.ragConversations.findMany({
|
||||
orderBy: [desc(ragConversations.updatedAt)],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConversation(id: string) {
|
||||
return db.query.ragConversations.findFirst({
|
||||
where: eq(ragConversations.id, id),
|
||||
with: { messages: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createConversation() {
|
||||
const [result] = await db
|
||||
.insert(ragConversations)
|
||||
.values({ title: "새 대화" })
|
||||
.returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveMessage(
|
||||
conversationId: string,
|
||||
role: string,
|
||||
content: string,
|
||||
sources?: unknown
|
||||
) {
|
||||
const [result] = await db
|
||||
.insert(ragMessages)
|
||||
.values({ conversationId, role, content, sources })
|
||||
.returning();
|
||||
|
||||
// Update conversation timestamp and title for first user message
|
||||
if (role === "user") {
|
||||
const conv = await db.query.ragConversations.findFirst({
|
||||
where: eq(ragConversations.id, conversationId),
|
||||
with: { messages: true },
|
||||
});
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (conv && conv.messages.length <= 2) {
|
||||
update.title = content.slice(0, 100);
|
||||
}
|
||||
await db
|
||||
.update(ragConversations)
|
||||
.set(update)
|
||||
.where(eq(ragConversations.id, conversationId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string) {
|
||||
await db.delete(ragConversations).where(eq(ragConversations.id, id));
|
||||
revalidatePath("/rag");
|
||||
}
|
||||
182
src/actions/risk-assessment.ts
Normal file
182
src/actions/risk-assessment.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
riskAssessments,
|
||||
riskAssessmentHazards,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, desc, asc } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function getAssessments() {
|
||||
return db.query.riskAssessments.findMany({
|
||||
orderBy: [desc(riskAssessments.createdAt)],
|
||||
with: { hazards: { orderBy: [asc(riskAssessmentHazards.sortOrder)] } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAssessment(id: string) {
|
||||
return db.query.riskAssessments.findFirst({
|
||||
where: eq(riskAssessments.id, id),
|
||||
with: { hazards: { orderBy: [asc(riskAssessmentHazards.sortOrder)] } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAssessment(data: {
|
||||
title: string;
|
||||
department?: string;
|
||||
location?: string;
|
||||
assessor?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [result] = await db.insert(riskAssessments).values(data).returning();
|
||||
revalidatePath("/risk-assessment");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateAssessment(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
title: string;
|
||||
department: string;
|
||||
location: string;
|
||||
assessor: string;
|
||||
description: string;
|
||||
status: "draft" | "review" | "approved" | "archived";
|
||||
}>
|
||||
) {
|
||||
const [result] = await db
|
||||
.update(riskAssessments)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(riskAssessments.id, id))
|
||||
.returning();
|
||||
revalidatePath("/risk-assessment");
|
||||
revalidatePath(`/risk-assessment/${id}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteAssessment(id: string) {
|
||||
await db.delete(riskAssessments).where(eq(riskAssessments.id, id));
|
||||
revalidatePath("/risk-assessment");
|
||||
}
|
||||
|
||||
export async function addHazard(
|
||||
assessmentId: string,
|
||||
data: {
|
||||
activity: string;
|
||||
hazard: string;
|
||||
category?: string;
|
||||
consequence?: string;
|
||||
severity: number;
|
||||
likelihood: number;
|
||||
existingControls?: string;
|
||||
additionalControls?: string;
|
||||
reducedSeverity?: number;
|
||||
reducedLikelihood?: number;
|
||||
responsible?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
) {
|
||||
// Get max sort order
|
||||
const existing = await db.query.riskAssessmentHazards.findMany({
|
||||
where: eq(riskAssessmentHazards.assessmentId, assessmentId),
|
||||
columns: { sortOrder: true },
|
||||
});
|
||||
const maxOrder = existing.reduce((max, h) => Math.max(max, h.sortOrder), -1);
|
||||
|
||||
const riskLevel = data.severity * data.likelihood;
|
||||
const reducedRiskLevel =
|
||||
data.reducedSeverity && data.reducedLikelihood
|
||||
? data.reducedSeverity * data.reducedLikelihood
|
||||
: null;
|
||||
|
||||
const [result] = await db
|
||||
.insert(riskAssessmentHazards)
|
||||
.values({
|
||||
assessmentId,
|
||||
sortOrder: maxOrder + 1,
|
||||
category: data.category,
|
||||
activity: data.activity,
|
||||
hazard: data.hazard,
|
||||
consequence: data.consequence,
|
||||
severity: data.severity,
|
||||
likelihood: data.likelihood,
|
||||
riskLevel,
|
||||
existingControls: data.existingControls,
|
||||
additionalControls: data.additionalControls,
|
||||
reducedSeverity: data.reducedSeverity,
|
||||
reducedLikelihood: data.reducedLikelihood,
|
||||
reducedRiskLevel: reducedRiskLevel,
|
||||
responsible: data.responsible,
|
||||
dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Update assessment timestamp
|
||||
await db
|
||||
.update(riskAssessments)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(riskAssessments.id, assessmentId));
|
||||
|
||||
revalidatePath(`/risk-assessment/${assessmentId}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateHazard(
|
||||
hazardId: string,
|
||||
data: Partial<{
|
||||
activity: string;
|
||||
hazard: string;
|
||||
category: string;
|
||||
consequence: string;
|
||||
severity: number;
|
||||
likelihood: number;
|
||||
existingControls: string;
|
||||
additionalControls: string;
|
||||
reducedSeverity: number;
|
||||
reducedLikelihood: number;
|
||||
responsible: string;
|
||||
dueDate: string;
|
||||
sortOrder: number;
|
||||
}>
|
||||
) {
|
||||
const updateData: Record<string, unknown> = { ...data };
|
||||
|
||||
if (data.severity !== undefined && data.likelihood !== undefined) {
|
||||
updateData.riskLevel = data.severity * data.likelihood;
|
||||
}
|
||||
if (
|
||||
data.reducedSeverity !== undefined &&
|
||||
data.reducedLikelihood !== undefined
|
||||
) {
|
||||
updateData.reducedRiskLevel = data.reducedSeverity * data.reducedLikelihood;
|
||||
}
|
||||
if (data.dueDate !== undefined) {
|
||||
updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.update(riskAssessmentHazards)
|
||||
.set(updateData)
|
||||
.where(eq(riskAssessmentHazards.id, hazardId))
|
||||
.returning();
|
||||
|
||||
revalidatePath("/risk-assessment");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteHazard(hazardId: string) {
|
||||
const [hazard] = await db
|
||||
.select({ assessmentId: riskAssessmentHazards.assessmentId })
|
||||
.from(riskAssessmentHazards)
|
||||
.where(eq(riskAssessmentHazards.id, hazardId));
|
||||
|
||||
await db
|
||||
.delete(riskAssessmentHazards)
|
||||
.where(eq(riskAssessmentHazards.id, hazardId));
|
||||
|
||||
if (hazard) {
|
||||
revalidatePath(`/risk-assessment/${hazard.assessmentId}`);
|
||||
}
|
||||
revalidatePath("/risk-assessment");
|
||||
}
|
||||
14
src/app/(authenticated)/checklists/inspections/[id]/page.tsx
Normal file
14
src/app/(authenticated)/checklists/inspections/[id]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getInspection } from "@/actions/checklists";
|
||||
import { InspectionDetail } from "@/components/checklists/inspection-detail";
|
||||
|
||||
export default async function InspectionDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const inspection = await getInspection(id);
|
||||
if (!inspection) notFound();
|
||||
return <InspectionDetail inspection={inspection} />;
|
||||
}
|
||||
13
src/app/(authenticated)/checklists/inspections/new/page.tsx
Normal file
13
src/app/(authenticated)/checklists/inspections/new/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getTemplates } from "@/actions/checklists";
|
||||
import { NewInspectionForm } from "@/components/checklists/new-inspection-form";
|
||||
|
||||
export default async function NewInspectionPage() {
|
||||
const templates = await getTemplates();
|
||||
const activeTemplates = templates.filter((t) => t.isActive);
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-6">새 점검 실행</h2>
|
||||
<NewInspectionForm templates={activeTemplates} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/app/(authenticated)/checklists/page.tsx
Normal file
58
src/app/(authenticated)/checklists/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TemplateList } from "@/components/checklists/template-list";
|
||||
import { InspectionList } from "@/components/checklists/inspection-list";
|
||||
import { NcrList } from "@/components/checklists/ncr-list";
|
||||
import { getTemplates, getInspections, getNonConformances } from "@/actions/checklists";
|
||||
|
||||
export default async function ChecklistsPage() {
|
||||
const [templates, allInspections, ncrs] = await Promise.all([
|
||||
getTemplates(),
|
||||
getInspections(),
|
||||
getNonConformances(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">점검 체크리스트</h2>
|
||||
<p className="text-muted-foreground">안전점검 템플릿을 관리하고 점검을 실행합니다.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/checklists/templates/new">
|
||||
<Plus className="h-4 w-4 mr-2" />새 템플릿
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/checklists/inspections/new">
|
||||
<Plus className="h-4 w-4 mr-2" />점검 실행
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="templates">
|
||||
<TabsList>
|
||||
<TabsTrigger value="templates">템플릿 ({templates.length})</TabsTrigger>
|
||||
<TabsTrigger value="inspections">점검 이력 ({allInspections.length})</TabsTrigger>
|
||||
<TabsTrigger value="ncr">
|
||||
부적합 ({ncrs.filter((n) => n.status !== "closed").length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="templates">
|
||||
<TemplateList templates={templates} />
|
||||
</TabsContent>
|
||||
<TabsContent value="inspections">
|
||||
<InspectionList inspections={allInspections} />
|
||||
</TabsContent>
|
||||
<TabsContent value="ncr">
|
||||
<NcrList ncrs={ncrs} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/app/(authenticated)/checklists/templates/[id]/page.tsx
Normal file
14
src/app/(authenticated)/checklists/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTemplate } from "@/actions/checklists";
|
||||
import { TemplateDetail } from "@/components/checklists/template-detail";
|
||||
|
||||
export default async function TemplateDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const template = await getTemplate(id);
|
||||
if (!template) notFound();
|
||||
return <TemplateDetail template={template} />;
|
||||
}
|
||||
82
src/app/(authenticated)/checklists/templates/new/page.tsx
Normal file
82
src/app/(authenticated)/checklists/templates/new/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { INSPECTION_TYPE_LABELS } from "@/lib/constants";
|
||||
import { createTemplate } from "@/actions/checklists";
|
||||
|
||||
export default function NewTemplatePage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
const form = new FormData(e.currentTarget);
|
||||
const result = await createTemplate({
|
||||
name: form.get("name") as string,
|
||||
type: form.get("type") as "daily" | "regular" | "special" | "equipment",
|
||||
description: form.get("description") as string,
|
||||
});
|
||||
router.push(`/checklists/templates/${result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-6">새 점검 템플릿</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">템플릿 이름 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="예: 일상 안전점검 체크리스트"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">점검 유형</Label>
|
||||
<Select name="type" defaultValue="daily">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(INSPECTION_TYPE_LABELS).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea id="description" name="description" rows={3} placeholder="점검 목적 및 범위" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "생성 중..." : "템플릿 생성"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>취소</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/(authenticated)/dashboard/page.tsx
Normal file
23
src/app/(authenticated)/dashboard/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
import { DashboardStats } from "@/components/dashboard/stats";
|
||||
import { DashboardCharts } from "@/components/dashboard/charts";
|
||||
import { QuickActions } from "@/components/dashboard/quick-actions";
|
||||
import { DashboardChartsLoader } from "@/components/dashboard/charts-loader";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">대시보드</h2>
|
||||
<p className="text-muted-foreground">안전관리 현황을 한눈에 확인하세요.</p>
|
||||
</div>
|
||||
<QuickActions />
|
||||
<Suspense fallback={<div className="h-32 animate-pulse bg-muted rounded-lg" />}>
|
||||
<DashboardStats />
|
||||
</Suspense>
|
||||
<Suspense fallback={<div className="h-64 animate-pulse bg-muted rounded-lg" />}>
|
||||
<DashboardChartsLoader />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(authenticated)/layout.tsx
Normal file
21
src/app/(authenticated)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
||||
export default async function AuthenticatedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
await requireAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="md:pl-64">
|
||||
<Header />
|
||||
<main className="p-4 md:p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/app/(authenticated)/rag/page.tsx
Normal file
35
src/app/(authenticated)/rag/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RagChat } from "@/components/rag/chat";
|
||||
import { DocumentManager } from "@/components/rag/document-manager";
|
||||
import { getConversations, getDocuments } from "@/actions/rag";
|
||||
|
||||
export default async function RagPage() {
|
||||
const [conversations, documents] = await Promise.all([
|
||||
getConversations(),
|
||||
getDocuments(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">안전 Q&A</h2>
|
||||
<p className="text-muted-foreground">
|
||||
안전 문서를 기반으로 AI에게 질문하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="chat">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chat">채팅</TabsTrigger>
|
||||
<TabsTrigger value="documents">문서 관리 ({documents.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="chat">
|
||||
<RagChat conversations={conversations} />
|
||||
</TabsContent>
|
||||
<TabsContent value="documents">
|
||||
<DocumentManager documents={documents} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/app/(authenticated)/risk-assessment/[id]/page.tsx
Normal file
15
src/app/(authenticated)/risk-assessment/[id]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAssessment } from "@/actions/risk-assessment";
|
||||
import { AssessmentDetail } from "@/components/risk-assessment/assessment-detail";
|
||||
|
||||
export default async function AssessmentDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const assessment = await getAssessment(id);
|
||||
if (!assessment) notFound();
|
||||
|
||||
return <AssessmentDetail assessment={assessment} />;
|
||||
}
|
||||
15
src/app/(authenticated)/risk-assessment/[id]/print/page.tsx
Normal file
15
src/app/(authenticated)/risk-assessment/[id]/print/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAssessment } from "@/actions/risk-assessment";
|
||||
import { PrintView } from "@/components/risk-assessment/print-view";
|
||||
|
||||
export default async function PrintAssessmentPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const assessment = await getAssessment(id);
|
||||
if (!assessment) notFound();
|
||||
|
||||
return <PrintView assessment={assessment} />;
|
||||
}
|
||||
91
src/app/(authenticated)/risk-assessment/new/page.tsx
Normal file
91
src/app/(authenticated)/risk-assessment/new/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createAssessment } from "@/actions/risk-assessment";
|
||||
|
||||
export default function NewAssessmentPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
const form = new FormData(e.currentTarget);
|
||||
const data = {
|
||||
title: form.get("title") as string,
|
||||
department: form.get("department") as string,
|
||||
location: form.get("location") as string,
|
||||
assessor: form.get("assessor") as string,
|
||||
description: form.get("description") as string,
|
||||
};
|
||||
const result = await createAssessment(data);
|
||||
router.push(`/risk-assessment/${result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-6">
|
||||
새 위험성평가 작성
|
||||
</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">평가 제목 *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="예: 2024년 1분기 현장작업 위험성평가"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Input id="department" name="department" placeholder="안전관리팀" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">작업장소</Label>
|
||||
<Input id="location" name="location" placeholder="제1공장 A동" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assessor">평가자</Label>
|
||||
<Input id="assessor" name="assessor" placeholder="홍길동" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="평가 대상 및 범위에 대한 설명"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "생성 중..." : "평가 생성"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(authenticated)/risk-assessment/page.tsx
Normal file
28
src/app/(authenticated)/risk-assessment/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AssessmentList } from "@/components/risk-assessment/assessment-list";
|
||||
import { getAssessments } from "@/actions/risk-assessment";
|
||||
|
||||
export default async function RiskAssessmentPage() {
|
||||
const assessments = await getAssessments();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">위험성평가</h2>
|
||||
<p className="text-muted-foreground">
|
||||
작업별 위험성평가를 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/risk-assessment/new">
|
||||
<Plus className="h-4 w-4 mr-2" />새 평가
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<AssessmentList assessments={assessments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/app/(authenticated)/settings/page.tsx
Normal file
141
src/app/(authenticated)/settings/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sun, Moon } from "lucide-react";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
Record<string, "checking" | "ok" | "error">
|
||||
>({
|
||||
ollama: "checking",
|
||||
embedding: "checking",
|
||||
qdrant: "checking",
|
||||
tika: "checking",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Check dark mode
|
||||
setDark(document.documentElement.classList.contains("dark"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check service connections
|
||||
async function checkServices() {
|
||||
const checks = [
|
||||
{ key: "ollama", url: "/api/health/ollama" },
|
||||
{ key: "embedding", url: "/api/health/embedding" },
|
||||
{ key: "qdrant", url: "/api/health/qdrant" },
|
||||
{ key: "tika", url: "/api/health/tika" },
|
||||
];
|
||||
for (const check of checks) {
|
||||
try {
|
||||
const res = await fetch(check.url);
|
||||
setConnectionStatus((prev) => ({
|
||||
...prev,
|
||||
[check.key]: res.ok ? "ok" : "error",
|
||||
}));
|
||||
} catch {
|
||||
setConnectionStatus((prev) => ({ ...prev, [check.key]: "error" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
checkServices();
|
||||
}, []);
|
||||
|
||||
function toggleDark() {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
localStorage.setItem("theme", next ? "dark" : "light");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">설정</h2>
|
||||
<p className="text-muted-foreground">시스템 설정을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">테마</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={toggleDark}>
|
||||
{dark ? (
|
||||
<Sun className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{dark ? "라이트 모드" : "다크 모드"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">서비스 연결 상태</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[
|
||||
{ key: "ollama", label: "Ollama (생성 AI)", desc: "맥미니 localhost:11434" },
|
||||
{ key: "embedding", label: "Embedding (bge-m3)", desc: "조립컴 GPU 11434" },
|
||||
{ key: "qdrant", label: "Qdrant (벡터DB)", desc: "맥미니 localhost:6333" },
|
||||
{ key: "tika", label: "Tika (OCR)", desc: "맥미니 localhost:9998" },
|
||||
].map((svc) => (
|
||||
<div key={svc.key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{svc.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{svc.desc}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
connectionStatus[svc.key] === "ok"
|
||||
? "bg-green-100 text-green-800"
|
||||
: connectionStatus[svc.key] === "error"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}
|
||||
>
|
||||
{connectionStatus[svc.key] === "ok"
|
||||
? "연결됨"
|
||||
: connectionStatus[svc.key] === "error"
|
||||
? "연결 실패"
|
||||
: "확인 중..."}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">시스템 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">플랫폼</span>
|
||||
<span>TK 안전관리 v1.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">프레임워크</span>
|
||||
<span>Next.js 15 + Drizzle ORM</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">LLM</span>
|
||||
<span>qwen3.5:35b-a3b</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">임베딩</span>
|
||||
<span>bge-m3 (1024d)</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/app/api/ai/chat/route.ts
Normal file
92
src/app/api/ai/chat/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { isAuthenticated } from "@/lib/auth";
|
||||
import { getEmbedding } from "@/lib/ai/embeddings";
|
||||
import { searchPoints } from "@/lib/ai/qdrant";
|
||||
import { ollamaChatStream } from "@/lib/ai/ollama";
|
||||
import { SYSTEM_PROMPT_RAG, buildRagPrompt } from "@/lib/ai/prompts";
|
||||
import { saveMessage } from "@/actions/rag";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await isAuthenticated())) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { question, conversationId } = await req.json();
|
||||
if (!question || !conversationId) {
|
||||
return new Response("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
// Save user message
|
||||
await saveMessage(conversationId, "user", question);
|
||||
|
||||
// Get embedding for question
|
||||
let contexts: string[] = [];
|
||||
let sources: Array<{ content: string; score: number; documentId: string }> = [];
|
||||
|
||||
try {
|
||||
const queryVector = await getEmbedding(question);
|
||||
const results = await searchPoints(queryVector, 5);
|
||||
contexts = results.map((r) => r.payload.content as string);
|
||||
sources = results.map((r) => ({
|
||||
content: (r.payload.content as string).slice(0, 200),
|
||||
score: r.score,
|
||||
documentId: r.payload.documentId as string,
|
||||
}));
|
||||
} catch {
|
||||
// If embedding/search fails, continue without context
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
const prompt = contexts.length > 0
|
||||
? buildRagPrompt(question, contexts)
|
||||
: question;
|
||||
|
||||
// Create SSE stream
|
||||
const encoder = new TextEncoder();
|
||||
let fullResponse = "";
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// Send sources first
|
||||
if (sources.length > 0) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "sources", sources })}\n\n`)
|
||||
);
|
||||
}
|
||||
|
||||
const messages = [{ role: "user", content: prompt }];
|
||||
for await (const chunk of ollamaChatStream(messages, {
|
||||
system: SYSTEM_PROMPT_RAG,
|
||||
})) {
|
||||
fullResponse += chunk;
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "token", content: chunk })}\n\n`)
|
||||
);
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
await saveMessage(conversationId, "assistant", fullResponse, sources);
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "error", error: errorMsg })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
51
src/app/api/ai/suggest-checklist/route.ts
Normal file
51
src/app/api/ai/suggest-checklist/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/lib/auth";
|
||||
import { getEmbedding } from "@/lib/ai/embeddings";
|
||||
import { searchPoints } from "@/lib/ai/qdrant";
|
||||
import { ollamaGenerate } from "@/lib/ai/ollama";
|
||||
import { SYSTEM_PROMPT_CHECKLIST, buildChecklistPrompt } from "@/lib/ai/prompts";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await isAuthenticated())) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { environment } = await req.json();
|
||||
if (!environment) {
|
||||
return NextResponse.json({ error: "Missing environment" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get RAG context
|
||||
let contexts: string[] = [];
|
||||
try {
|
||||
const vector = await getEmbedding(environment);
|
||||
const results = await searchPoints(vector, 3);
|
||||
contexts = results.map((r) => r.payload.content as string);
|
||||
} catch {
|
||||
// Continue without context
|
||||
}
|
||||
|
||||
const prompt = buildChecklistPrompt(environment, contexts);
|
||||
|
||||
try {
|
||||
const response = await ollamaGenerate({
|
||||
prompt,
|
||||
system: SYSTEM_PROMPT_CHECKLIST,
|
||||
format: "json",
|
||||
options: { temperature: 0.3 },
|
||||
});
|
||||
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
return NextResponse.json({ items: [], raw: response });
|
||||
}
|
||||
|
||||
const items = JSON.parse(jsonMatch[0]);
|
||||
return NextResponse.json({ items });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "AI error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/app/api/ai/suggest-controls/route.ts
Normal file
52
src/app/api/ai/suggest-controls/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { isAuthenticated } from "@/lib/auth";
|
||||
import { ollamaChatStream } from "@/lib/ai/ollama";
|
||||
import { SYSTEM_PROMPT_CONTROLS } from "@/lib/ai/prompts";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await isAuthenticated())) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { hazard, consequence } = await req.json();
|
||||
if (!hazard) {
|
||||
return new Response("Missing hazard", { status: 400 });
|
||||
}
|
||||
|
||||
const prompt = `유해위험요인: ${hazard}\n예상 재해: ${consequence || "미지정"}\n\n위 유해위험요인에 대한 위험성 감소대책을 계층별로 제안하세요.`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const messages = [{ role: "user", content: prompt }];
|
||||
for await (const chunk of ollamaChatStream(messages, {
|
||||
system: SYSTEM_PROMPT_CONTROLS,
|
||||
})) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "token", content: chunk })}\n\n`)
|
||||
);
|
||||
}
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "error", error: err instanceof Error ? err.message : "Error" })}\n\n`
|
||||
)
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
52
src/app/api/ai/suggest-hazards/route.ts
Normal file
52
src/app/api/ai/suggest-hazards/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/lib/auth";
|
||||
import { getEmbedding } from "@/lib/ai/embeddings";
|
||||
import { searchPoints } from "@/lib/ai/qdrant";
|
||||
import { ollamaGenerate } from "@/lib/ai/ollama";
|
||||
import { SYSTEM_PROMPT_HAZARD, buildHazardPrompt } from "@/lib/ai/prompts";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!(await isAuthenticated())) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { activity } = await req.json();
|
||||
if (!activity) {
|
||||
return NextResponse.json({ error: "Missing activity" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get RAG context
|
||||
let contexts: string[] = [];
|
||||
try {
|
||||
const vector = await getEmbedding(activity);
|
||||
const results = await searchPoints(vector, 3);
|
||||
contexts = results.map((r) => r.payload.content as string);
|
||||
} catch {
|
||||
// Continue without context
|
||||
}
|
||||
|
||||
const prompt = buildHazardPrompt(activity, contexts);
|
||||
|
||||
try {
|
||||
const response = await ollamaGenerate({
|
||||
prompt,
|
||||
system: SYSTEM_PROMPT_HAZARD,
|
||||
format: "json",
|
||||
options: { temperature: 0.3 },
|
||||
});
|
||||
|
||||
// Parse JSON from response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
return NextResponse.json({ hazards: [], raw: response });
|
||||
}
|
||||
|
||||
const hazards = JSON.parse(jsonMatch[0]);
|
||||
return NextResponse.json({ hazards });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "AI error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/app/api/auth/login/route.ts
Normal file
11
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { login } from "@/lib/auth";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { password } = await req.json();
|
||||
const success = await login(password);
|
||||
if (success) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { logout } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
await logout();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
15
src/app/api/health/embedding/route.ts
Normal file
15
src/app/api/health/embedding/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.EMBEDDING_BASE_URL || "http://100.111.160.84:11434"}/api/tags`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
return res.ok
|
||||
? NextResponse.json({ ok: true })
|
||||
: NextResponse.json({ ok: false }, { status: 502 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/health/ollama/route.ts
Normal file
15
src/app/api/health/ollama/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.OLLAMA_BASE_URL || "http://localhost:11434"}/api/tags`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
return res.ok
|
||||
? NextResponse.json({ ok: true })
|
||||
: NextResponse.json({ ok: false }, { status: 502 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/health/qdrant/route.ts
Normal file
15
src/app/api/health/qdrant/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.QDRANT_URL || "http://localhost:6333"}/collections`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
return res.ok
|
||||
? NextResponse.json({ ok: true })
|
||||
: NextResponse.json({ ok: false }, { status: 502 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/health/tika/route.ts
Normal file
15
src/app/api/health/tika/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.TIKA_URL || "http://localhost:9998"}/tika`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
return res.ok
|
||||
? NextResponse.json({ ok: true })
|
||||
: NextResponse.json({ ok: false }, { status: 502 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "TK 안전관리",
|
||||
description: "Technical Korea 안전관리 플랫폼",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +24,18 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="ko" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try{if(localStorage.theme==='dark')document.documentElement.classList.add('dark')}catch(e){}`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
91
src/app/login/page.tsx
Normal file
91
src/app/login/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { HardHat, Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setError("비밀번호가 올바르지 않습니다.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted/50 px-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center space-y-2 pb-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<HardHat className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">TK 안전관리</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Technical Korea 안전관리 플랫폼
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "로그인 중..." : "로그인"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isAuthenticated } from "@/lib/auth";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default async function Home() {
|
||||
const authed = await isAuthenticated();
|
||||
if (authed) {
|
||||
redirect("/dashboard");
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
128
src/components/checklists/ai-checklist-panel.tsx
Normal file
128
src/components/checklists/ai-checklist-panel.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles, Plus, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { addTemplateItem } from "@/actions/checklists";
|
||||
|
||||
interface SuggestedItem {
|
||||
category: string;
|
||||
content: string;
|
||||
standard: string;
|
||||
}
|
||||
|
||||
export function AiChecklistPanel({ templateId }: { templateId: string }) {
|
||||
const router = useRouter();
|
||||
const [environment, setEnvironment] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SuggestedItem[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSuggest() {
|
||||
if (!environment.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/suggest-checklist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ environment }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
setSuggestions(data.items || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "AI 오류");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleAdd(item: SuggestedItem) {
|
||||
await addTemplateItem(templateId, {
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
standard: item.standard,
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleAddAll() {
|
||||
for (const item of suggestions) {
|
||||
await addTemplateItem(templateId, {
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
standard: item.standard,
|
||||
});
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
AI 항목 생성
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] sm:max-w-[500px] overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>AI 점검항목 생성</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={environment}
|
||||
onChange={(e) => setEnvironment(e.target.value)}
|
||||
placeholder="작업환경을 설명하세요. 예: 건설현장 콘크리트 타설 작업"
|
||||
rows={3}
|
||||
/>
|
||||
<Button onClick={handleSuggest} disabled={loading || !environment.trim()}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{loading ? "생성 중..." : "항목 생성"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleAddAll}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
전체 추가 ({suggestions.length}건)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((item, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-3 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline">{item.category}</Badge>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleAdd(item)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm">{item.content}</p>
|
||||
{item.standard && (
|
||||
<p className="text-xs text-muted-foreground">기준: {item.standard}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
289
src/components/checklists/inspection-detail.tsx
Normal file
289
src/components/checklists/inspection-detail.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Save, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
saveInspectionResults,
|
||||
completeInspection,
|
||||
createNonConformance,
|
||||
} from "@/actions/checklists";
|
||||
|
||||
interface TemplateItem {
|
||||
id: string;
|
||||
category: string | null;
|
||||
content: string;
|
||||
standard: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface InspectionProps {
|
||||
inspection: {
|
||||
id: string;
|
||||
inspector: string | null;
|
||||
location: string | null;
|
||||
inspectionDate: Date;
|
||||
completedAt: Date | null;
|
||||
template: {
|
||||
name: string;
|
||||
items: TemplateItem[];
|
||||
};
|
||||
results: Array<{
|
||||
id: string;
|
||||
templateItemId: string;
|
||||
result: string | null;
|
||||
memo: string | null;
|
||||
}>;
|
||||
nonConformances: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function InspectionDetail({ inspection }: InspectionProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showNcrForm, setShowNcrForm] = useState<string | null>(null);
|
||||
|
||||
// Build initial state from existing results
|
||||
const existingMap = new Map(
|
||||
inspection.results.map((r) => [
|
||||
r.templateItemId,
|
||||
{ result: r.result || "", memo: r.memo || "" },
|
||||
])
|
||||
);
|
||||
|
||||
const [results, setResults] = useState<
|
||||
Record<string, { result: string; memo: string }>
|
||||
>(
|
||||
Object.fromEntries(
|
||||
inspection.template.items.map((item) => [
|
||||
item.id,
|
||||
existingMap.get(item.id) || { result: "", memo: "" },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
function updateResult(itemId: string, field: "result" | "memo", value: string) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[itemId]: { ...prev[itemId], [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const data = Object.entries(results)
|
||||
.filter(([, v]) => v.result)
|
||||
.map(([templateItemId, v]) => ({
|
||||
templateItemId,
|
||||
result: v.result,
|
||||
memo: v.memo,
|
||||
}));
|
||||
await saveInspectionResults(inspection.id, data);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (!confirm("점검을 완료하시겠습니까? 완료 후 수정할 수 없습니다.")) return;
|
||||
await handleSave();
|
||||
await completeInspection(inspection.id);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleCreateNcr(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
await createNonConformance({
|
||||
inspectionId: inspection.id,
|
||||
inspectionResultId: showNcrForm || undefined,
|
||||
description: form.get("description") as string,
|
||||
severity: form.get("severity") as string,
|
||||
responsiblePerson: form.get("responsiblePerson") as string,
|
||||
});
|
||||
setShowNcrForm(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const isCompleted = !!inspection.completedAt;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/checklists">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{inspection.template.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
{isCompleted ? (
|
||||
<Badge variant="secondary">완료</Badge>
|
||||
) : (
|
||||
<Badge>진행 중</Badge>
|
||||
)}
|
||||
{inspection.inspector && <span>{inspection.inspector}</span>}
|
||||
{inspection.location && <span>{inspection.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!isCompleted && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleSave} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
<Button onClick={handleComplete}>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
점검 완료
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{inspection.template.items.map((item, i) => {
|
||||
const r = results[item.id] || { result: "", memo: "" };
|
||||
return (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-sm text-muted-foreground mt-1 w-8">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm font-medium">{item.content}</span>
|
||||
</div>
|
||||
{item.standard && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
기준: {item.standard}
|
||||
</p>
|
||||
)}
|
||||
{!isCompleted && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Textarea
|
||||
placeholder="메모"
|
||||
value={r.memo}
|
||||
onChange={(e) =>
|
||||
updateResult(item.id, "memo", e.target.value)
|
||||
}
|
||||
className="h-8 min-h-8 text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCompleted && r.memo && (
|
||||
<p className="text-sm text-muted-foreground">{r.memo}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{["pass", "fail", "na"].map((v) => {
|
||||
const labels: Record<string, string> = {
|
||||
pass: "합격",
|
||||
fail: "불합격",
|
||||
na: "N/A",
|
||||
};
|
||||
const colors: Record<string, string> = {
|
||||
pass: r.result === v ? "bg-green-500 text-white" : "",
|
||||
fail: r.result === v ? "bg-red-500 text-white" : "",
|
||||
na: r.result === v ? "bg-gray-500 text-white" : "",
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
key={v}
|
||||
size="sm"
|
||||
variant={r.result === v ? "default" : "outline"}
|
||||
className={colors[v]}
|
||||
disabled={isCompleted}
|
||||
onClick={() => updateResult(item.id, "result", v)}
|
||||
>
|
||||
{labels[v]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{!isCompleted && r.result === "fail" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => setShowNcrForm(item.id)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showNcrForm && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">부적합 사항 등록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreateNcr} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>부적합 내용 *</Label>
|
||||
<Textarea name="description" required placeholder="부적합 사항을 설명하세요" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>심각도</Label>
|
||||
<Input name="severity" placeholder="low / medium / high" defaultValue="medium" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>담당자</Label>
|
||||
<Input name="responsiblePerson" placeholder="담당자명" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" variant="destructive">NCR 등록</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowNcrForm(null)}>취소</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{inspection.nonConformances.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
부적합 사항 ({inspection.nonConformances.length}건)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{inspection.nonConformances.map((ncr) => (
|
||||
<div key={ncr.id} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline">{ncr.status}</Badge>
|
||||
<span>{ncr.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/checklists/inspection-list.tsx
Normal file
64
src/components/checklists/inspection-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface InspectionItem {
|
||||
id: string;
|
||||
inspector: string | null;
|
||||
location: string | null;
|
||||
inspectionDate: Date;
|
||||
completedAt: Date | null;
|
||||
template: { name: string };
|
||||
results: unknown[];
|
||||
nonConformances: { status: string }[];
|
||||
}
|
||||
|
||||
export function InspectionList({ inspections }: { inspections: InspectionItem[] }) {
|
||||
if (inspections.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">점검 이력이 없습니다.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{inspections.map((insp) => {
|
||||
const openNcrs = insp.nonConformances.filter((n) => n.status !== "closed").length;
|
||||
return (
|
||||
<Card key={insp.id} className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Link href={`/checklists/inspections/${insp.id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-semibold truncate">{insp.template.name}</h3>
|
||||
{insp.completedAt ? (
|
||||
<Badge variant="secondary">완료</Badge>
|
||||
) : (
|
||||
<Badge>진행 중</Badge>
|
||||
)}
|
||||
{openNcrs > 0 && (
|
||||
<Badge variant="destructive">NCR {openNcrs}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
{insp.inspector && <span>{insp.inspector}</span>}
|
||||
{insp.location && <span>{insp.location}</span>}
|
||||
<span>{new Date(insp.inspectionDate).toLocaleDateString("ko-KR")}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={`/checklists/inspections/${insp.id}`}>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/checklists/ncr-list.tsx
Normal file
107
src/components/checklists/ncr-list.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NCR_STATUS_LABELS } from "@/lib/constants";
|
||||
import { updateNonConformance } from "@/actions/checklists";
|
||||
|
||||
interface NcrItem {
|
||||
id: string;
|
||||
description: string;
|
||||
severity: string | null;
|
||||
status: "open" | "corrective_action" | "verification" | "closed";
|
||||
correctiveAction: string | null;
|
||||
responsiblePerson: string | null;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
inspection: { template: { name: string } };
|
||||
}
|
||||
|
||||
const statusFlow: Record<string, string | null> = {
|
||||
open: "corrective_action",
|
||||
corrective_action: "verification",
|
||||
verification: "closed",
|
||||
closed: null,
|
||||
};
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
low: "bg-yellow-100 text-yellow-800",
|
||||
medium: "bg-orange-100 text-orange-800",
|
||||
high: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export function NcrList({ ncrs }: { ncrs: NcrItem[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
if (ncrs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">부적합 사항이 없습니다.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleAdvance(id: string, currentStatus: string) {
|
||||
const next = statusFlow[currentStatus];
|
||||
if (!next) return;
|
||||
await updateNonConformance(id, {
|
||||
status: next as NcrItem["status"],
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{ncrs.map((ncr) => {
|
||||
const next = statusFlow[ncr.status];
|
||||
return (
|
||||
<Card key={ncr.id}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{NCR_STATUS_LABELS[ncr.status]}</Badge>
|
||||
{ncr.severity && (
|
||||
<Badge className={severityColors[ncr.severity] || ""} variant="outline">
|
||||
{ncr.severity}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ncr.inspection.template.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mb-1">{ncr.description}</p>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{ncr.responsiblePerson && <span>담당: {ncr.responsiblePerson}</span>}
|
||||
{ncr.dueDate && (
|
||||
<span>기한: {new Date(ncr.dueDate).toLocaleDateString("ko-KR")}</span>
|
||||
)}
|
||||
<span>{new Date(ncr.createdAt).toLocaleDateString("ko-KR")}</span>
|
||||
</div>
|
||||
{ncr.correctiveAction && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
시정조치: {ncr.correctiveAction}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{next && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAdvance(ncr.id, ncr.status)}
|
||||
>
|
||||
{NCR_STATUS_LABELS[next]} →
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/checklists/new-inspection-form.tsx
Normal file
86
src/components/checklists/new-inspection-form.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createInspection } from "@/actions/checklists";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { checklistTemplates } from "@/lib/db/schema";
|
||||
|
||||
type Template = InferSelectModel<typeof checklistTemplates>;
|
||||
|
||||
export function NewInspectionForm({ templates }: { templates: Template[] }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!templateId) return;
|
||||
setLoading(true);
|
||||
const form = new FormData(e.currentTarget);
|
||||
const result = await createInspection({
|
||||
templateId,
|
||||
inspector: form.get("inspector") as string,
|
||||
location: form.get("location") as string,
|
||||
});
|
||||
router.push(`/checklists/inspections/${result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>점검 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{templates.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
활성 템플릿이 없습니다. 먼저 템플릿을 생성하세요.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>점검 템플릿 *</Label>
|
||||
<Select value={templateId} onValueChange={setTemplateId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="템플릿을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inspector">점검자</Label>
|
||||
<Input id="inspector" name="inspector" placeholder="점검자명" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">점검 장소</Label>
|
||||
<Input id="location" name="location" placeholder="점검 장소" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" disabled={loading || !templateId}>
|
||||
{loading ? "생성 중..." : "점검 시작"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>취소</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
134
src/components/checklists/template-detail.tsx
Normal file
134
src/components/checklists/template-detail.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { INSPECTION_TYPE_LABELS } from "@/lib/constants";
|
||||
import { addTemplateItem, deleteTemplateItem } from "@/actions/checklists";
|
||||
import { AiChecklistPanel } from "./ai-checklist-panel";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { checklistTemplates, checklistTemplateItems } from "@/lib/db/schema";
|
||||
|
||||
type Template = InferSelectModel<typeof checklistTemplates> & {
|
||||
items: InferSelectModel<typeof checklistTemplateItems>[];
|
||||
};
|
||||
|
||||
export function TemplateDetail({ template }: { template: Template }) {
|
||||
const router = useRouter();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleAddItem(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const form = new FormData(e.currentTarget);
|
||||
await addTemplateItem(template.id, {
|
||||
content: form.get("content") as string,
|
||||
category: form.get("category") as string,
|
||||
standard: form.get("standard") as string,
|
||||
});
|
||||
setShowAdd(false);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleDeleteItem(itemId: string) {
|
||||
if (!confirm("이 항목을 삭제하시겠습니까?")) return;
|
||||
await deleteTemplateItem(itemId);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/checklists"><ArrowLeft className="h-5 w-5" /></Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{template.name}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary">{INSPECTION_TYPE_LABELS[template.type]}</Badge>
|
||||
{template.description && (
|
||||
<span className="text-sm text-muted-foreground">{template.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">점검 항목 ({template.items.length}개)</h3>
|
||||
<div className="flex gap-2">
|
||||
<AiChecklistPanel templateId={template.id} />
|
||||
<Button onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="h-4 w-4 mr-2" />항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<Card className="border-primary/50">
|
||||
<CardContent className="pt-4">
|
||||
<form onSubmit={handleAddItem} className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>분류</Label>
|
||||
<Input name="category" placeholder="예: 작업환경" />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>점검 내용 *</Label>
|
||||
<Input name="content" required placeholder="예: 안전난간 설치 상태 확인" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>판정 기준</Label>
|
||||
<Input name="standard" placeholder="관련 법령 또는 기준" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
{saving ? "추가 중..." : "추가"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowAdd(false)}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{template.items.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border rounded-lg">
|
||||
점검 항목이 없습니다. 항목을 추가해주세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{template.items.map((item, i) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<span className="text-sm text-muted-foreground w-8">{i + 1}</span>
|
||||
{item.category && <Badge variant="outline">{item.category}</Badge>}
|
||||
<span className="flex-1 text-sm">{item.content}</span>
|
||||
{item.standard && (
|
||||
<span className="text-xs text-muted-foreground max-w-48 truncate">
|
||||
{item.standard}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleDeleteItem(item.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/checklists/template-list.tsx
Normal file
66
src/components/checklists/template-list.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { INSPECTION_TYPE_LABELS } from "@/lib/constants";
|
||||
import { deleteTemplate } from "@/actions/checklists";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { checklistTemplates, checklistTemplateItems } from "@/lib/db/schema";
|
||||
|
||||
type Template = InferSelectModel<typeof checklistTemplates> & {
|
||||
items: InferSelectModel<typeof checklistTemplateItems>[];
|
||||
};
|
||||
|
||||
export function TemplateList({ templates }: { templates: Template[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground mb-4">점검 템플릿이 없습니다.</p>
|
||||
<Button asChild>
|
||||
<Link href="/checklists/templates/new">첫 템플릿 만들기</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`"${name}" 템플릿을 삭제하시겠습니까?`)) return;
|
||||
await deleteTemplate(id);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{templates.map((t) => (
|
||||
<Card key={t.id} className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Link href={`/checklists/templates/${t.id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-semibold truncate">{t.name}</h3>
|
||||
<Badge variant="secondary">{INSPECTION_TYPE_LABELS[t.type]}</Badge>
|
||||
{!t.isActive && <Badge variant="outline">비활성</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
항목 {t.items.length}개 | {new Date(t.createdAt).toLocaleDateString("ko-KR")}
|
||||
</p>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(t.id, t.name)}>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Link href={`/checklists/templates/${t.id}`}>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/dashboard/charts-loader.tsx
Normal file
64
src/components/dashboard/charts-loader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { riskAssessmentHazards, nonConformances } from "@/lib/db/schema";
|
||||
import { getRiskGrade } from "@/lib/constants";
|
||||
import { DashboardCharts } from "./charts";
|
||||
|
||||
export async function DashboardChartsLoader() {
|
||||
let riskDistribution = [
|
||||
{ name: "저위험", value: 0, color: "#22c55e" },
|
||||
{ name: "보통", value: 0, color: "#eab308" },
|
||||
{ name: "고위험", value: 0, color: "#f97316" },
|
||||
{ name: "매우위험", value: 0, color: "#ef4444" },
|
||||
];
|
||||
|
||||
let ncrByStatus = [
|
||||
{ name: "미조치", value: 0, color: "#ef4444" },
|
||||
{ name: "시정조치", value: 0, color: "#f97316" },
|
||||
{ name: "확인 중", value: 0, color: "#eab308" },
|
||||
{ name: "종결", value: 0, color: "#22c55e" },
|
||||
];
|
||||
|
||||
try {
|
||||
// Risk distribution
|
||||
const hazards = await db
|
||||
.select({ riskLevel: riskAssessmentHazards.riskLevel })
|
||||
.from(riskAssessmentHazards);
|
||||
|
||||
for (const h of hazards) {
|
||||
const grade = getRiskGrade(h.riskLevel);
|
||||
const idx = riskDistribution.findIndex(
|
||||
(d) =>
|
||||
d.name ===
|
||||
{ low: "저위험", medium: "보통", high: "고위험", critical: "매우위험" }[
|
||||
grade.grade
|
||||
]
|
||||
);
|
||||
if (idx >= 0) riskDistribution[idx].value++;
|
||||
}
|
||||
|
||||
// NCR distribution
|
||||
const ncrs = await db
|
||||
.select({ status: nonConformances.status })
|
||||
.from(nonConformances);
|
||||
|
||||
const ncrStatusMap: Record<string, number> = {
|
||||
open: 0,
|
||||
corrective_action: 1,
|
||||
verification: 2,
|
||||
closed: 3,
|
||||
};
|
||||
for (const n of ncrs) {
|
||||
const idx = ncrStatusMap[n.status];
|
||||
if (idx !== undefined) ncrByStatus[idx].value++;
|
||||
}
|
||||
} catch {
|
||||
// DB not connected yet
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardCharts
|
||||
riskDistribution={riskDistribution}
|
||||
ncrByStatus={ncrByStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
88
src/components/dashboard/charts.tsx
Normal file
88
src/components/dashboard/charts.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
interface ChartsProps {
|
||||
riskDistribution: Array<{ name: string; value: number; color: string }>;
|
||||
ncrByStatus: Array<{ name: string; value: number; color: string }>;
|
||||
}
|
||||
|
||||
export function DashboardCharts({
|
||||
riskDistribution,
|
||||
ncrByStatus,
|
||||
}: ChartsProps) {
|
||||
const hasRiskData = riskDistribution.some((d) => d.value > 0);
|
||||
const hasNcrData = ncrByStatus.some((d) => d.value > 0);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">위험등급 분포</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasRiskData ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={riskDistribution}>
|
||||
<XAxis dataKey="name" fontSize={12} />
|
||||
<YAxis allowDecimals={false} fontSize={12} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" name="건수">
|
||||
{riskDistribution.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
|
||||
위험성평가 데이터가 추가되면 차트가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">부적합 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasNcrData ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={ncrByStatus}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={({ name, value }) => `${name} ${value}`}
|
||||
>
|
||||
{ncrByStatus.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
|
||||
부적합 데이터가 추가되면 차트가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/dashboard/quick-actions.tsx
Normal file
30
src/components/dashboard/quick-actions.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Plus, MessageSquare, ClipboardCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/risk-assessment/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
새 위험성평가
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/rag">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
안전 Q&A
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/checklists/inspections/new">
|
||||
<ClipboardCheck className="h-4 w-4 mr-2" />
|
||||
점검 실행
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/components/dashboard/stats.tsx
Normal file
88
src/components/dashboard/stats.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ShieldAlert, ClipboardCheck, AlertTriangle, FileText } from "lucide-react";
|
||||
import { db } from "@/lib/db";
|
||||
import { riskAssessments, inspections, nonConformances, ragDocuments } from "@/lib/db/schema";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
|
||||
export async function DashboardStats() {
|
||||
let assessmentCount = 0;
|
||||
let inspectionCount = 0;
|
||||
let openNcrCount = 0;
|
||||
let documentCount = 0;
|
||||
|
||||
try {
|
||||
const [assessments] = await db
|
||||
.select({ count: count() })
|
||||
.from(riskAssessments);
|
||||
assessmentCount = assessments.count;
|
||||
|
||||
const [inspectionsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(inspections);
|
||||
inspectionCount = inspectionsResult.count;
|
||||
|
||||
const [ncrs] = await db
|
||||
.select({ count: count() })
|
||||
.from(nonConformances)
|
||||
.where(eq(nonConformances.status, "open"));
|
||||
openNcrCount = ncrs.count;
|
||||
|
||||
const [docs] = await db
|
||||
.select({ count: count() })
|
||||
.from(ragDocuments);
|
||||
documentCount = docs.count;
|
||||
} catch {
|
||||
// DB not yet connected - show zeros
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "위험성평가",
|
||||
value: assessmentCount,
|
||||
icon: ShieldAlert,
|
||||
description: "총 평가 수",
|
||||
},
|
||||
{
|
||||
title: "안전점검",
|
||||
value: inspectionCount,
|
||||
icon: ClipboardCheck,
|
||||
description: "총 점검 수",
|
||||
},
|
||||
{
|
||||
title: "미조치 NCR",
|
||||
value: openNcrCount,
|
||||
icon: AlertTriangle,
|
||||
description: "조치 필요",
|
||||
alert: openNcrCount > 0,
|
||||
},
|
||||
{
|
||||
title: "RAG 문서",
|
||||
value: documentCount,
|
||||
icon: FileText,
|
||||
description: "등록 문서",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon
|
||||
className={`h-4 w-4 ${
|
||||
stat.alert ? "text-destructive" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${stat.alert ? "text-destructive" : ""}`}>
|
||||
{stat.value}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/layout/header.tsx
Normal file
43
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, Menu } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center px-4 md:px-6">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<MobileNav />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2 text-muted-foreground"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">로그아웃</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
67
src/components/layout/mobile-nav.tsx
Normal file
67
src/components/layout/mobile-nav.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
ClipboardCheck,
|
||||
Settings,
|
||||
HardHat,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const icons = {
|
||||
LayoutDashboard,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
ClipboardCheck,
|
||||
Settings,
|
||||
} as const;
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/dashboard", label: "대시보드", icon: "LayoutDashboard" as const },
|
||||
{ href: "/risk-assessment", label: "위험성평가", icon: "ShieldAlert" as const },
|
||||
{ href: "/rag", label: "안전 Q&A", icon: "MessageSquare" as const },
|
||||
{ href: "/checklists", label: "점검 체크리스트", icon: "ClipboardCheck" as const },
|
||||
{ href: "/settings", label: "설정", icon: "Settings" as const },
|
||||
];
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b">
|
||||
<HardHat className="h-7 w-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="font-bold text-lg leading-tight">TK 안전관리</h1>
|
||||
<p className="text-xs text-muted-foreground">Safety Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = icons[item.icon];
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/layout/sidebar.tsx
Normal file
67
src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
ClipboardCheck,
|
||||
Settings,
|
||||
HardHat,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const icons = {
|
||||
LayoutDashboard,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
ClipboardCheck,
|
||||
Settings,
|
||||
} as const;
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/dashboard", label: "대시보드", icon: "LayoutDashboard" as const },
|
||||
{ href: "/risk-assessment", label: "위험성평가", icon: "ShieldAlert" as const },
|
||||
{ href: "/rag", label: "안전 Q&A", icon: "MessageSquare" as const },
|
||||
{ href: "/checklists", label: "점검 체크리스트", icon: "ClipboardCheck" as const },
|
||||
{ href: "/settings", label: "설정", icon: "Settings" as const },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0 border-r bg-card">
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b">
|
||||
<HardHat className="h-7 w-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="font-bold text-lg leading-tight">TK 안전관리</h1>
|
||||
<p className="text-xs text-muted-foreground">Safety Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = icons[item.icon];
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
246
src/components/rag/chat.tsx
Normal file
246
src/components/rag/chat.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Send, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useStreaming } from "@/hooks/use-streaming";
|
||||
import { createConversation, getConversation, deleteConversation } from "@/actions/rag";
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
sources?: Array<{ content: string; score: number; documentId: string }> | null;
|
||||
}
|
||||
|
||||
export function RagChat({
|
||||
conversations,
|
||||
}: {
|
||||
conversations: Conversation[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { isStreaming, content, sources, error, streamChat, reset } =
|
||||
useStreaming();
|
||||
|
||||
// Load conversation messages
|
||||
async function loadConversation(id: string) {
|
||||
setActiveConvId(id);
|
||||
reset();
|
||||
const conv = await getConversation(id);
|
||||
if (conv) {
|
||||
setMessages(
|
||||
conv.messages.map((m) => ({
|
||||
...m,
|
||||
sources: m.sources as Message["sources"],
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewConversation() {
|
||||
const conv = await createConversation();
|
||||
setActiveConvId(conv.id);
|
||||
setMessages([]);
|
||||
reset();
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await deleteConversation(id);
|
||||
if (activeConvId === id) {
|
||||
setActiveConvId(null);
|
||||
setMessages([]);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!input.trim() || isStreaming) return;
|
||||
|
||||
let convId = activeConvId;
|
||||
if (!convId) {
|
||||
const conv = await createConversation();
|
||||
convId = conv.id;
|
||||
setActiveConvId(convId);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const question = input.trim();
|
||||
setInput("");
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: `user-${Date.now()}`, role: "user", content: question },
|
||||
]);
|
||||
|
||||
reset();
|
||||
await streamChat("/api/ai/chat", {
|
||||
question,
|
||||
conversationId: convId,
|
||||
});
|
||||
}
|
||||
|
||||
// When streaming completes, add assistant message
|
||||
useEffect(() => {
|
||||
if (!isStreaming && content) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content,
|
||||
sources: sources.length > 0 ? sources : null,
|
||||
},
|
||||
]);
|
||||
reset();
|
||||
}
|
||||
}, [isStreaming, content]);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, content]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-[250px_1fr] gap-4 h-[calc(100vh-250px)]">
|
||||
{/* Sidebar - Conversation list */}
|
||||
<div className="border rounded-lg p-3 space-y-2 overflow-auto">
|
||||
<Button
|
||||
className="w-full"
|
||||
size="sm"
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />새 대화
|
||||
</Button>
|
||||
{conversations.map((conv) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={`flex items-center gap-2 p-2 rounded cursor-pointer text-sm ${
|
||||
activeConvId === conv.id ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => loadConversation(conv.id)}
|
||||
>
|
||||
<span className="flex-1 truncate">{conv.title || "새 대화"}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(conv.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex flex-col border rounded-lg">
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>안전 관련 질문을 입력하세요.</p>
|
||||
<p className="text-sm mt-1">
|
||||
업로드된 문서를 기반으로 답변합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<div
|
||||
className={`flex ${
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
{msg.sources && msg.sources.length > 0 && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
{msg.sources.map((s, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
title={s.content}
|
||||
>
|
||||
출처 {i + 1} ({(s.score * 100).toFixed(0)}%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg px-4 py-2 text-sm bg-muted">
|
||||
<div className="whitespace-pre-wrap">
|
||||
{content || <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center text-sm text-destructive">
|
||||
오류: {error}
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="질문을 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
|
||||
className="min-h-10 max-h-32 resize-none"
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
size="icon"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
src/components/rag/document-manager.tsx
Normal file
167
src/components/rag/document-manager.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Upload, Trash2, FileText, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DOC_CATEGORY_LABELS } from "@/lib/constants";
|
||||
import { uploadDocument, deleteDocument } from "@/actions/rag";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { ragDocuments } from "@/lib/db/schema";
|
||||
|
||||
type Doc = InferSelectModel<typeof ragDocuments>;
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "대기",
|
||||
processing: "처리 중",
|
||||
completed: "완료",
|
||||
failed: "실패",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
processing: "bg-blue-100 text-blue-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
failed: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export function DocumentManager({ documents }: { documents: Doc[] }) {
|
||||
const router = useRouter();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [category, setCategory] = useState("other");
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("category", category);
|
||||
await uploadDocument(formData);
|
||||
}
|
||||
setUploading(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleDelete(docId: string, name: string) {
|
||||
if (!confirm(`"${name}" 문서를 삭제하시겠습니까?`)) return;
|
||||
await deleteDocument(docId);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | null) {
|
||||
if (!bytes) return "-";
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">문서 업로드</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DOC_CATEGORY_LABELS).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.hwp,.hwpx,.docx,.doc,.txt,.xlsx,.pptx"
|
||||
multiple
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button asChild disabled={uploading}>
|
||||
<span>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{uploading ? "업로드 중..." : "파일 선택"}
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
PDF, HWP, DOCX, TXT 등
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
업로드된 문서가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<Card key={doc.id}>
|
||||
<CardContent className="flex items-center gap-4 py-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{doc.originalName}
|
||||
</p>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{DOC_CATEGORY_LABELS[doc.category]}</span>
|
||||
<span>{formatSize(doc.fileSize)}</span>
|
||||
{doc.chunkCount !== null && doc.chunkCount > 0 && (
|
||||
<span>{doc.chunkCount} 청크</span>
|
||||
)}
|
||||
<span>{new Date(doc.createdAt).toLocaleDateString("ko-KR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className={statusColors[doc.status]}
|
||||
variant="outline"
|
||||
>
|
||||
{doc.status === "processing" && (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
)}
|
||||
{statusLabels[doc.status]}
|
||||
</Badge>
|
||||
{doc.errorMessage && (
|
||||
<span className="text-xs text-destructive max-w-32 truncate">
|
||||
{doc.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleDelete(doc.id, doc.originalName)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/risk-assessment/ai-controls-panel.tsx
Normal file
67
src/components/risk-assessment/ai-controls-panel.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useStreaming } from "@/hooks/use-streaming";
|
||||
|
||||
export function AiControlsPanel({
|
||||
hazard,
|
||||
consequence,
|
||||
}: {
|
||||
hazard: string;
|
||||
consequence?: string;
|
||||
}) {
|
||||
const { isStreaming, content, error, streamChat, reset } = useStreaming();
|
||||
|
||||
async function handleSuggest() {
|
||||
reset();
|
||||
await streamChat("/api/ai/suggest-controls", { hazard, consequence });
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
대책 제안
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] sm:max-w-[500px] overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>AI 감소대책 제안</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-muted rounded-lg text-sm">
|
||||
<p className="font-medium">유해위험요인:</p>
|
||||
<p>{hazard}</p>
|
||||
{consequence && (
|
||||
<>
|
||||
<p className="font-medium mt-2">예상 재해:</p>
|
||||
<p>{consequence}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSuggest} disabled={isStreaming}>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isStreaming ? "생성 중..." : "대책 생성"}
|
||||
</Button>
|
||||
|
||||
{content && (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm">{content}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
134
src/components/risk-assessment/ai-hazard-panel.tsx
Normal file
134
src/components/risk-assessment/ai-hazard-panel.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles, Plus, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { getRiskGrade } from "@/lib/constants";
|
||||
import { addHazard } from "@/actions/risk-assessment";
|
||||
|
||||
interface SuggestedHazard {
|
||||
category: string;
|
||||
hazard: string;
|
||||
consequence: string;
|
||||
severity: number;
|
||||
likelihood: number;
|
||||
existingControls: string;
|
||||
}
|
||||
|
||||
export function AiHazardPanel({ assessmentId }: { assessmentId: string }) {
|
||||
const router = useRouter();
|
||||
const [activity, setActivity] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SuggestedHazard[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSuggest() {
|
||||
if (!activity.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/suggest-hazards", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activity }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
setSuggestions(data.hazards || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "AI 오류");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleAdd(hazard: SuggestedHazard) {
|
||||
await addHazard(assessmentId, {
|
||||
category: hazard.category,
|
||||
activity: activity,
|
||||
hazard: hazard.hazard,
|
||||
consequence: hazard.consequence,
|
||||
severity: hazard.severity,
|
||||
likelihood: hazard.likelihood,
|
||||
existingControls: hazard.existingControls,
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
AI 추천
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] sm:max-w-[500px] overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>AI 유해위험요인 추천</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={activity}
|
||||
onChange={(e) => setActivity(e.target.value)}
|
||||
placeholder="작업/공정을 설명하세요. 예: 5층 건물 외벽 페인트 작업, 비계 사용"
|
||||
rows={3}
|
||||
/>
|
||||
<Button onClick={handleSuggest} disabled={loading || !activity.trim()}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{loading ? "분석 중..." : "위험요인 분석"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{suggestions.map((h, i) => {
|
||||
const risk = h.severity * h.likelihood;
|
||||
const grade = getRiskGrade(risk);
|
||||
return (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline">{h.category}</Badge>
|
||||
<Badge className={`${grade.color} ${grade.textColor} border`} variant="outline">
|
||||
{risk} ({grade.label})
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{h.hazard}</p>
|
||||
{h.consequence && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
예상 재해: {h.consequence}
|
||||
</p>
|
||||
)}
|
||||
{h.existingControls && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
기존 조치: {h.existingControls}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>S={h.severity} x L={h.likelihood}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAdd(h)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
231
src/components/risk-assessment/assessment-detail.tsx
Normal file
231
src/components/risk-assessment/assessment-detail.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Printer,
|
||||
Save,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ASSESSMENT_STATUS_LABELS } from "@/lib/constants";
|
||||
import { updateAssessment } from "@/actions/risk-assessment";
|
||||
import { HazardTable } from "./hazard-table";
|
||||
import { HazardForm } from "./hazard-form";
|
||||
import { RiskMatrix } from "./risk-matrix";
|
||||
import { AiHazardPanel } from "./ai-hazard-panel";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { riskAssessments, riskAssessmentHazards } from "@/lib/db/schema";
|
||||
|
||||
type Assessment = InferSelectModel<typeof riskAssessments> & {
|
||||
hazards: InferSelectModel<typeof riskAssessmentHazards>[];
|
||||
};
|
||||
|
||||
export function AssessmentDetail({
|
||||
assessment,
|
||||
}: {
|
||||
assessment: Assessment;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleStatusChange(status: string) {
|
||||
await updateAssessment(assessment.id, {
|
||||
status: status as Assessment["status"],
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const form = new FormData(e.currentTarget);
|
||||
await updateAssessment(assessment.id, {
|
||||
title: form.get("title") as string,
|
||||
department: form.get("department") as string,
|
||||
location: form.get("location") as string,
|
||||
assessor: form.get("assessor") as string,
|
||||
description: form.get("description") as string,
|
||||
});
|
||||
setEditing(false);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const statusFlow: Record<string, string | null> = {
|
||||
draft: "review",
|
||||
review: "approved",
|
||||
approved: "archived",
|
||||
archived: null,
|
||||
};
|
||||
const nextStatus = statusFlow[assessment.status];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/risk-assessment">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{assessment.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary">
|
||||
{ASSESSMENT_STATUS_LABELS[assessment.status]}
|
||||
</Badge>
|
||||
{assessment.department && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{assessment.department}
|
||||
</span>
|
||||
)}
|
||||
{assessment.location && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{assessment.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{nextStatus && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStatusChange(nextStatus)}
|
||||
>
|
||||
{ASSESSMENT_STATUS_LABELS[nextStatus]}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
{editing ? "취소" : "수정"}
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={`/risk-assessment/${assessment.id}/print`}
|
||||
target="_blank"
|
||||
>
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
인쇄
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보 수정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={assessment.title}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Input
|
||||
id="department"
|
||||
name="department"
|
||||
defaultValue={assessment.department || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">장소</Label>
|
||||
<Input
|
||||
id="location"
|
||||
name="location"
|
||||
defaultValue={assessment.location || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assessor">평가자</Label>
|
||||
<Input
|
||||
id="assessor"
|
||||
name="assessor"
|
||||
defaultValue={assessment.assessor || ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={assessment.description || ""}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
유해위험요인 ({assessment.hazards.length}건)
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<AiHazardPanel assessmentId={assessment.id} />
|
||||
<Button onClick={() => setShowAddForm(!showAddForm)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
위험요인 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<HazardForm
|
||||
assessmentId={assessment.id}
|
||||
onComplete={() => {
|
||||
setShowAddForm(false);
|
||||
router.refresh();
|
||||
}}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HazardTable hazards={assessment.hazards} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RiskMatrix hazards={assessment.hazards} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/risk-assessment/assessment-list.tsx
Normal file
101
src/components/risk-assessment/assessment-list.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ASSESSMENT_STATUS_LABELS, getRiskGrade } from "@/lib/constants";
|
||||
import { deleteAssessment } from "@/actions/risk-assessment";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { riskAssessments, riskAssessmentHazards } from "@/lib/db/schema";
|
||||
|
||||
type Assessment = InferSelectModel<typeof riskAssessments> & {
|
||||
hazards: InferSelectModel<typeof riskAssessmentHazards>[];
|
||||
};
|
||||
|
||||
export function AssessmentList({
|
||||
assessments,
|
||||
}: {
|
||||
assessments: Assessment[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
if (assessments.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
아직 위험성평가가 없습니다.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/risk-assessment/new">첫 평가 작성하기</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, title: string) {
|
||||
if (!confirm(`"${title}" 평가를 삭제하시겠습니까?`)) return;
|
||||
await deleteAssessment(id);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{assessments.map((a) => {
|
||||
const maxRisk = a.hazards.reduce(
|
||||
(max, h) => Math.max(max, h.riskLevel),
|
||||
0
|
||||
);
|
||||
const grade = maxRisk > 0 ? getRiskGrade(maxRisk) : null;
|
||||
|
||||
return (
|
||||
<Card key={a.id} className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Link
|
||||
href={`/risk-assessment/${a.id}`}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-semibold truncate">{a.title}</h3>
|
||||
<Badge variant="secondary">
|
||||
{ASSESSMENT_STATUS_LABELS[a.status]}
|
||||
</Badge>
|
||||
{grade && (
|
||||
<Badge
|
||||
className={`${grade.color} ${grade.textColor} border`}
|
||||
variant="outline"
|
||||
>
|
||||
{grade.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
{a.department && <span>{a.department}</span>}
|
||||
{a.location && <span>{a.location}</span>}
|
||||
<span>위험요인 {a.hazards.length}건</span>
|
||||
<span>
|
||||
{new Date(a.createdAt).toLocaleDateString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(a.id, a.title)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Link href={`/risk-assessment/${a.id}`}>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/components/risk-assessment/hazard-form.tsx
Normal file
206
src/components/risk-assessment/hazard-form.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getRiskGrade, SEVERITY_LABELS, LIKELIHOOD_LABELS } from "@/lib/constants";
|
||||
import { addHazard } from "@/actions/risk-assessment";
|
||||
|
||||
const HAZARD_CATEGORIES = [
|
||||
"추락",
|
||||
"전도",
|
||||
"협착",
|
||||
"감전",
|
||||
"화재/폭발",
|
||||
"화학물질",
|
||||
"소음",
|
||||
"분진",
|
||||
"인력운반",
|
||||
"절단/찔림",
|
||||
"충돌",
|
||||
"질식",
|
||||
"기타",
|
||||
];
|
||||
|
||||
export function HazardForm({
|
||||
assessmentId,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: {
|
||||
assessmentId: string;
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [severity, setSeverity] = useState(1);
|
||||
const [likelihood, setLikelihood] = useState(1);
|
||||
const riskLevel = severity * likelihood;
|
||||
const grade = getRiskGrade(riskLevel);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const form = new FormData(e.currentTarget);
|
||||
await addHazard(assessmentId, {
|
||||
category: form.get("category") as string,
|
||||
activity: form.get("activity") as string,
|
||||
hazard: form.get("hazard") as string,
|
||||
consequence: form.get("consequence") as string,
|
||||
severity,
|
||||
likelihood,
|
||||
existingControls: form.get("existingControls") as string,
|
||||
additionalControls: form.get("additionalControls") as string,
|
||||
responsible: form.get("responsible") as string,
|
||||
});
|
||||
onComplete();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-primary/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">유해위험요인 추가</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">위험 분류</Label>
|
||||
<Select name="category" defaultValue="기타">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HAZARD_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="activity">작업/공정 *</Label>
|
||||
<Input
|
||||
id="activity"
|
||||
name="activity"
|
||||
required
|
||||
placeholder="예: 고소작업 (비계 설치)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hazard">유해위험요인 *</Label>
|
||||
<Textarea
|
||||
id="hazard"
|
||||
name="hazard"
|
||||
required
|
||||
placeholder="예: 비계 상부에서 추락 위험"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="consequence">예상 재해</Label>
|
||||
<Input
|
||||
id="consequence"
|
||||
name="consequence"
|
||||
placeholder="예: 골절, 사망"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>중대성 (S)</Label>
|
||||
<Select
|
||||
value={String(severity)}
|
||||
onValueChange={(v) => setSeverity(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((v) => (
|
||||
<SelectItem key={v} value={String(v)}>
|
||||
{v} - {SEVERITY_LABELS[v]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>가능성 (L)</Label>
|
||||
<Select
|
||||
value={String(likelihood)}
|
||||
onValueChange={(v) => setLikelihood(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((v) => (
|
||||
<SelectItem key={v} value={String(v)}>
|
||||
{v} - {LIKELIHOOD_LABELS[v]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>위험성 (S x L)</Label>
|
||||
<div
|
||||
className={`flex items-center justify-center h-9 rounded-md border text-sm font-bold ${grade.color} ${grade.textColor}`}
|
||||
>
|
||||
{riskLevel} ({grade.label})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="existingControls">기존 안전조치</Label>
|
||||
<Textarea
|
||||
id="existingControls"
|
||||
name="existingControls"
|
||||
placeholder="현재 적용 중인 안전조치"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="additionalControls">추가 감소대책</Label>
|
||||
<Textarea
|
||||
id="additionalControls"
|
||||
name="additionalControls"
|
||||
placeholder="추가 필요한 위험성 감소대책"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="responsible">담당자</Label>
|
||||
<Input id="responsible" name="responsible" placeholder="담당자명" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "저장 중..." : "추가"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
108
src/components/risk-assessment/hazard-table.tsx
Normal file
108
src/components/risk-assessment/hazard-table.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getRiskGrade } from "@/lib/constants";
|
||||
import { deleteHazard } from "@/actions/risk-assessment";
|
||||
import { AiControlsPanel } from "./ai-controls-panel";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { riskAssessmentHazards } from "@/lib/db/schema";
|
||||
|
||||
type Hazard = InferSelectModel<typeof riskAssessmentHazards>;
|
||||
|
||||
export function HazardTable({ hazards }: { hazards: Hazard[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
if (hazards.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground border rounded-lg">
|
||||
등록된 유해위험요인이 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("이 위험요인을 삭제하시겠습니까?")) return;
|
||||
await deleteHazard(id);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">#</TableHead>
|
||||
<TableHead className="w-20">분류</TableHead>
|
||||
<TableHead>작업/공정</TableHead>
|
||||
<TableHead>유해위험요인</TableHead>
|
||||
<TableHead className="w-14 text-center">S</TableHead>
|
||||
<TableHead className="w-14 text-center">L</TableHead>
|
||||
<TableHead className="w-20 text-center">위험성</TableHead>
|
||||
<TableHead>기존 조치</TableHead>
|
||||
<TableHead>감소 대책</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hazards.map((h, i) => {
|
||||
const grade = getRiskGrade(h.riskLevel);
|
||||
return (
|
||||
<TableRow key={h.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{i + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{h.category || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{h.activity}</TableCell>
|
||||
<TableCell>{h.hazard}</TableCell>
|
||||
<TableCell className="text-center">{h.severity}</TableCell>
|
||||
<TableCell className="text-center">{h.likelihood}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
className={`${grade.color} ${grade.textColor} border`}
|
||||
variant="outline"
|
||||
>
|
||||
{h.riskLevel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-40 truncate">
|
||||
{h.existingControls || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-40">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate">{h.additionalControls || "-"}</span>
|
||||
<AiControlsPanel hazard={h.hazard} consequence={h.consequence || undefined} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleDelete(h.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/risk-assessment/print-view.tsx
Normal file
126
src/components/risk-assessment/print-view.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { getRiskGrade, ASSESSMENT_STATUS_LABELS } from "@/lib/constants";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { riskAssessments, riskAssessmentHazards } from "@/lib/db/schema";
|
||||
|
||||
type Assessment = InferSelectModel<typeof riskAssessments> & {
|
||||
hazards: InferSelectModel<typeof riskAssessmentHazards>[];
|
||||
};
|
||||
|
||||
export function PrintView({ assessment }: { assessment: Assessment }) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<title>{assessment.title} - 위험성평가표</title>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@media print {
|
||||
body { margin: 0; padding: 10mm; font-size: 10pt; }
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
}
|
||||
body { font-family: 'Malgun Gothic', sans-serif; color: #333; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th, td { border: 1px solid #333; padding: 6px 8px; text-align: center; font-size: 9pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; }
|
||||
.header { text-align: center; margin-bottom: 15px; }
|
||||
.header h1 { font-size: 16pt; margin: 0 0 5px 0; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 5px; margin-bottom: 10px; font-size: 9pt; }
|
||||
.info-grid div { border: 1px solid #ccc; padding: 4px 8px; }
|
||||
.info-label { font-weight: bold; background: #f9f9f9; }
|
||||
.risk-low { background: #dcfce7; }
|
||||
.risk-medium { background: #fef9c3; }
|
||||
.risk-high { background: #fed7aa; }
|
||||
.risk-critical { background: #fecaca; }
|
||||
td.left { text-align: left; }
|
||||
.print-btn { position: fixed; top: 10px; right: 10px; padding: 8px 20px; background: #333; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
@media print { .print-btn { display: none; } }
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<button className="print-btn" onClick={() => window.print()}>
|
||||
인쇄
|
||||
</button>
|
||||
<div className="header">
|
||||
<h1>위험성평가표</h1>
|
||||
<p>{assessment.title}</p>
|
||||
</div>
|
||||
<div className="info-grid">
|
||||
<div className="info-label">부서</div>
|
||||
<div>{assessment.department || "-"}</div>
|
||||
<div className="info-label">작업장소</div>
|
||||
<div>{assessment.location || "-"}</div>
|
||||
<div className="info-label">평가자</div>
|
||||
<div>{assessment.assessor || "-"}</div>
|
||||
<div className="info-label">평가일</div>
|
||||
<div>
|
||||
{assessment.assessDate
|
||||
? new Date(assessment.assessDate).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="info-label">상태</div>
|
||||
<div>{ASSESSMENT_STATUS_LABELS[assessment.status]}</div>
|
||||
<div className="info-label">버전</div>
|
||||
<div>v{assessment.version}</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "3%" }}>No</th>
|
||||
<th style={{ width: "7%" }}>분류</th>
|
||||
<th style={{ width: "12%" }}>작업/공정</th>
|
||||
<th style={{ width: "18%" }}>유해위험요인</th>
|
||||
<th style={{ width: "8%" }}>예상 재해</th>
|
||||
<th style={{ width: "3%" }}>S</th>
|
||||
<th style={{ width: "3%" }}>L</th>
|
||||
<th style={{ width: "5%" }}>위험성</th>
|
||||
<th style={{ width: "15%" }}>기존 안전조치</th>
|
||||
<th style={{ width: "15%" }}>감소대책</th>
|
||||
<th style={{ width: "3%" }}>S'</th>
|
||||
<th style={{ width: "3%" }}>L'</th>
|
||||
<th style={{ width: "5%" }}>잔류</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assessment.hazards.map((h, i) => {
|
||||
const grade = getRiskGrade(h.riskLevel);
|
||||
const reducedGrade = h.reducedRiskLevel
|
||||
? getRiskGrade(h.reducedRiskLevel)
|
||||
: null;
|
||||
return (
|
||||
<tr key={h.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{h.category || "-"}</td>
|
||||
<td className="left">{h.activity}</td>
|
||||
<td className="left">{h.hazard}</td>
|
||||
<td>{h.consequence || "-"}</td>
|
||||
<td>{h.severity}</td>
|
||||
<td>{h.likelihood}</td>
|
||||
<td className={`risk-${grade.grade}`}>{h.riskLevel}</td>
|
||||
<td className="left">{h.existingControls || "-"}</td>
|
||||
<td className="left">{h.additionalControls || "-"}</td>
|
||||
<td>{h.reducedSeverity || "-"}</td>
|
||||
<td>{h.reducedLikelihood || "-"}</td>
|
||||
<td
|
||||
className={
|
||||
reducedGrade ? `risk-${reducedGrade.grade}` : ""
|
||||
}
|
||||
>
|
||||
{h.reducedRiskLevel || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{assessment.hazards.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={13}>등록된 유해위험요인이 없습니다.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
92
src/components/risk-assessment/risk-matrix.tsx
Normal file
92
src/components/risk-assessment/risk-matrix.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getRiskGrade, SEVERITY_LABELS, LIKELIHOOD_LABELS } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { riskAssessmentHazards } from "@/lib/db/schema";
|
||||
|
||||
type Hazard = InferSelectModel<typeof riskAssessmentHazards>;
|
||||
|
||||
export function RiskMatrix({ hazards }: { hazards: Hazard[] }) {
|
||||
// Count hazards per cell
|
||||
const cellCounts: Record<string, number> = {};
|
||||
hazards.forEach((h) => {
|
||||
const key = `${h.severity}-${h.likelihood}`;
|
||||
cellCounts[key] = (cellCounts[key] || 0) + 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">위험성 매트릭스 (5x5)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-center text-muted-foreground mb-1">
|
||||
중대성 (S) →
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-0.5 text-xs">
|
||||
<div className="flex items-center justify-center text-muted-foreground rotate-0 h-8">
|
||||
L↓\S→
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className="flex items-center justify-center h-8 font-medium"
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
{[5, 4, 3, 2, 1].map((l) => (
|
||||
<>
|
||||
<div
|
||||
key={`label-${l}`}
|
||||
className="flex items-center justify-center h-10 font-medium"
|
||||
>
|
||||
{l}
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((s) => {
|
||||
const risk = s * l;
|
||||
const grade = getRiskGrade(risk);
|
||||
const count = cellCounts[`${s}-${l}`] || 0;
|
||||
return (
|
||||
<div
|
||||
key={`${s}-${l}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center h-10 rounded text-xs font-bold border",
|
||||
grade.color,
|
||||
grade.textColor,
|
||||
count > 0 && "ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
{count > 0 ? count : risk}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3 text-xs justify-center flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-green-100 border border-green-300" />
|
||||
저위험(1-4)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-yellow-100 border border-yellow-300" />
|
||||
보통(5-9)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-orange-100 border border-orange-300" />
|
||||
고위험(10-16)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-red-100 border border-red-300" />
|
||||
매우위험(17-25)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
109
src/components/ui/avatar.tsx
Normal file
109
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarBadge,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
}
|
||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
220
src/components/ui/calendar.tsx
Normal file
220
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
89
src/components/ui/popover.tsx
Normal file
89
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
143
src/components/ui/sheet.tsx
Normal file
143
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
91
src/components/ui/tabs.tsx
Normal file
91
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
92
src/hooks/use-streaming.ts
Normal file
92
src/hooks/use-streaming.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface Source {
|
||||
content: string;
|
||||
score: number;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
isStreaming: boolean;
|
||||
content: string;
|
||||
sources: Source[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useStreaming() {
|
||||
const [state, setState] = useState<StreamState>({
|
||||
isStreaming: false,
|
||||
content: "",
|
||||
sources: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const streamChat = useCallback(
|
||||
async (url: string, body: Record<string, unknown>) => {
|
||||
setState({ isStreaming: true, content: "", sources: [], error: null });
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No reader");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.type === "token") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
content: prev.content + data.content,
|
||||
}));
|
||||
} else if (data.type === "sources") {
|
||||
setState((prev) => ({ ...prev, sources: data.sources }));
|
||||
} else if (data.type === "error") {
|
||||
setState((prev) => ({ ...prev, error: data.error }));
|
||||
} else if (data.type === "done") {
|
||||
setState((prev) => ({ ...prev, isStreaming: false }));
|
||||
}
|
||||
} catch {
|
||||
// skip malformed data
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isStreaming: false,
|
||||
error: err instanceof Error ? err.message : "Stream error",
|
||||
}));
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isStreaming: false }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ isStreaming: false, content: "", sources: [], error: null });
|
||||
}, []);
|
||||
|
||||
return { ...state, streamChat, reset };
|
||||
}
|
||||
59
src/lib/ai/chunking.ts
Normal file
59
src/lib/ai/chunking.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
const CHUNK_SIZE = 1500;
|
||||
const CHUNK_OVERLAP = 200;
|
||||
|
||||
// Korean sentence boundary patterns
|
||||
const SENTENCE_ENDINGS = /([.!?。]\s*|\n\n)/;
|
||||
|
||||
export function chunkText(
|
||||
text: string,
|
||||
chunkSize: number = CHUNK_SIZE,
|
||||
overlap: number = CHUNK_OVERLAP
|
||||
): string[] {
|
||||
// Clean up text
|
||||
const cleaned = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (cleaned.length <= chunkSize) {
|
||||
return [cleaned];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < cleaned.length) {
|
||||
let end = start + chunkSize;
|
||||
|
||||
if (end >= cleaned.length) {
|
||||
chunks.push(cleaned.slice(start).trim());
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to find a sentence boundary near the end
|
||||
const searchWindow = cleaned.slice(
|
||||
Math.max(start, end - 200),
|
||||
end + 100
|
||||
);
|
||||
const match = searchWindow.match(SENTENCE_ENDINGS);
|
||||
if (match && match.index !== undefined) {
|
||||
const boundaryOffset = Math.max(start, end - 200) + match.index + match[0].length;
|
||||
if (boundaryOffset > start && boundaryOffset <= end + 100) {
|
||||
end = boundaryOffset;
|
||||
}
|
||||
}
|
||||
|
||||
const chunk = cleaned.slice(start, end).trim();
|
||||
if (chunk.length > 0) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
start = end - overlap;
|
||||
if (start <= 0) {
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks.filter((c) => c.length > 50);
|
||||
}
|
||||
37
src/lib/ai/embeddings.ts
Normal file
37
src/lib/ai/embeddings.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const EMBEDDING_BASE_URL =
|
||||
process.env.EMBEDDING_BASE_URL || "http://100.111.160.84:11434";
|
||||
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || "bge-m3";
|
||||
|
||||
export async function getEmbedding(text: string): Promise<number[]> {
|
||||
const res = await fetch(`${EMBEDDING_BASE_URL}/api/embed`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Embedding error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.embeddings[0];
|
||||
}
|
||||
|
||||
export async function getEmbeddings(texts: string[]): Promise<number[][]> {
|
||||
// Batch in groups of 10
|
||||
const results: number[][] = [];
|
||||
for (let i = 0; i < texts.length; i += 10) {
|
||||
const batch = texts.slice(i, i + 10);
|
||||
const res = await fetch(`${EMBEDDING_BASE_URL}/api/embed`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: batch,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Embedding error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
results.push(...data.embeddings);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
140
src/lib/ai/ollama.ts
Normal file
140
src/lib/ai/ollama.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
const OLLAMA_BASE_URL =
|
||||
process.env.OLLAMA_BASE_URL || "http://localhost:11434";
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "qwen3.5:35b-a3b";
|
||||
|
||||
export interface OllamaGenerateOptions {
|
||||
model?: string;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
stream?: boolean;
|
||||
format?: "json";
|
||||
options?: {
|
||||
temperature?: number;
|
||||
num_predict?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function ollamaGenerate(
|
||||
opts: OllamaGenerateOptions
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${OLLAMA_BASE_URL}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: opts.model || OLLAMA_MODEL,
|
||||
prompt: opts.prompt,
|
||||
system: opts.system,
|
||||
stream: false,
|
||||
format: opts.format,
|
||||
options: opts.options,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.response;
|
||||
}
|
||||
|
||||
export async function* ollamaStream(
|
||||
opts: OllamaGenerateOptions
|
||||
): AsyncGenerator<string> {
|
||||
const res = await fetch(`${OLLAMA_BASE_URL}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: opts.model || OLLAMA_MODEL,
|
||||
prompt: opts.prompt,
|
||||
system: opts.system,
|
||||
stream: true,
|
||||
options: opts.options,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.response) yield json.response;
|
||||
if (json.done) return;
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ollamaChat(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
opts?: { model?: string; system?: string; stream?: boolean }
|
||||
): Promise<string> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: opts?.model || OLLAMA_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (opts?.system) {
|
||||
body.messages = [{ role: "system", content: opts.system }, ...messages];
|
||||
}
|
||||
const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ollama chat error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.message?.content || "";
|
||||
}
|
||||
|
||||
export async function* ollamaChatStream(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
opts?: { model?: string; system?: string }
|
||||
): AsyncGenerator<string> {
|
||||
const allMessages = opts?.system
|
||||
? [{ role: "system", content: opts.system }, ...messages]
|
||||
: messages;
|
||||
|
||||
const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: opts?.model || OLLAMA_MODEL,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ollama chat error: ${res.status}`);
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.content) yield json.message.content;
|
||||
if (json.done) return;
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/lib/ai/prompts.ts
Normal file
92
src/lib/ai/prompts.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export const SYSTEM_PROMPT_RAG = `당신은 산업안전보건 전문가 AI 어시스턴트입니다.
|
||||
제공된 문서 컨텍스트를 기반으로 정확하고 전문적인 답변을 합니다.
|
||||
|
||||
규칙:
|
||||
1. 컨텍스트에 있는 정보만 사용하여 답변하세요.
|
||||
2. 확실하지 않은 내용은 "제공된 문서에서 관련 정보를 찾을 수 없습니다"라고 답하세요.
|
||||
3. 법령이나 규정을 인용할 때는 정확한 조항을 명시하세요.
|
||||
4. 답변은 한국어로 작성하세요.
|
||||
5. /no_think`;
|
||||
|
||||
export const SYSTEM_PROMPT_HAZARD = `당신은 산업안전보건 위험성평가 전문가입니다.
|
||||
주어진 작업 설명과 참고 문서를 바탕으로 유해위험요인을 분석합니다.
|
||||
|
||||
반드시 JSON 배열로 응답하세요. 각 항목은 다음 필드를 포함합니다:
|
||||
- category: 위험 분류 (예: 추락, 전도, 협착, 감전, 화재/폭발, 화학물질, 소음, 분진, 인력운반, 기타)
|
||||
- hazard: 유해위험요인 설명
|
||||
- consequence: 예상 피해/재해 결과
|
||||
- severity: 중대성 (1-5)
|
||||
- likelihood: 가능성 (1-5)
|
||||
- existingControls: 기존 안전조치
|
||||
|
||||
응답 형식:
|
||||
\`\`\`json
|
||||
[{"category":"추락","hazard":"...","consequence":"...","severity":4,"likelihood":3,"existingControls":"..."}]
|
||||
\`\`\`
|
||||
/no_think`;
|
||||
|
||||
export const SYSTEM_PROMPT_CONTROLS = `당신은 산업안전보건 전문가입니다.
|
||||
주어진 유해위험요인에 대해 위험성 감소대책을 계층별로 제안합니다.
|
||||
|
||||
위험성 감소대책 계층 (Hierarchy of Controls):
|
||||
1. 제거 (Elimination)
|
||||
2. 대체 (Substitution)
|
||||
3. 공학적 대책 (Engineering Controls)
|
||||
4. 관리적 대책 (Administrative Controls)
|
||||
5. 개인보호구 (PPE)
|
||||
|
||||
각 계층별로 구체적이고 실행 가능한 대책을 한국어로 제안하세요.
|
||||
/no_think`;
|
||||
|
||||
export const SYSTEM_PROMPT_CHECKLIST = `당신은 산업안전보건 점검 전문가입니다.
|
||||
주어진 작업환경 설명과 참고 문서를 바탕으로 안전점검 체크리스트 항목을 생성합니다.
|
||||
|
||||
반드시 JSON 배열로 응답하세요. 각 항목은 다음 필드를 포함합니다:
|
||||
- category: 점검 분류 (예: 작업환경, 안전장치, 보호구, 전기, 화재, 정리정돈)
|
||||
- content: 점검 항목 내용
|
||||
- standard: 판정 기준/근거
|
||||
|
||||
응답 형식:
|
||||
\`\`\`json
|
||||
[{"category":"작업환경","content":"...","standard":"..."}]
|
||||
\`\`\`
|
||||
/no_think`;
|
||||
|
||||
export function buildRagPrompt(question: string, contexts: string[]): string {
|
||||
const contextBlock = contexts
|
||||
.map((c, i) => `[문서 ${i + 1}]\n${c}`)
|
||||
.join("\n\n---\n\n");
|
||||
return `다음 문서 내용을 참고하여 질문에 답하세요.
|
||||
|
||||
${contextBlock}
|
||||
|
||||
질문: ${question}
|
||||
|
||||
답변:`;
|
||||
}
|
||||
|
||||
export function buildHazardPrompt(
|
||||
activity: string,
|
||||
contexts: string[]
|
||||
): string {
|
||||
const contextBlock =
|
||||
contexts.length > 0
|
||||
? `\n참고 문서:\n${contexts.join("\n---\n")}\n`
|
||||
: "";
|
||||
return `작업 설명: ${activity}
|
||||
${contextBlock}
|
||||
위 작업에서 발생할 수 있는 유해위험요인을 분석하여 JSON 배열로 응답하세요.`;
|
||||
}
|
||||
|
||||
export function buildChecklistPrompt(
|
||||
environment: string,
|
||||
contexts: string[]
|
||||
): string {
|
||||
const contextBlock =
|
||||
contexts.length > 0
|
||||
? `\n참고 문서:\n${contexts.join("\n---\n")}\n`
|
||||
: "";
|
||||
return `작업환경 설명: ${environment}
|
||||
${contextBlock}
|
||||
위 작업환경에 적합한 안전점검 체크리스트 항목을 JSON 배열로 생성하세요.`;
|
||||
}
|
||||
111
src/lib/ai/qdrant.ts
Normal file
111
src/lib/ai/qdrant.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
|
||||
const QDRANT_COLLECTION = process.env.QDRANT_COLLECTION || "safety-docs";
|
||||
|
||||
interface QdrantPoint {
|
||||
id: string;
|
||||
vector: number[];
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface QdrantSearchResult {
|
||||
id: string;
|
||||
score: number;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function ensureCollection(
|
||||
vectorSize: number = 1024
|
||||
): Promise<void> {
|
||||
// Check if collection exists
|
||||
const checkRes = await fetch(
|
||||
`${QDRANT_URL}/collections/${QDRANT_COLLECTION}`
|
||||
);
|
||||
if (checkRes.ok) return;
|
||||
|
||||
// Create collection with dense + sparse vectors for hybrid search
|
||||
const res = await fetch(`${QDRANT_URL}/collections/${QDRANT_COLLECTION}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
vectors: {
|
||||
size: vectorSize,
|
||||
distance: "Cosine",
|
||||
},
|
||||
sparse_vectors: {
|
||||
bm25: {
|
||||
modifier: "idf",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Qdrant create collection error: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertPoints(points: QdrantPoint[]): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${QDRANT_URL}/collections/${QDRANT_COLLECTION}/points`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
points: points.map((p) => ({
|
||||
id: p.id,
|
||||
vector: p.vector,
|
||||
payload: p.payload,
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Qdrant upsert error: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchPoints(
|
||||
vector: number[],
|
||||
limit: number = 5,
|
||||
filter?: Record<string, unknown>
|
||||
): Promise<QdrantSearchResult[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true,
|
||||
};
|
||||
if (filter) body.filter = filter;
|
||||
|
||||
const res = await fetch(
|
||||
`${QDRANT_URL}/collections/${QDRANT_COLLECTION}/points/search`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Qdrant search error: ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function deletePointsByFilter(
|
||||
filter: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${QDRANT_URL}/collections/${QDRANT_COLLECTION}/points/delete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filter }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Qdrant delete error: ${text}`);
|
||||
}
|
||||
}
|
||||
45
src/lib/ai/tika.ts
Normal file
45
src/lib/ai/tika.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const TIKA_URL = process.env.TIKA_URL || "http://localhost:9998";
|
||||
|
||||
export async function extractText(
|
||||
fileBuffer: Uint8Array,
|
||||
mimeType?: string
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/plain",
|
||||
};
|
||||
if (mimeType) {
|
||||
headers["Content-Type"] = mimeType;
|
||||
}
|
||||
|
||||
const res = await fetch(`${TIKA_URL}/tika`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: fileBuffer as unknown as BodyInit,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Tika extraction error: ${res.status}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function extractMetadata(
|
||||
fileBuffer: Uint8Array,
|
||||
mimeType?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (mimeType) {
|
||||
headers["Content-Type"] = mimeType;
|
||||
}
|
||||
|
||||
const res = await fetch(`${TIKA_URL}/meta`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: fileBuffer as unknown as BodyInit,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Tika metadata error: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
78
src/lib/auth.ts
Normal file
78
src/lib/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import crypto from "crypto";
|
||||
|
||||
const SESSION_COOKIE = "tk-safety-session";
|
||||
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
function getSessionSecret(): string {
|
||||
return process.env.SESSION_SECRET || "fallback-secret-change-me";
|
||||
}
|
||||
|
||||
function createSessionToken(): string {
|
||||
const payload = {
|
||||
authenticated: true,
|
||||
iat: Date.now(),
|
||||
};
|
||||
const data = JSON.stringify(payload);
|
||||
const hmac = crypto
|
||||
.createHmac("sha256", getSessionSecret())
|
||||
.update(data)
|
||||
.digest("hex");
|
||||
return Buffer.from(`${data}.${hmac}`).toString("base64");
|
||||
}
|
||||
|
||||
function verifySessionToken(token: string): boolean {
|
||||
try {
|
||||
const decoded = Buffer.from(token, "base64").toString("utf-8");
|
||||
const lastDot = decoded.lastIndexOf(".");
|
||||
if (lastDot === -1) return false;
|
||||
const data = decoded.slice(0, lastDot);
|
||||
const hmac = decoded.slice(lastDot + 1);
|
||||
const expectedHmac = crypto
|
||||
.createHmac("sha256", getSessionSecret())
|
||||
.update(data)
|
||||
.digest("hex");
|
||||
if (hmac !== expectedHmac) return false;
|
||||
const payload = JSON.parse(data);
|
||||
const age = Date.now() - payload.iat;
|
||||
return age < SESSION_MAX_AGE * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<boolean> {
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || "changeme";
|
||||
if (password !== adminPassword) return false;
|
||||
|
||||
const token = createSessionToken();
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
path: "/",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return false;
|
||||
return verifySessionToken(token);
|
||||
}
|
||||
|
||||
export async function requireAuth(): Promise<void> {
|
||||
const authenticated = await isAuthenticated();
|
||||
if (!authenticated) {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
76
src/lib/constants.ts
Normal file
76
src/lib/constants.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export const APP_NAME = "TK 안전관리";
|
||||
export const APP_DESCRIPTION = "Technical Korea 안전관리 플랫폼";
|
||||
|
||||
export const RISK_MATRIX_SIZE = 5;
|
||||
|
||||
export const SEVERITY_LABELS: Record<number, string> = {
|
||||
1: "무시",
|
||||
2: "경미",
|
||||
3: "보통",
|
||||
4: "중대",
|
||||
5: "치명적",
|
||||
};
|
||||
|
||||
export const LIKELIHOOD_LABELS: Record<number, string> = {
|
||||
1: "거의 없음",
|
||||
2: "가끔",
|
||||
3: "보통",
|
||||
4: "자주",
|
||||
5: "매우 높음",
|
||||
};
|
||||
|
||||
export const RISK_COLORS: Record<string, string> = {
|
||||
low: "bg-green-500",
|
||||
medium: "bg-yellow-500",
|
||||
high: "bg-orange-500",
|
||||
critical: "bg-red-500",
|
||||
};
|
||||
|
||||
export function getRiskGrade(riskLevel: number): {
|
||||
grade: string;
|
||||
label: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
} {
|
||||
if (riskLevel <= 4) return { grade: "low", label: "저위험", color: "bg-green-100 border-green-300", textColor: "text-green-800" };
|
||||
if (riskLevel <= 9) return { grade: "medium", label: "보통", color: "bg-yellow-100 border-yellow-300", textColor: "text-yellow-800" };
|
||||
if (riskLevel <= 16) return { grade: "high", label: "고위험", color: "bg-orange-100 border-orange-300", textColor: "text-orange-800" };
|
||||
return { grade: "critical", label: "매우 위험", color: "bg-red-100 border-red-300", textColor: "text-red-800" };
|
||||
}
|
||||
|
||||
export const ASSESSMENT_STATUS_LABELS: Record<string, string> = {
|
||||
draft: "초안",
|
||||
review: "검토",
|
||||
approved: "승인",
|
||||
archived: "보관",
|
||||
};
|
||||
|
||||
export const NCR_STATUS_LABELS: Record<string, string> = {
|
||||
open: "미조치",
|
||||
corrective_action: "시정조치 중",
|
||||
verification: "확인 중",
|
||||
closed: "종결",
|
||||
};
|
||||
|
||||
export const INSPECTION_TYPE_LABELS: Record<string, string> = {
|
||||
daily: "일상점검",
|
||||
regular: "정기점검",
|
||||
special: "특별점검",
|
||||
equipment: "장비점검",
|
||||
};
|
||||
|
||||
export const DOC_CATEGORY_LABELS: Record<string, string> = {
|
||||
law: "산업안전보건법",
|
||||
kosha_guide: "KOSHA 가이드",
|
||||
internal: "사내규정",
|
||||
procedure: "안전작업절차서",
|
||||
other: "기타",
|
||||
};
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ href: "/dashboard", label: "대시보드", icon: "LayoutDashboard" },
|
||||
{ href: "/risk-assessment", label: "위험성평가", icon: "ShieldAlert" },
|
||||
{ href: "/rag", label: "안전 Q&A", icon: "MessageSquare" },
|
||||
{ href: "/checklists", label: "점검 체크리스트", icon: "ClipboardCheck" },
|
||||
{ href: "/settings", label: "설정", icon: "Settings" },
|
||||
] as const;
|
||||
8
src/lib/db/index.ts
Normal file
8
src/lib/db/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
314
src/lib/db/schema.ts
Normal file
314
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
serial,
|
||||
varchar,
|
||||
jsonb,
|
||||
boolean,
|
||||
decimal,
|
||||
uuid,
|
||||
pgEnum,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
// Enums
|
||||
export const assessmentStatusEnum = pgEnum("assessment_status", [
|
||||
"draft",
|
||||
"review",
|
||||
"approved",
|
||||
"archived",
|
||||
]);
|
||||
|
||||
export const ncrStatusEnum = pgEnum("ncr_status", [
|
||||
"open",
|
||||
"corrective_action",
|
||||
"verification",
|
||||
"closed",
|
||||
]);
|
||||
|
||||
export const inspectionTypeEnum = pgEnum("inspection_type", [
|
||||
"daily",
|
||||
"regular",
|
||||
"special",
|
||||
"equipment",
|
||||
]);
|
||||
|
||||
export const docCategoryEnum = pgEnum("doc_category", [
|
||||
"law",
|
||||
"kosha_guide",
|
||||
"internal",
|
||||
"procedure",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export const processingStatusEnum = pgEnum("processing_status", [
|
||||
"pending",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
// ─── 위험성평가 ───
|
||||
|
||||
export const riskAssessments = pgTable("risk_assessments", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
department: varchar("department", { length: 100 }),
|
||||
location: varchar("location", { length: 200 }),
|
||||
assessor: varchar("assessor", { length: 100 }),
|
||||
assessDate: timestamp("assess_date").defaultNow(),
|
||||
status: assessmentStatusEnum("status").default("draft").notNull(),
|
||||
version: integer("version").default(1).notNull(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const riskAssessmentHazards = pgTable("risk_assessment_hazards", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
assessmentId: uuid("assessment_id")
|
||||
.references(() => riskAssessments.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
category: varchar("category", { length: 100 }),
|
||||
activity: varchar("activity", { length: 255 }).notNull(),
|
||||
hazard: text("hazard").notNull(),
|
||||
consequence: text("consequence"),
|
||||
severity: integer("severity").notNull().default(1),
|
||||
likelihood: integer("likelihood").notNull().default(1),
|
||||
riskLevel: integer("risk_level").notNull().default(1),
|
||||
existingControls: text("existing_controls"),
|
||||
additionalControls: text("additional_controls"),
|
||||
reducedSeverity: integer("reduced_severity"),
|
||||
reducedLikelihood: integer("reduced_likelihood"),
|
||||
reducedRiskLevel: integer("reduced_risk_level"),
|
||||
responsible: varchar("responsible", { length: 100 }),
|
||||
dueDate: timestamp("due_date"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// ─── RAG 문서 ───
|
||||
|
||||
export const ragDocuments = pgTable("rag_documents", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
filename: varchar("filename", { length: 500 }).notNull(),
|
||||
originalName: varchar("original_name", { length: 500 }).notNull(),
|
||||
category: docCategoryEnum("category").default("other").notNull(),
|
||||
fileSize: integer("file_size"),
|
||||
mimeType: varchar("mime_type", { length: 100 }),
|
||||
pageCount: integer("page_count"),
|
||||
chunkCount: integer("chunk_count").default(0),
|
||||
status: processingStatusEnum("status").default("pending").notNull(),
|
||||
errorMessage: text("error_message"),
|
||||
metadata: jsonb("metadata"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const ragChunks = pgTable("rag_chunks", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
documentId: uuid("document_id")
|
||||
.references(() => ragDocuments.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
chunkIndex: integer("chunk_index").notNull(),
|
||||
content: text("content").notNull(),
|
||||
metadata: jsonb("metadata"),
|
||||
qdrantPointId: varchar("qdrant_point_id", { length: 100 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const ragConversations = pgTable("rag_conversations", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
title: varchar("title", { length: 255 }).default("새 대화"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const ragMessages = pgTable("rag_messages", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
conversationId: uuid("conversation_id")
|
||||
.references(() => ragConversations.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
role: varchar("role", { length: 20 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
sources: jsonb("sources"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// ─── 체크리스트 ───
|
||||
|
||||
export const checklistTemplates = pgTable("checklist_templates", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: inspectionTypeEnum("type").default("daily").notNull(),
|
||||
description: text("description"),
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const checklistTemplateItems = pgTable("checklist_template_items", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
templateId: uuid("template_id")
|
||||
.references(() => checklistTemplates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
category: varchar("category", { length: 100 }),
|
||||
content: text("content").notNull(),
|
||||
standard: text("standard"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const inspections = pgTable("inspections", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
templateId: uuid("template_id")
|
||||
.references(() => checklistTemplates.id)
|
||||
.notNull(),
|
||||
inspector: varchar("inspector", { length: 100 }),
|
||||
location: varchar("location", { length: 200 }),
|
||||
inspectionDate: timestamp("inspection_date").defaultNow().notNull(),
|
||||
notes: text("notes"),
|
||||
completedAt: timestamp("completed_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const inspectionResults = pgTable("inspection_results", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inspectionId: uuid("inspection_id")
|
||||
.references(() => inspections.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
templateItemId: uuid("template_item_id")
|
||||
.references(() => checklistTemplateItems.id)
|
||||
.notNull(),
|
||||
result: varchar("result", { length: 20 }),
|
||||
photo: varchar("photo", { length: 500 }),
|
||||
memo: text("memo"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const nonConformances = pgTable("non_conformances", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inspectionId: uuid("inspection_id")
|
||||
.references(() => inspections.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
inspectionResultId: uuid("inspection_result_id").references(
|
||||
() => inspectionResults.id
|
||||
),
|
||||
description: text("description").notNull(),
|
||||
severity: varchar("severity", { length: 20 }).default("medium"),
|
||||
status: ncrStatusEnum("status").default("open").notNull(),
|
||||
correctiveAction: text("corrective_action"),
|
||||
responsiblePerson: varchar("responsible_person", { length: 100 }),
|
||||
dueDate: timestamp("due_date"),
|
||||
closedAt: timestamp("closed_at"),
|
||||
closedBy: varchar("closed_by", { length: 100 }),
|
||||
photos: jsonb("photos").$type<string[]>(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// ─── 앱 설정 ───
|
||||
|
||||
export const appSettings = pgTable("app_settings", {
|
||||
id: serial("id").primaryKey(),
|
||||
key: varchar("key", { length: 100 }).notNull().unique(),
|
||||
value: text("value"),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// ─── Relations ───
|
||||
|
||||
export const riskAssessmentsRelations = relations(
|
||||
riskAssessments,
|
||||
({ many }) => ({
|
||||
hazards: many(riskAssessmentHazards),
|
||||
})
|
||||
);
|
||||
|
||||
export const riskAssessmentHazardsRelations = relations(
|
||||
riskAssessmentHazards,
|
||||
({ one }) => ({
|
||||
assessment: one(riskAssessments, {
|
||||
fields: [riskAssessmentHazards.assessmentId],
|
||||
references: [riskAssessments.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const ragDocumentsRelations = relations(ragDocuments, ({ many }) => ({
|
||||
chunks: many(ragChunks),
|
||||
}));
|
||||
|
||||
export const ragChunksRelations = relations(ragChunks, ({ one }) => ({
|
||||
document: one(ragDocuments, {
|
||||
fields: [ragChunks.documentId],
|
||||
references: [ragDocuments.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const ragConversationsRelations = relations(
|
||||
ragConversations,
|
||||
({ many }) => ({
|
||||
messages: many(ragMessages),
|
||||
})
|
||||
);
|
||||
|
||||
export const ragMessagesRelations = relations(ragMessages, ({ one }) => ({
|
||||
conversation: one(ragConversations, {
|
||||
fields: [ragMessages.conversationId],
|
||||
references: [ragConversations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const checklistTemplatesRelations = relations(
|
||||
checklistTemplates,
|
||||
({ many }) => ({
|
||||
items: many(checklistTemplateItems),
|
||||
inspections: many(inspections),
|
||||
})
|
||||
);
|
||||
|
||||
export const checklistTemplateItemsRelations = relations(
|
||||
checklistTemplateItems,
|
||||
({ one }) => ({
|
||||
template: one(checklistTemplates, {
|
||||
fields: [checklistTemplateItems.templateId],
|
||||
references: [checklistTemplates.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const inspectionsRelations = relations(inspections, ({ one, many }) => ({
|
||||
template: one(checklistTemplates, {
|
||||
fields: [inspections.templateId],
|
||||
references: [checklistTemplates.id],
|
||||
}),
|
||||
results: many(inspectionResults),
|
||||
nonConformances: many(nonConformances),
|
||||
}));
|
||||
|
||||
export const inspectionResultsRelations = relations(
|
||||
inspectionResults,
|
||||
({ one }) => ({
|
||||
inspection: one(inspections, {
|
||||
fields: [inspectionResults.inspectionId],
|
||||
references: [inspections.id],
|
||||
}),
|
||||
templateItem: one(checklistTemplateItems, {
|
||||
fields: [inspectionResults.templateItemId],
|
||||
references: [checklistTemplateItems.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const nonConformancesRelations = relations(
|
||||
nonConformances,
|
||||
({ one }) => ({
|
||||
inspection: one(inspections, {
|
||||
fields: [nonConformances.inspectionId],
|
||||
references: [inspections.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user