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:
Hyungi Ahn
2026-03-03 12:33:55 +09:00
parent 6e01d783c4
commit 2a9968fa7f
103 changed files with 14992 additions and 91 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.local
uploads
drizzle/migrations
*.md

24
.env.example Normal file
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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!,
},
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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");
}

View 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");
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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",
},
});
}

View 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 }
);
}
}

View 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",
},
});
}

View 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 }
);
}
}

View 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 });
}

View 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 });
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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 {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -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
View 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>
);
}

View File

@@ -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");
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;</th>
<th style={{ width: "3%" }}>L&apos;</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>
);
}

View 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>
);
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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
View 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
View 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,
}

View 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 }

View 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 }

View 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 }

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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