Compare commits
8 Commits
archive/st
...
10540a38b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 10540a38b4 | |||
| b67dc47dc7 | |||
| 9fcf4f2dc7 | |||
| 03b25bc273 | |||
| d0daa0efe8 | |||
| 51064f3a03 | |||
|
|
0ee8e723bd | ||
|
|
64724656af |
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"models": {
|
"models": {
|
||||||
"main": {
|
"main": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-opus-4-20250514",
|
"modelId": "opus",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 32000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
},
|
},
|
||||||
"research": {
|
"research": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-sonnet-4-20250514",
|
"modelId": "opus",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 32000,
|
||||||
"temperature": 0.1
|
"temperature": 0.1
|
||||||
},
|
},
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-sonnet-4-20250514",
|
"modelId": "sonnet",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 64000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
}
|
}
|
||||||
|
|||||||
3
.taskmaster/state.json
Normal file
3
.taskmaster/state.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"migrationNoticeShown": true
|
||||||
|
}
|
||||||
@@ -703,13 +703,190 @@
|
|||||||
"status": "deferred",
|
"status": "deferred",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-04-03T10:19:26.779Z"
|
"updatedAt": "2026-04-03T10:19:26.779Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "83",
|
||||||
|
"title": "Phase 1 — Project setup (legal-ai UI rewrite)",
|
||||||
|
"description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"details": "**השלמות (2026-04-11):**\n\n✅ **Scaffold:** Next.js 16.2.3 (חדש יותר מ-v15 שתוכנן), React 19.2.4, Tailwind v4, Turbopack.\n\n✅ **תלויות מותקנות** (`web-ui/package.json:11-21`):\n- @tanstack/react-query ^5.97.0\n- @tanstack/react-table ^8.21.3\n- react-hook-form ^7.72.1 + @hookform/resolvers ^5.2.2\n- zod ^4.3.6\n- lucide-react ^1.8.0\n- react-dropzone ^15.0.0\n- openapi-typescript ^7.13.0 (devDep)\n\n✅ **Design tokens** (`web-ui/src/app/globals.css:10-107`): Tailwind v4 @theme עם כל הצבעים (navy, cream, parchment, gold, ink, status colors), radii, shadows, fonts, dark mode preserved.\n\n✅ **RTL Hebrew** (`web-ui/src/app/layout.tsx:5-10, 23`): Heebo עם hebrew+latin subsets, `lang=\"he\" dir=\"rtl\"` on html.\n\n✅ **AppShell** (`web-ui/src/components/app-shell.tsx:29-70`): Navy header עם gold border-b-3, RTL nav, parchment body.\n\n✅ **Home page placeholder** (`web-ui/src/app/page.tsx`).\n\n✅ **Build:** `npm run build` עובר ב-3.8s, 0 errors, static.\n\n**נותר:** אישור ויזואלי של המשתמש עם `npm run dev`.\n\n**תוכנית מלאה:** `~/.claude/plans/joyful-marinating-sutton.md`",
|
||||||
|
"testStrategy": "1. `cd web-ui && npm run dev` — פתיחה ב-http://localhost:3000\n2. וידוא ויזואלי: Header navy עם gold rule, RTL rendering, פונט Heebo טעון\n3. השוואה ל-legal-ai.nautilus.marcusgroup.org — אותו מראה header\n4. בדיקת dark mode (toggle class על html)\n5. אישור סופי מהמשתמש",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "יצירת Next.js 16 scaffold עם TypeScript + Tailwind v4 + App Router",
|
||||||
|
"description": "הרצת create-next-app ב-web-ui/ עם App Router, TypeScript, Tailwind v4, ESLint",
|
||||||
|
"dependencies": [],
|
||||||
|
"details": "Next.js 16.2.3 (חדש יותר מ-v15), React 19.2.4, Tailwind v4, Turbopack. קבצים: web-ui/package.json, tsconfig.json, next.config.ts",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "npm run build succeeds",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "התקנת כל התלויות הנדרשות",
|
||||||
|
"description": "npm install של @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript",
|
||||||
|
"dependencies": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"details": "ראה web-ui/package.json:11-21 לגרסאות המותקנות. כולל openapi-typescript כ-devDep לשלב 2.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "npm ls shows all packages",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "העברת design tokens ל-Tailwind v4 @theme",
|
||||||
|
"description": "פורט מלא של design-system.css ל-globals.css עם Tailwind v4 @theme syntax",
|
||||||
|
"dependencies": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/app/globals.css:10-107 כולל כל הצבעים (navy, cream, parchment, gold, ink), status colors, radii, shadows, fonts, dark mode. CSS variables עובדים עם Tailwind classes.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "Tailwind classes like bg-navy, text-gold work correctly",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "הגדרת RTL Hebrew עם Heebo font",
|
||||||
|
"description": "next/font/google Heebo עם hebrew+latin, lang=he dir=rtl על html",
|
||||||
|
"dependencies": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/app/layout.tsx:5-10 — Heebo עם weights 300-900, display swap. שורה 23: html lang=he dir=rtl.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "Page renders RTL, Heebo font loaded",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "בניית AppShell component עם navy header + gold rule",
|
||||||
|
"description": "רכיב shell עם header navy, gold border, RTL nav, parchment body",
|
||||||
|
"dependencies": [
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/components/app-shell.tsx:29-70 — Header עם bg-navy, border-b-3 border-gold, nav links (בית, העלאת מסמכים, אימון סגנון, מיומנויות, אבחון), main content area עם max-w-1400px.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "Visual match to current header",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "יצירת דף בית placeholder",
|
||||||
|
"description": "דף page.tsx עם AppShell ו-placeholder content",
|
||||||
|
"dependencies": [
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/app/page.tsx:1-27 — דף בית עם כותרת 'עוזר משפטי', תיאור המערכת, gold gradient divider, כרטיס סטטוס.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "npm run build succeeds (done: 3.8s, 0 errors)",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "אישור ויזואלי מהמשתמש — npm run dev",
|
||||||
|
"description": "הרצת dev server ואישור סופי שה-UI תואם לציפיות — header, RTL, fonts, colors",
|
||||||
|
"dependencies": [
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"details": "המשתמש צריך להריץ 'cd web-ui && npm run dev' ולאשר שהכל נראה כמו legal-ai.nautilus.marcusgroup.org. בדיקת dark mode אופציונלית.",
|
||||||
|
"status": "pending",
|
||||||
|
"testStrategy": "User confirms visual parity with current site, RTL works, Heebo font loads",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-04-11T13:50:47.941Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "84",
|
||||||
|
"title": "Phase 2 — API client + generated TypeScript types",
|
||||||
|
"description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "useCases() hook returns typed array from live FastAPI. TypeScript errors if backend endpoint changes without frontend update.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"83"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T15:51:34.020Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "85",
|
||||||
|
"title": "Phase 3 — Core read views (home, case detail, compose)",
|
||||||
|
"description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "Users can browse case list, open a case detail, and view the compose screen with live data from FastAPI. All 3 screens visually match the existing legal-ai identity.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"84"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T16:09:18.006Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "86",
|
||||||
|
"title": "Phase 4 — Forms and wizards (new case, upload, inline edits)",
|
||||||
|
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "Users can create a new case via the multi-step wizard (case appears in Gitea + Paperclip), upload documents with live SSE progress, and edit case fields inline.",
|
||||||
|
"status": "in-progress",
|
||||||
|
"dependencies": [
|
||||||
|
"85"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T16:18:28.714Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "87",
|
||||||
|
"title": "Phase 5 — Secondary screens (compare, training, style report, skills, diagnostics)",
|
||||||
|
"description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [
|
||||||
|
"86"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "88",
|
||||||
|
"title": "Phase 6 — Polish & testing",
|
||||||
|
"description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "Lighthouse a11y score > 90, all loading states visible, errors show toasts, README has documented smoke test steps.",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [
|
||||||
|
"87"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "89",
|
||||||
|
"title": "Phase 7 — Deployment & cutover",
|
||||||
|
"description": "Add multi-stage Dockerfile for web-ui/ (Node 20 build -> nginx serve of out/). Add web-ui as new app in Coolify project pointing to staging subdomain legal-ai-next.nautilus.marcusgroup.org. Run full smoke test against staging. Cutover: DNS flip legal-ai.nautilus.marcusgroup.org to new app, keep old on rollback subdomain for 1 week. Follow-up PR removes legal-ai/web/static/index.html + design-system.css once stable. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 7 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "legal-ai.nautilus.marcusgroup.org serves the new Next.js UI in production. Old UI accessible on rollback subdomain for 7 days. SSE streams working through Coolify proxy.",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [
|
||||||
|
"88"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-04-04T07:50:59.999Z",
|
"lastModified": "2026-04-11T16:18:28.714Z",
|
||||||
"taskCount": 51,
|
"taskCount": 58,
|
||||||
"completedCount": 49,
|
"completedCount": 52,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
53
Dockerfile
53
Dockerfile
@@ -1,27 +1,40 @@
|
|||||||
FROM python:3.12-slim
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# Dockerfile — Next.js 16 web-ui (ui-rewrite branch only)
|
||||||
|
#
|
||||||
|
# This file REPLACES the FastAPI Dockerfile on this branch so that
|
||||||
|
# Coolify's default /Dockerfile lookup builds the new Next.js staging
|
||||||
|
# UI. The FastAPI Dockerfile lives on `main` and is unaffected.
|
||||||
|
#
|
||||||
|
# When the rewrite is merged to main, decide between:
|
||||||
|
# (a) keeping both via separate Dockerfiles + dockerfile_location config, or
|
||||||
|
# (b) a multi-stage Dockerfile that serves both, or
|
||||||
|
# (c) fully replacing FastAPI's StaticFiles with this Next.js front end.
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY web-ui/package.json web-ui/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
# System deps for PyMuPDF and document processing
|
FROM node:20-alpine AS builder
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
WORKDIR /app
|
||||||
gcc libmupdf-dev libfreetype6-dev libharfbuzz-dev libjpeg62-turbo-dev \
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
libopenjp2-7-dev curl git && rm -rf /var/lib/apt/lists/* && \
|
COPY web-ui/ ./
|
||||||
git config --global init.defaultBranch main
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Copy Ezer Mishpati MCP server source
|
FROM node:20-alpine AS runner
|
||||||
COPY mcp-server/pyproject.toml /app/mcp-server/pyproject.toml
|
WORKDIR /app
|
||||||
COPY mcp-server/src/ /app/mcp-server/src/
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Install MCP server + web deps
|
# next.config.ts uses output: 'standalone', so we copy only the minimal runtime
|
||||||
RUN pip install --no-cache-dir /app/mcp-server && \
|
COPY --from=builder /app/public ./public
|
||||||
pip install --no-cache-dir fastapi uvicorn python-multipart
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Copy web app
|
EXPOSE 3000
|
||||||
COPY web/ /app/web/
|
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/mcp-server/src
|
CMD ["node", "server.js"]
|
||||||
ENV DOTENV_PATH=/dev/null
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8080"]
|
|
||||||
|
|||||||
41
web-ui/.gitignore
vendored
Normal file
41
web-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
5
web-ui/AGENTS.md
Normal file
5
web-ui/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
1
web-ui/CLAUDE.md
Normal file
1
web-ui/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
36
web-ui/README.md
Normal file
36
web-ui/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
25
web-ui/components.json
Normal file
25
web-ui/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": true,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
18
web-ui/eslint.config.mjs
Normal file
18
web-ui/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
33
web-ui/next.config.ts
Normal file
33
web-ui/next.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staging config — proxies /api/* and /openapi.json to the production FastAPI
|
||||||
|
* at legal-ai.nautilus.marcusgroup.org. This lets the new Next.js UI call the
|
||||||
|
* existing backend without CORS and without running a second FastAPI instance.
|
||||||
|
*
|
||||||
|
* When the rewrite branch is cut over to production, set NEXT_PUBLIC_API_BASE_URL
|
||||||
|
* and/or move the FastAPI in front of this app via traefik routing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_ORIGIN =
|
||||||
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||||
|
"https://legal-ai.nautilus.marcusgroup.org";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${API_ORIGIN}/api/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/openapi.json",
|
||||||
|
destination: `${API_ORIGIN}/openapi.json`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
11781
web-ui/package-lock.json
generated
Normal file
11781
web-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
web-ui/package.json
Normal file
43
web-ui/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "web-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"api:types": "openapi-typescript https://legal-ai.nautilus.marcusgroup.org/openapi.json -o src/lib/api/types.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.97.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"next": "16.2.3",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"react-dropzone": "^15.0.0",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
|
"shadcn": "^4.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.3",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
web-ui/postcss.config.mjs
Normal file
7
web-ui/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
web-ui/public/file.svg
Normal file
1
web-ui/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
web-ui/public/globe.svg
Normal file
1
web-ui/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
web-ui/public/next.svg
Normal file
1
web-ui/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
web-ui/public/vercel.svg
Normal file
1
web-ui/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
web-ui/public/window.svg
Normal file
1
web-ui/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
198
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
198
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||||
|
import { useCase } from "@/lib/api/cases";
|
||||||
|
import { useResearchAnalysis } from "@/lib/api/research";
|
||||||
|
|
||||||
|
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||||
|
if (!content?.trim()) return null;
|
||||||
|
return (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose">
|
||||||
|
{content.trim()}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComposePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ caseNumber: string }>;
|
||||||
|
}) {
|
||||||
|
const { caseNumber } = use(params);
|
||||||
|
const caseQuery = useCase(caseNumber);
|
||||||
|
const analysis = useResearchAnalysis(caseNumber);
|
||||||
|
|
||||||
|
const isNotFound =
|
||||||
|
analysis.error instanceof Error &&
|
||||||
|
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
{/* Header strip */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-1">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${caseNumber}`}
|
||||||
|
className="hover:text-gold-deep"
|
||||||
|
>
|
||||||
|
ערר {caseNumber}
|
||||||
|
</Link>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="text-navy">עורך החלטה</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="text-navy mb-0">ניתוח משפטי וכתיבת עמדה</h1>
|
||||||
|
{caseQuery.data?.title && (
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||||
|
{caseQuery.data.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
{analysis.isPending ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : isNotFound ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-12 text-center space-y-3">
|
||||||
|
<div className="text-gold text-3xl" aria-hidden>❦</div>
|
||||||
|
<h2 className="text-navy text-lg mb-0">
|
||||||
|
טרם בוצע ניתוח משפטי לתיק זה
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink-muted text-sm max-w-md mx-auto">
|
||||||
|
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
|
||||||
|
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : analysis.error ? (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-5 text-center">
|
||||||
|
<p className="text-danger">{analysis.error.message}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : analysis.data ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
|
{/* Main editable column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Threshold claims */}
|
||||||
|
{analysis.data.threshold_claims &&
|
||||||
|
analysis.data.threshold_claims.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-navy text-xl mb-0">טענות סף</h2>
|
||||||
|
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||||
|
{analysis.data.threshold_claims.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analysis.data.threshold_claims.map((tc, i) => (
|
||||||
|
<SubsectionCard
|
||||||
|
key={tc.id}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
item={tc}
|
||||||
|
defaultOpen={i === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issues */}
|
||||||
|
{analysis.data.issues && analysis.data.issues.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-navy text-xl mb-0">סוגיות להכרעה</h2>
|
||||||
|
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||||
|
{analysis.data.issues.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analysis.data.issues.map((iss) => (
|
||||||
|
<SubsectionCard
|
||||||
|
key={iss.id}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
item={iss}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!analysis.data.threshold_claims?.length &&
|
||||||
|
!analysis.data.issues?.length) && (
|
||||||
|
<Card className="bg-surface border-rule">
|
||||||
|
<CardContent className="px-6 py-10 text-center text-ink-muted">
|
||||||
|
לא נמצאו טענות סף או סוגיות בניתוח זה.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side column: background prose + conclusions */}
|
||||||
|
<aside className="space-y-5">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 space-y-5">
|
||||||
|
<h2 className="text-navy text-base mb-0">רקע לניתוח</h2>
|
||||||
|
<ProseSection
|
||||||
|
title="צד מיוצג"
|
||||||
|
content={analysis.data.represented_party}
|
||||||
|
/>
|
||||||
|
<ProseSection
|
||||||
|
title="רקע דיוני"
|
||||||
|
content={analysis.data.procedural_background}
|
||||||
|
/>
|
||||||
|
<ProseSection
|
||||||
|
title="עובדות מוסכמות"
|
||||||
|
content={analysis.data.agreed_facts}
|
||||||
|
/>
|
||||||
|
<ProseSection
|
||||||
|
title="עובדות במחלוקת"
|
||||||
|
content={analysis.data.disputed_facts}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{analysis.data.conclusions?.trim() && (
|
||||||
|
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 space-y-2">
|
||||||
|
<h2 className="text-gold-deep text-base mb-0">מסקנות</h2>
|
||||||
|
<p className="text-sm text-ink leading-relaxed whitespace-pre-line prose">
|
||||||
|
{analysis.data.conclusions.trim()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
133
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { CaseHeader } from "@/components/cases/case-header";
|
||||||
|
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||||
|
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||||
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
|
import { useCase } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Next 16 breaking change: route params are now a Promise.
|
||||||
|
* The `use()` hook unwraps them inside a client component.
|
||||||
|
*/
|
||||||
|
export default function CaseDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ caseNumber: string }>;
|
||||||
|
}) {
|
||||||
|
const { caseNumber } = use(params);
|
||||||
|
const { data, isPending, error } = useCase(caseNumber);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
{error ? (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-6 text-center space-y-3">
|
||||||
|
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
||||||
|
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isPending ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-6 w-96" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<CaseHeader data={data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<Tabs defaultValue="overview" dir="rtl">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-1">
|
||||||
|
<TabsList className="bg-rule-soft/60">
|
||||||
|
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents">
|
||||||
|
מסמכים
|
||||||
|
{data?.documents && (
|
||||||
|
<span className="ms-1.5 text-[0.7rem] text-ink-muted tabular-nums">
|
||||||
|
({data.documents.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="actions">פעולות</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
|
||||||
|
<p className="text-ink-soft text-sm leading-relaxed">
|
||||||
|
{data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-navy text-base mb-2">סיכום מהיר</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-y-2 gap-x-6 text-sm">
|
||||||
|
<dt className="text-ink-muted">מסמכים בתיק</dt>
|
||||||
|
<dd className="text-ink tabular-nums">
|
||||||
|
{data?.documents?.length ?? 0}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-ink-muted">בעיבוד</dt>
|
||||||
|
<dd className="text-ink tabular-nums">
|
||||||
|
{data?.processing_count ?? 0}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="documents" className="mt-5">
|
||||||
|
<DocumentsPanel data={data} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="actions" className="mt-5">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
|
<Link href={`/cases/${caseNumber}/compose`}>
|
||||||
|
פתח בעורך ההחלטה
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{data && <CaseEditDialog data={data} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
עריכת פרטי התיק נשמרת מיד דרך PUT /api/cases/{caseNumber}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm h-fit">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
|
||||||
|
<WorkflowTimeline status={data?.status} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web-ui/src/app/cases/new/page.tsx
Normal file
26
web-ui/src/app/cases/new/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { CaseWizard } from "@/components/wizard/case-wizard";
|
||||||
|
|
||||||
|
export default function NewCasePage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-1">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="text-navy">תיק חדש</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="text-navy mb-0">יצירת תיק ערר</h1>
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||||
|
שלושה שלבים קצרים — פרטי יסוד, צדדים, והשלמות. התיק ייווצר ב-DB
|
||||||
|
וב-Gitea מיד בשמירה.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CaseWizard />
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
web-ui/src/app/favicon.ico
Normal file
BIN
web-ui/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
248
web-ui/src/app/globals.css
Normal file
248
web-ui/src/app/globals.css
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
* Ezer Mishpati — Design Tokens
|
||||||
|
* Ported from legal-ai/web/static/design-system.css.
|
||||||
|
* Editorial/judicial aesthetic for a Hebrew RTL judicial tool.
|
||||||
|
* Palette: Navy + Cream + Gold. Typography: Heebo.
|
||||||
|
*
|
||||||
|
* shadcn semantic tokens (background, foreground, primary, etc.)
|
||||||
|
* are mapped onto this palette in the @theme inline block + :root
|
||||||
|
* further down, so shadcn primitives inherit the editorial voice.
|
||||||
|
* ══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* ── Colors ─────────────────────────────────────────── */
|
||||||
|
--color-navy: #0f172a;
|
||||||
|
--color-navy-soft: #1e293b;
|
||||||
|
--color-navy-dim: #334155;
|
||||||
|
|
||||||
|
--color-cream: #f5f1e8;
|
||||||
|
--color-cream-deep: #ede8d8;
|
||||||
|
--color-parchment: #fbf8f0;
|
||||||
|
|
||||||
|
--color-gold: #a97d3a;
|
||||||
|
--color-gold-deep: #8b6428;
|
||||||
|
--color-gold-soft: #c89a56;
|
||||||
|
--color-gold-wash: #fdf6e8;
|
||||||
|
|
||||||
|
--color-ink: #1a1a2e;
|
||||||
|
--color-ink-soft: #3a3a52;
|
||||||
|
--color-ink-muted: #6b7280;
|
||||||
|
--color-ink-light: #9ca3af;
|
||||||
|
|
||||||
|
--color-rule: #e5dfd0;
|
||||||
|
--color-rule-soft: #f0ead8;
|
||||||
|
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-raised: #fbf8f0;
|
||||||
|
--color-bg: #f5f1e8;
|
||||||
|
|
||||||
|
/* Status colors — tuned to the palette */
|
||||||
|
--color-success: #4a7c59;
|
||||||
|
--color-success-bg: #e8efe7;
|
||||||
|
--color-warn: #b8894a;
|
||||||
|
--color-warn-bg: #faf0dc;
|
||||||
|
--color-danger: #a54242;
|
||||||
|
--color-danger-bg: #f5e6e6;
|
||||||
|
--color-info: #4e6a8c;
|
||||||
|
--color-info-bg: #e6ecf3;
|
||||||
|
|
||||||
|
/* ── Typography ─────────────────────────────────────── */
|
||||||
|
--font-sans: var(--font-heebo), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-display: var(--font-heebo), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-body: var(--font-heebo), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
||||||
|
|
||||||
|
/* ── Shadows — soft, editorial ──────────────────────── */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
--shadow: 0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08), 0 2px 4px rgba(15, 23, 42, 0.04);
|
||||||
|
--shadow-lg: 0 10px 30px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.05);
|
||||||
|
|
||||||
|
/* ── Transitions ────────────────────────────────────── */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shadcn/ui semantic tokens — wired to CSS vars defined in :root / .dark */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--font-heading: var(--font-display);
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* shadcn token values — mapped onto the editorial palette.
|
||||||
|
* background/foreground/primary all reference the navy-cream-gold system
|
||||||
|
* so shadcn primitives (Button, Card, Badge…) fit the judicial tone
|
||||||
|
* without per-component overrides.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--background: var(--color-bg); /* cream */
|
||||||
|
--foreground: var(--color-ink); /* near-black */
|
||||||
|
--card: var(--color-surface); /* white */
|
||||||
|
--card-foreground: var(--color-ink);
|
||||||
|
--popover: var(--color-surface);
|
||||||
|
--popover-foreground: var(--color-ink);
|
||||||
|
--primary: var(--color-navy); /* navy buttons */
|
||||||
|
--primary-foreground: var(--color-parchment);
|
||||||
|
--secondary: var(--color-cream-deep);
|
||||||
|
--secondary-foreground: var(--color-navy);
|
||||||
|
--muted: var(--color-rule-soft);
|
||||||
|
--muted-foreground: var(--color-ink-muted);
|
||||||
|
--accent: var(--color-gold-wash); /* subtle gold backgrounds */
|
||||||
|
--accent-foreground: var(--color-gold-deep);
|
||||||
|
--destructive: var(--color-danger);
|
||||||
|
--border: var(--color-rule);
|
||||||
|
--input: var(--color-rule);
|
||||||
|
--ring: var(--color-gold); /* gold focus ring */
|
||||||
|
|
||||||
|
--chart-1: var(--color-navy);
|
||||||
|
--chart-2: var(--color-gold);
|
||||||
|
--chart-3: var(--color-info);
|
||||||
|
--chart-4: var(--color-success);
|
||||||
|
--chart-5: var(--color-warn);
|
||||||
|
|
||||||
|
--sidebar: var(--color-parchment);
|
||||||
|
--sidebar-foreground: var(--color-ink);
|
||||||
|
--sidebar-primary: var(--color-navy);
|
||||||
|
--sidebar-primary-foreground: var(--color-parchment);
|
||||||
|
--sidebar-accent: var(--color-gold-wash);
|
||||||
|
--sidebar-accent-foreground: var(--color-gold-deep);
|
||||||
|
--sidebar-border: var(--color-rule);
|
||||||
|
--sidebar-ring: var(--color-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme — class-based, toggled on <html> or <body>.
|
||||||
|
Inverts navy ↔ parchment and keeps gold as accent. */
|
||||||
|
.dark {
|
||||||
|
--color-navy: #f5f1e8;
|
||||||
|
--color-navy-soft: #e8e0c8;
|
||||||
|
--color-navy-dim: #c7bc9a;
|
||||||
|
|
||||||
|
--color-cream: #0a0f1c;
|
||||||
|
--color-cream-deep: #121a2e;
|
||||||
|
--color-parchment: #161f36;
|
||||||
|
|
||||||
|
--color-gold: #d4a55a;
|
||||||
|
--color-gold-deep: #e8bc6f;
|
||||||
|
--color-gold-soft: #c89a56;
|
||||||
|
--color-gold-wash: rgba(212, 165, 90, 0.08);
|
||||||
|
|
||||||
|
--color-ink: #f5f1e8;
|
||||||
|
--color-ink-soft: #d8d2c0;
|
||||||
|
--color-ink-muted: #9a9380;
|
||||||
|
--color-ink-light: #6a6458;
|
||||||
|
|
||||||
|
--color-rule: #2a3352;
|
||||||
|
--color-rule-soft: #1e2a45;
|
||||||
|
|
||||||
|
--color-surface: #141b2f;
|
||||||
|
--color-surface-raised: #1a2238;
|
||||||
|
--color-bg: #0a0f1c;
|
||||||
|
|
||||||
|
--color-success: #5a9a6a;
|
||||||
|
--color-success-bg: rgba(90, 154, 106, 0.12);
|
||||||
|
--color-warn: #c79956;
|
||||||
|
--color-warn-bg: rgba(199, 153, 86, 0.12);
|
||||||
|
--color-danger: #c16565;
|
||||||
|
--color-danger-bg: rgba(193, 101, 101, 0.12);
|
||||||
|
--color-info: #6d8bab;
|
||||||
|
--color-info-bg: rgba(109, 139, 171, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
* Base layer — editorial typography + Hebrew prose defaults
|
||||||
|
* ══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-feature-settings: "kern", "liga", "clig", "calt";
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--color-navy);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 2.3rem; font-weight: 900; }
|
||||||
|
h2 { font-size: 1.8rem; }
|
||||||
|
h3 { font-size: 1.45rem; }
|
||||||
|
h4 { font-size: 1.2rem; }
|
||||||
|
h5 { font-size: 1.05rem; }
|
||||||
|
h6 { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
/* Prose paragraphs — justified for Hebrew legal text */
|
||||||
|
p.prose,
|
||||||
|
.prose p {
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
hyphens: auto;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-gold-wash);
|
||||||
|
color: var(--color-navy);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
web-ui/src/app/layout.tsx
Normal file
33
web-ui/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Heebo, Geist } from "next/font/google";
|
||||||
|
import { Providers } from "@/lib/providers";
|
||||||
|
import "./globals.css";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||||
|
|
||||||
|
const heebo = Heebo({
|
||||||
|
variable: "--font-heebo",
|
||||||
|
subsets: ["hebrew", "latin"],
|
||||||
|
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "עוזר משפטי — ניהול תיקים",
|
||||||
|
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="he" dir="rtl" className={cn("h-full", "antialiased", heebo.variable, "font-sans", geist.variable)}>
|
||||||
|
<body className="min-h-full flex flex-col">
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web-ui/src/app/page.tsx
Normal file
61
web-ui/src/app/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { KPICards } from "@/components/cases/kpi-cards";
|
||||||
|
import { StatusDonut } from "@/components/cases/status-donut";
|
||||||
|
import { CasesTable } from "@/components/cases/cases-table";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useCases } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { data, isPending, error } = useCases(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-8">
|
||||||
|
<header className="flex items-end justify-between gap-6 flex-wrap">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
|
||||||
|
ועדת ערר לתכנון ובנייה · ירושלים
|
||||||
|
</div>
|
||||||
|
<h1 className="text-navy">עוזר משפטי</h1>
|
||||||
|
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
|
||||||
|
לוח בקרה לניהול תיקי ערר, ניתוח סגנון, וכתיבת החלטות לפי ארכיטקטורת
|
||||||
|
12 הבלוקים.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
|
<Link href="/cases/new">+ תיק חדש</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
<KPICards cases={data} loading={isPending} />
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_auto]">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-navy text-xl mb-0">רשימת תיקים</h2>
|
||||||
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
|
מעודכן חי
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CasesTable cases={data} loading={isPending} error={error} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm lg:w-[320px]">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-navy text-lg mb-4">פיזור סטטוסים</h2>
|
||||||
|
<StatusDonut cases={data} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
web-ui/src/components/app-shell.tsx
Normal file
71
web-ui/src/components/app-shell.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ezer Mishpati navigation shell.
|
||||||
|
*
|
||||||
|
* Editorial/judicial aesthetic:
|
||||||
|
* - Navy header with a gold hairline rule (border-b-3)
|
||||||
|
* - Parchment/cream body background (set on <body> via globals.css)
|
||||||
|
* - Hebrew RTL throughout (set on <html> in layout.tsx)
|
||||||
|
*
|
||||||
|
* Structure mirrors the current vanilla index.html header so that visual
|
||||||
|
* continuity is preserved while we migrate screen-by-screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ href: "/", label: "בית" },
|
||||||
|
{ href: "/upload", label: "העלאת מסמכים" },
|
||||||
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
|
{ href: "/diagnostics", label: "אבחון" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header
|
||||||
|
className="
|
||||||
|
relative z-10 flex items-center gap-4
|
||||||
|
px-10 py-[18px]
|
||||||
|
bg-navy text-parchment
|
||||||
|
border-b-[3px] border-gold
|
||||||
|
shadow-md
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Link href="/" className="flex items-baseline gap-3 hover:text-parchment">
|
||||||
|
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment">
|
||||||
|
עוזר משפטי
|
||||||
|
</span>
|
||||||
|
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="me-auto flex items-center gap-1">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="
|
||||||
|
px-3 py-1.5 rounded
|
||||||
|
text-sm text-parchment/80
|
||||||
|
transition-colors
|
||||||
|
hover:text-parchment hover:bg-navy-soft/60
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
web-ui/src/components/cases/case-edit-dialog.tsx
Normal file
160
web-ui/src/components/cases/case-edit-dialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||||
|
DialogHeader, DialogTitle, DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useUpdateCase } from "@/lib/api/cases";
|
||||||
|
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
|
||||||
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inline edit dialog for core case fields. Uses react-hook-form + zod
|
||||||
|
* directly (shadcn's <Form> registry entry wasn't available at init
|
||||||
|
* time, so the styling is reproduced by hand in a lean form layout).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function FieldError({ message }: { message?: string }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const mutate = useUpdateCase(data.case_number);
|
||||||
|
|
||||||
|
const form = useForm<CaseUpdateInput>({
|
||||||
|
resolver: zodResolver(caseUpdateSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: data.title ?? "",
|
||||||
|
subject: data.subject ?? "",
|
||||||
|
hearing_date: data.hearing_date ?? "",
|
||||||
|
notes: "",
|
||||||
|
expected_outcome: data.expected_outcome ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Re-sync the form when the underlying case refetches after save */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.reset({
|
||||||
|
title: data.title ?? "",
|
||||||
|
subject: data.subject ?? "",
|
||||||
|
hearing_date: data.hearing_date ?? "",
|
||||||
|
notes: "",
|
||||||
|
expected_outcome: data.expected_outcome ?? "",
|
||||||
|
});
|
||||||
|
}, [open, data, form]);
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
try {
|
||||||
|
await mutate.mutateAsync(values);
|
||||||
|
toast.success("פרטי התיק עודכנו");
|
||||||
|
setOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון התיק");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
עריכת פרטי תיק
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
|
||||||
|
<DialogDescription className="text-ink-muted">
|
||||||
|
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title" className="text-navy">כותרת</Label>
|
||||||
|
<Input id="title" {...form.register("title")} className="mt-1" />
|
||||||
|
<FieldError message={form.formState.errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject" className="text-navy">נושא</Label>
|
||||||
|
<Input id="subject" {...form.register("subject")} className="mt-1" />
|
||||||
|
<FieldError message={form.formState.errors.subject?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
|
||||||
|
<Input
|
||||||
|
id="hearing_date"
|
||||||
|
type="date"
|
||||||
|
{...form.register("hearing_date")}
|
||||||
|
className="mt-1 tabular-nums"
|
||||||
|
/>
|
||||||
|
<FieldError message={form.formState.errors.hearing_date?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy">תוצאה צפויה</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("expected_outcome") || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
form.setValue("expected_outcome", v === "__none__" ? "" : v)
|
||||||
|
}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{expectedOutcomes.map((o) => (
|
||||||
|
<SelectItem key={o.value || "none"} value={o.value || "__none__"}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes" className="text-navy">הערות (יתווספו לקיים)</Label>
|
||||||
|
<Textarea id="notes" rows={3} {...form.register("notes")} className="mt-1" />
|
||||||
|
<FieldError message={form.formState.errors.notes?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={mutate.isPending}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutate.isPending}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{mutate.isPending ? "שומר…" : "שמור שינויים"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
web-ui/src/components/cases/case-header.tsx
Normal file
67
web-ui/src/components/cases/case-header.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
function formatDate(iso?: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("he-IL", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso ?? "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseHeader({ data }: { data?: CaseDetail }) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted mb-3 flex items-center gap-2">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>תיקי ערר</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="text-navy tabular-nums">{data?.case_number ?? "…"}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-6 flex-wrap">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||||
|
ערר {data?.case_number ?? "—"}
|
||||||
|
</span>
|
||||||
|
{data?.status && <StatusBadge status={data.status} />}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
||||||
|
{data?.title ?? "טוען…"}
|
||||||
|
</h1>
|
||||||
|
{data?.subject && (
|
||||||
|
<p className="text-ink-muted text-sm max-w-2xl leading-relaxed">
|
||||||
|
{data.subject}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||||
|
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
תאריך דיון
|
||||||
|
</dt>
|
||||||
|
<dd className="text-ink-soft tabular-nums">{formatDate(data?.hearing_date)}</dd>
|
||||||
|
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
עודכן
|
||||||
|
</dt>
|
||||||
|
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||||
|
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
ועדה
|
||||||
|
</dt>
|
||||||
|
<dd className="text-ink-soft">{data?.committee_type ?? "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
web-ui/src/components/cases/cases-table.tsx
Normal file
191
web-ui/src/components/cases/cases-table.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
function formatDate(iso?: string) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("he-IL", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<Case>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "case_number",
|
||||||
|
header: "מס׳ ערר",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${row.original.case_number}`}
|
||||||
|
className="text-navy font-semibold hover:text-gold-deep tabular-nums"
|
||||||
|
>
|
||||||
|
{row.original.case_number}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: "כותרת",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
|
||||||
|
{row.original.title}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "סטטוס",
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "document_count",
|
||||||
|
header: "מסמכים",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums text-ink-soft">
|
||||||
|
{row.original.document_count ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "updated_at",
|
||||||
|
header: "עודכן",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-ink-muted text-sm">{formatDate(row.original.updated_at)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CasesTable({
|
||||||
|
cases,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
cases?: Case[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: Error | null;
|
||||||
|
}) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "updated_at", desc: true },
|
||||||
|
]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
|
||||||
|
const data = useMemo(() => cases ?? [], [cases]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: { sorting, globalFilter },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
globalFilterFn: (row, _colId, filterValue: string) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const needle = filterValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
row.original.case_number.toLowerCase().includes(needle) ||
|
||||||
|
row.original.title.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
|
||||||
|
className="max-w-sm bg-surface"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-ink-muted me-auto">
|
||||||
|
{table.getFilteredRowModel().rows.length} תיקים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-rule-soft/60">
|
||||||
|
{table.getHeaderGroups().map((hg) => (
|
||||||
|
<TableRow key={hg.id} className="border-rule">
|
||||||
|
{hg.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
className="text-navy font-semibold cursor-pointer select-none text-right"
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? ""}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-rule">
|
||||||
|
{columns.map((_c, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : error ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="text-center text-danger py-8">
|
||||||
|
שגיאה בטעינת תיקים: {error.message}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="text-center text-ink-muted py-12">
|
||||||
|
<div className="text-gold text-2xl mb-2" aria-hidden>❦</div>
|
||||||
|
{globalFilter ? "אין תיקים תואמים לחיפוש" : "עדיין אין תיקי ערר"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className="border-rule hover:bg-gold-wash/40 transition-colors"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-3">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web-ui/src/components/cases/documents-panel.tsx
Normal file
73
web-ui/src/components/cases/documents-panel.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
function formatSize(bytes?: number) {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryTone(category?: string | null) {
|
||||||
|
switch (category) {
|
||||||
|
case "appeal": return "bg-info-bg text-info border-info/40";
|
||||||
|
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
|
||||||
|
case "decision": return "bg-success-bg text-success border-success/40";
|
||||||
|
case "protocol": return "bg-warn-bg text-warn border-warn/40";
|
||||||
|
default: return "bg-rule-soft text-ink-muted border-rule";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsPanel({ data }: { data?: CaseDetail }) {
|
||||||
|
const docs = data?.documents ?? [];
|
||||||
|
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-ink-muted">
|
||||||
|
<div className="text-gold text-2xl mb-2" aria-hidden>❦</div>
|
||||||
|
<p className="text-sm">אין מסמכים בתיק זה</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="max-h-[520px]">
|
||||||
|
<ul className="divide-y divide-rule">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<li
|
||||||
|
key={doc.id}
|
||||||
|
className="py-3 flex items-start justify-between gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="text-ink font-medium truncate" title={doc.filename}>
|
||||||
|
{doc.filename}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3">
|
||||||
|
{doc.size_bytes && (
|
||||||
|
<span className="tabular-nums">{formatSize(doc.size_bytes)}</span>
|
||||||
|
)}
|
||||||
|
{doc.uploaded_at && (
|
||||||
|
<span>
|
||||||
|
{new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{doc.status && doc.status !== "ready" && (
|
||||||
|
<span className="text-warn">{doc.status}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{doc.category && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${categoryTone(doc.category)}`}
|
||||||
|
>
|
||||||
|
{doc.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
web-ui/src/components/cases/kpi-cards.tsx
Normal file
65
web-ui/src/components/cases/kpi-cards.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
label: string;
|
||||||
|
caption: string;
|
||||||
|
value: number;
|
||||||
|
tone: "navy" | "gold" | "warn" | "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_STYLES: Record<Bucket["tone"], string> = {
|
||||||
|
navy: "before:bg-navy text-navy",
|
||||||
|
gold: "before:bg-gold text-gold-deep",
|
||||||
|
warn: "before:bg-warn text-warn",
|
||||||
|
success: "before:bg-success text-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
function bucketize(cases: Case[] | undefined): Bucket[] {
|
||||||
|
const c = cases ?? [];
|
||||||
|
const inProgress = c.filter((x) =>
|
||||||
|
["processing", "documents_ready", "outcome_set", "brainstorming", "direction_approved"].includes(x.status),
|
||||||
|
).length;
|
||||||
|
const drafting = c.filter((x) =>
|
||||||
|
["drafting", "qa_review", "drafted"].includes(x.status),
|
||||||
|
).length;
|
||||||
|
const done = c.filter((x) =>
|
||||||
|
["exported", "reviewed", "final"].includes(x.status),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" },
|
||||||
|
{ label: "בהכנה", caption: "מסמכים וניתוח", value: inProgress, tone: "gold" },
|
||||||
|
{ label: "בכתיבה", caption: "טיוטות ו-QA", value: drafting, tone: "warn" },
|
||||||
|
{ label: "מוכנים", caption: "יוצאו או סופיים", value: done, tone: "success" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KPICards({ cases, loading }: { cases?: Case[]; loading?: boolean }) {
|
||||||
|
const buckets = bucketize(cases);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{buckets.map((b) => (
|
||||||
|
<Card
|
||||||
|
key={b.label}
|
||||||
|
className={`
|
||||||
|
relative overflow-hidden bg-surface shadow-sm border-rule
|
||||||
|
before:content-[''] before:absolute before:top-0 before:right-0 before:h-full before:w-[3px]
|
||||||
|
${TONE_STYLES[b.tone]}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<CardContent className="px-5 py-4 flex flex-col gap-1">
|
||||||
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[2.3rem] font-black leading-none">
|
||||||
|
{loading ? "—" : b.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.78rem] text-ink-muted mt-0.5">{b.caption}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
web-ui/src/components/cases/status-badge.tsx
Normal file
53
web-ui/src/components/cases/status-badge.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { CaseStatus } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||||
|
new: "חדש",
|
||||||
|
uploading: "מעלה",
|
||||||
|
processing: "בעיבוד",
|
||||||
|
documents_ready: "מסמכים מוכנים",
|
||||||
|
outcome_set: "תוצאה נקבעה",
|
||||||
|
brainstorming: "סיעור מוחות",
|
||||||
|
direction_approved: "כיוון אושר",
|
||||||
|
drafting: "בכתיבה",
|
||||||
|
qa_review: "QA",
|
||||||
|
drafted: "טיוטה",
|
||||||
|
exported: "יוצא",
|
||||||
|
reviewed: "נבדק",
|
||||||
|
final: "סופי",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Status color groups:
|
||||||
|
* intake → new, uploading, processing (muted parchment)
|
||||||
|
* prep → documents_ready, outcome_set (info blue)
|
||||||
|
* thinking→ brainstorming, direction_approved (gold)
|
||||||
|
* writing → drafting, qa_review, drafted (warn amber)
|
||||||
|
* done → exported, reviewed, final (success green) */
|
||||||
|
const STATUS_TONE: Record<CaseStatus, string> = {
|
||||||
|
new: "bg-rule-soft text-ink-muted border-rule",
|
||||||
|
uploading: "bg-rule-soft text-ink-muted border-rule",
|
||||||
|
processing: "bg-info-bg text-info border-info/30",
|
||||||
|
documents_ready: "bg-info-bg text-info border-info/40",
|
||||||
|
outcome_set: "bg-info-bg text-info border-info/40",
|
||||||
|
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
|
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
||||||
|
drafting: "bg-warn-bg text-warn border-warn/40",
|
||||||
|
qa_review: "bg-warn-bg text-warn border-warn/40",
|
||||||
|
drafted: "bg-warn-bg text-warn border-warn/50",
|
||||||
|
exported: "bg-success-bg text-success border-success/40",
|
||||||
|
reviewed: "bg-success-bg text-success border-success/50",
|
||||||
|
final: "bg-success-bg text-success border-success/60 font-semibold",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: CaseStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium ${STATUS_TONE[status] ?? ""}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { STATUS_LABELS };
|
||||||
91
web-ui/src/components/cases/status-donut.tsx
Normal file
91
web-ui/src/components/cases/status-donut.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Case, CaseStatus } from "@/lib/api/cases";
|
||||||
|
import { STATUS_LABELS } from "@/components/cases/status-badge";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero().
|
||||||
|
* Kept deliberately dependency-free (no D3/recharts) — a single background-image.
|
||||||
|
* Five status groups map onto the navy/gold/info/warn/success palette.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
||||||
|
|
||||||
|
const GROUP_OF: Record<CaseStatus, GroupKey> = {
|
||||||
|
new: "intake", uploading: "intake", processing: "intake",
|
||||||
|
documents_ready: "prep", outcome_set: "prep",
|
||||||
|
brainstorming: "thinking", direction_approved: "thinking",
|
||||||
|
drafting: "writing", qa_review: "writing", drafted: "writing",
|
||||||
|
exported: "done", reviewed: "done", final: "done",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_META: Record<GroupKey, { label: string; color: string }> = {
|
||||||
|
intake: { label: "חדש / בעיבוד", color: "var(--color-ink-muted)" },
|
||||||
|
prep: { label: "הכנה", color: "var(--color-info)" },
|
||||||
|
thinking: { label: "ניתוח וכיוון", color: "var(--color-gold)" },
|
||||||
|
writing: { label: "בכתיבה", color: "var(--color-warn)" },
|
||||||
|
done: { label: "מוכן", color: "var(--color-success)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusDonut({ cases }: { cases?: Case[] }) {
|
||||||
|
const counts: Record<GroupKey, number> = {
|
||||||
|
intake: 0, prep: 0, thinking: 0, writing: 0, done: 0,
|
||||||
|
};
|
||||||
|
(cases ?? []).forEach((c) => {
|
||||||
|
const g = GROUP_OF[c.status];
|
||||||
|
if (g) counts[g] += 1;
|
||||||
|
});
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
const segments: { key: GroupKey; start: number; end: number }[] = [];
|
||||||
|
let pct = 0;
|
||||||
|
(Object.keys(counts) as GroupKey[]).forEach((k) => {
|
||||||
|
if (counts[k] === 0) return;
|
||||||
|
const start = total === 0 ? 0 : (pct / total) * 360;
|
||||||
|
pct += counts[k];
|
||||||
|
const end = total === 0 ? 360 : (pct / total) * 360;
|
||||||
|
segments.push({ key: k, start, end });
|
||||||
|
});
|
||||||
|
|
||||||
|
const background =
|
||||||
|
total === 0
|
||||||
|
? "conic-gradient(var(--color-rule-soft) 0deg 360deg)"
|
||||||
|
: `conic-gradient(${segments
|
||||||
|
.map((s) => `${GROUP_META[s.key].color} ${s.start}deg ${s.end}deg`)
|
||||||
|
.join(", ")})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div
|
||||||
|
className="relative w-[140px] h-[140px] rounded-full shadow-sm"
|
||||||
|
style={{ background }}
|
||||||
|
aria-label="פיזור תיקים לפי סטטוס"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-[18px] bg-surface rounded-full flex flex-col items-center justify-center">
|
||||||
|
<span className="font-display text-2xl font-black text-navy leading-none">
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted mt-1">תיקים</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{(Object.keys(GROUP_META) as GroupKey[]).map((k) => (
|
||||||
|
<li key={k} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ background: GROUP_META[k].color }}
|
||||||
|
/>
|
||||||
|
<span className="text-ink-soft">{GROUP_META[k].label}</span>
|
||||||
|
<span className="text-ink-muted tabular-nums me-auto ms-1">
|
||||||
|
{counts[k]}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exported for the legend tests / docs if ever needed */
|
||||||
|
export { STATUS_LABELS };
|
||||||
77
web-ui/src/components/cases/workflow-timeline.tsx
Normal file
77
web-ui/src/components/cases/workflow-timeline.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CaseStatus } from "@/lib/api/cases";
|
||||||
|
import { STATUS_LABELS } from "@/components/cases/status-badge";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vertical RTL workflow timeline showing the 13-status case pipeline.
|
||||||
|
* Groups the raw statuses into the 5 visual phases used across the app
|
||||||
|
* (intake → prep → thinking → writing → done) so the user sees
|
||||||
|
* "where am I in the process" rather than 13 micro-steps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Phase = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
statuses: CaseStatus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASES: Phase[] = [
|
||||||
|
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
|
||||||
|
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready", "outcome_set"] },
|
||||||
|
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved"] },
|
||||||
|
{ key: "writing", label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
|
||||||
|
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function phaseIndexOf(status?: CaseStatus): number {
|
||||||
|
if (!status) return -1;
|
||||||
|
return PHASES.findIndex((p) => p.statuses.includes(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
|
||||||
|
const currentIdx = phaseIndexOf(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="relative space-y-4">
|
||||||
|
<div
|
||||||
|
className="absolute top-2 bottom-2 right-[11px] w-px bg-rule"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{PHASES.map((phase, i) => {
|
||||||
|
const state =
|
||||||
|
currentIdx === -1 ? "pending"
|
||||||
|
: i < currentIdx ? "done"
|
||||||
|
: i === currentIdx ? "current"
|
||||||
|
: "pending";
|
||||||
|
|
||||||
|
const dotTone =
|
||||||
|
state === "done" ? "bg-success border-success"
|
||||||
|
: state === "current" ? "bg-gold border-gold shadow-[0_0_0_4px_color-mix(in_oklab,var(--color-gold)_20%,transparent)]"
|
||||||
|
: "bg-surface border-rule";
|
||||||
|
|
||||||
|
const labelTone =
|
||||||
|
state === "done" ? "text-ink-soft"
|
||||||
|
: state === "current" ? "text-navy font-semibold"
|
||||||
|
: "text-ink-muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={phase.key} className="relative flex items-start gap-3 ps-7">
|
||||||
|
<span
|
||||||
|
className={`absolute right-[5px] top-1 inline-block w-3 h-3 rounded-full border-2 ${dotTone}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className={`text-sm ${labelTone}`}>{phase.label}</span>
|
||||||
|
{state === "current" && status && (
|
||||||
|
<span className="text-[0.72rem] text-gold-deep mt-0.5">
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
web-ui/src/components/compose/chair-editor.tsx
Normal file
96
web-ui/src/components/compose/chair-editor.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useSaveChairPosition } from "@/lib/api/research";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chair-position editor for a single threshold claim or issue.
|
||||||
|
*
|
||||||
|
* Autosaves on blur, with an optimistic in-memory "last saved" value so the
|
||||||
|
* user sees immediate feedback. No debounced per-keystroke save — the user
|
||||||
|
* writes in long paragraphs and the backend writes to a file, so per-blur
|
||||||
|
* is the right granularity (matches the vanilla UI's behavior).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SaveState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "saving" }
|
||||||
|
| { kind: "saved"; at: Date }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
export function ChairEditor({
|
||||||
|
caseNumber,
|
||||||
|
sectionId,
|
||||||
|
initialValue,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
sectionId: string;
|
||||||
|
initialValue: string;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [state, setState] = useState<SaveState>({ kind: "idle" });
|
||||||
|
const lastSaved = useRef(initialValue);
|
||||||
|
const mutate = useSaveChairPosition(caseNumber);
|
||||||
|
|
||||||
|
/* Reset when the upstream analysis refetches (e.g. after initial load) */
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValue);
|
||||||
|
lastSaved.current = initialValue;
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === lastSaved.current.trim()) return;
|
||||||
|
setState({ kind: "saving" });
|
||||||
|
try {
|
||||||
|
await mutate.mutateAsync({ sectionId, position: trimmed });
|
||||||
|
lastSaved.current = trimmed;
|
||||||
|
setState({ kind: "saved", at: new Date() });
|
||||||
|
} catch (e) {
|
||||||
|
setState({
|
||||||
|
kind: "error",
|
||||||
|
message: e instanceof Error ? e.message : "שגיאה בשמירה",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[0.78rem] font-semibold text-navy">
|
||||||
|
עמדת ועדת הערר
|
||||||
|
</span>
|
||||||
|
<SaveIndicator state={state} />
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
rows={6}
|
||||||
|
dir="rtl"
|
||||||
|
placeholder="כתבי כאן את עמדתך לגבי סוגיה זו. הטקסט נשמר אוטומטית כשעוזבת את השדה."
|
||||||
|
className="
|
||||||
|
w-full resize-y rounded border border-rule bg-parchment
|
||||||
|
px-3 py-2 text-sm leading-relaxed text-ink
|
||||||
|
shadow-inner focus:border-gold focus:outline-none
|
||||||
|
focus:ring-2 focus:ring-gold/30
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveIndicator({ state }: { state: SaveState }) {
|
||||||
|
if (state.kind === "idle") return null;
|
||||||
|
if (state.kind === "saving") {
|
||||||
|
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||||
|
}
|
||||||
|
if (state.kind === "saved") {
|
||||||
|
const time = state.at.toLocaleTimeString("he-IL", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</span>;
|
||||||
|
}
|
||||||
88
web-ui/src/components/compose/subsection-card.tsx
Normal file
88
web-ui/src/components/compose/subsection-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { ChairEditor } from "@/components/compose/chair-editor";
|
||||||
|
import type { ResearchSubsection } from "@/lib/api/research";
|
||||||
|
|
||||||
|
export function SubsectionCard({
|
||||||
|
caseNumber,
|
||||||
|
item,
|
||||||
|
defaultOpen = false,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
item: ResearchSubsection;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const isFilled = Boolean(item.chair_position?.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="
|
||||||
|
w-full flex items-center gap-3 px-4 py-3 text-right
|
||||||
|
hover:bg-gold-wash/30 transition-colors
|
||||||
|
focus:outline-none focus-visible:bg-gold-wash/40
|
||||||
|
"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="
|
||||||
|
inline-flex items-center justify-center shrink-0
|
||||||
|
w-7 h-7 rounded-full
|
||||||
|
bg-navy text-parchment font-display font-bold text-sm
|
||||||
|
tabular-nums
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{item.number}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-navy font-semibold text-base leading-snug">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
text-[0.72rem] rounded-full px-2.5 py-0.5 border shrink-0
|
||||||
|
${
|
||||||
|
isFilled
|
||||||
|
? "bg-success-bg text-success border-success/40"
|
||||||
|
: "bg-rule-soft text-ink-muted border-rule"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isFilled ? "✓ עמדה נקבעה" : "ממתין לעמדה"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-ink-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-rule px-5 py-4 space-y-4 bg-parchment/40">
|
||||||
|
{item.fields.length > 0 && (
|
||||||
|
<dl className="space-y-3">
|
||||||
|
{item.fields.map((f, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1">
|
||||||
|
{f.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-ink-soft leading-relaxed whitespace-pre-line">
|
||||||
|
{f.content}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<ChairEditor
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
sectionId={item.id}
|
||||||
|
initialValue={item.chair_position ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
web-ui/src/components/documents/upload-sheet.tsx
Normal file
208
web-ui/src/components/documents/upload-sheet.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { Upload, FileText, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Upload sheet — drag-drop zone + doc-type selector, with live SSE
|
||||||
|
* progress for the most-recent upload. Intentionally sequential:
|
||||||
|
* a single file at a time keeps the SSE subscription simple and
|
||||||
|
* matches how the FastAPI processor handles one task_id per file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOC_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: "auto", label: "זיהוי אוטומטי" },
|
||||||
|
{ value: "appeal", label: "כתב ערר" },
|
||||||
|
{ value: "response", label: "כתב תשובה" },
|
||||||
|
{ value: "protocol", label: "פרוטוקול דיון" },
|
||||||
|
{ value: "decision", label: "החלטת ועדה מקומית" },
|
||||||
|
{ value: "plan", label: "תכנית" },
|
||||||
|
{ value: "reference",label: "חומר רקע" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type UploadRow = {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
taskId: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(event: ProgressEvent | null): string {
|
||||||
|
if (!event) return "מתחיל…";
|
||||||
|
if (event.status === "queued") return "בתור";
|
||||||
|
if (event.status === "processing")
|
||||||
|
return event.step ? `בעיבוד · ${event.step}` : "בעיבוד";
|
||||||
|
if (event.status === "completed") return "הושלם";
|
||||||
|
if (event.status === "failed") return event.error ?? "נכשל";
|
||||||
|
return event.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressPercent(event: ProgressEvent | null): number {
|
||||||
|
if (!event) return 5;
|
||||||
|
if (event.status === "queued") return 10;
|
||||||
|
if (event.status === "processing") return 55;
|
||||||
|
if (event.status === "completed") return 100;
|
||||||
|
if (event.status === "failed") return 100;
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadRowView({ row }: { row: UploadRow }) {
|
||||||
|
const progress = useProgress(row.taskId);
|
||||||
|
const pct = row.error ? 100 : progressPercent(progress);
|
||||||
|
const failed = row.error || progress?.status === "failed";
|
||||||
|
const done = progress?.status === "completed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{done ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||||||
|
) : failed ? (
|
||||||
|
<XCircle className="w-4 h-4 text-danger shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
|
||||||
|
)}
|
||||||
|
<FileText className="w-4 h-4 text-ink-muted shrink-0" />
|
||||||
|
<span className="text-sm text-ink truncate flex-1" title={row.filename}>
|
||||||
|
{row.filename}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[0.72rem] tabular-nums shrink-0 ${
|
||||||
|
done ? "text-success" : failed ? "text-danger" : "text-ink-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.error ?? statusLabel(progress)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
className={failed ? "[&>div]:bg-danger" : done ? "[&>div]:bg-success" : ""}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadSheet({ caseNumber }: { caseNumber: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [docType, setDocType] = useState("auto");
|
||||||
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
|
const mutate = useUploadDocument(caseNumber);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
for (const file of files) {
|
||||||
|
const rowId = crypto.randomUUID();
|
||||||
|
setRows((r) => [
|
||||||
|
...r,
|
||||||
|
{ id: rowId, filename: file.name, taskId: null },
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const res = await mutate.mutateAsync({ file, docType });
|
||||||
|
setRows((r) =>
|
||||||
|
r.map((row) =>
|
||||||
|
row.id === rowId ? { ...row, taskId: res.task_id } : row,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setRows((r) =>
|
||||||
|
r.map((row) =>
|
||||||
|
row.id === rowId
|
||||||
|
? { ...row, error: e instanceof Error ? e.message : "שגיאה" }
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docType, mutate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropzone = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
"application/pdf": [".pdf"],
|
||||||
|
"application/msword": [".doc"],
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
|
||||||
|
"text/plain": [".txt"],
|
||||||
|
"text/markdown": [".md"],
|
||||||
|
},
|
||||||
|
maxSize: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Upload className="w-4 h-4 me-1" /> העלאת מסמכים
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-full sm:max-w-lg" dir="rtl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="text-navy">העלאת מסמכים לתיק {caseNumber}</SheetTitle>
|
||||||
|
<SheetDescription className="text-ink-muted">
|
||||||
|
PDF, DOCX, DOC, TXT, MD — עד 50MB לקובץ. הקבצים מעובדים ברקע
|
||||||
|
והסטטוס מתעדכן בזמן אמת.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4 px-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-navy mb-1.5">
|
||||||
|
סיווג
|
||||||
|
</label>
|
||||||
|
<Select value={docType} onValueChange={setDocType} dir="rtl">
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DOC_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...dropzone.getRootProps()}
|
||||||
|
className={`
|
||||||
|
rounded-lg border-2 border-dashed p-8 text-center cursor-pointer
|
||||||
|
transition-colors
|
||||||
|
${
|
||||||
|
dropzone.isDragActive
|
||||||
|
? "border-gold bg-gold-wash"
|
||||||
|
: "border-rule bg-parchment/40 hover:bg-gold-wash/50 hover:border-gold/60"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input {...dropzone.getInputProps()} />
|
||||||
|
<Upload className="w-8 h-8 mx-auto mb-2 text-gold-deep" />
|
||||||
|
<p className="text-sm text-navy font-medium">
|
||||||
|
{dropzone.isDragActive
|
||||||
|
? "שחרר כאן להעלאה"
|
||||||
|
: "גרור קבצים או לחץ לבחירה"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mt-1">
|
||||||
|
ניתן להעלות מספר קבצים בבת אחת
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<UploadRowView key={row.id} row={row} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
web-ui/src/components/ui/badge.tsx
Normal file
49
web-ui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 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/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 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 }
|
||||||
67
web-ui/src/components/ui/button.tsx
Normal file
67
web-ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 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 [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 }
|
||||||
103
web-ui/src/components/ui/card.tsx
Normal file
103
web-ui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
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-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
168
web-ui/src/components/ui/dialog.tsx
Normal file
168
web-ui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
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 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 end-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</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", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 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(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
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 *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
269
web-ui/src/components/ui/dropdown-menu.tsx
Normal file
269
web-ui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
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,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 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 end-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</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,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 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 end-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</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-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
|
||||||
|
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(
|
||||||
|
"ms-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-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-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="rtl:rotate-180 ms-auto" />
|
||||||
|
</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-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
19
web-ui/src/components/ui/input.tsx
Normal file
19
web-ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
web-ui/src/components/ui/label.tsx
Normal file
24
web-ui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
31
web-ui/src/components/ui/progress.tsx
Normal file
31
web-ui/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="size-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
55
web-ui/src/components/ui/scroll-area.tsx
Normal file
55
web-ui/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"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"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-s data-vertical:border-s-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
192
web-ui/src/components/ui/select.tsx
Normal file
192
web-ui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...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-1.5 rounded-lg border border-input bg-transparent py-2 pe-2 ps-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</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"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 rtl:data-[side=left]:translate-x-1 data-[side=right]:translate-x-1 rtl:data-[side=right]:-translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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-1.5 py-1 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-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]: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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</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(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
28
web-ui/src/components/ui/separator.tsx
Normal file
28
web-ui/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
147
web-ui/src/components/ui/sheet.tsx
Normal file
147
web-ui/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
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/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-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"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-e data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-s data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 end-3"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.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-heading text-base font-medium 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,
|
||||||
|
}
|
||||||
13
web-ui/src/components/ui/skeleton.tsx
Normal file
13
web-ui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
49
web-ui/src/components/ui/sonner.tsx
Normal file
49
web-ui/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
116
web-ui/src/components/ui/table.tsx
Normal file
116
web-ui/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded: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-start align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0",
|
||||||
|
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])]:pe-0",
|
||||||
|
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,
|
||||||
|
}
|
||||||
90
web-ui/src/components/ui/tabs.tsx
Normal file
90
web-ui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"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}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-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-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-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-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-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 has-data-[icon=inline-end]:pe-1 has-data-[icon=inline-start]:ps-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_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-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-end-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-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 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
18
web-ui/src/components/ui/textarea.tsx
Normal file
18
web-ui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
283
web-ui/src/components/wizard/case-wizard.tsx
Normal file
283
web-ui/src/components/wizard/case-wizard.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { PartiesField } from "@/components/wizard/parties-field";
|
||||||
|
import { useCreateCase } from "@/lib/api/cases";
|
||||||
|
import {
|
||||||
|
caseCreateSchema, committeeTypes, expectedOutcomes,
|
||||||
|
type CaseCreateInput,
|
||||||
|
} from "@/lib/schemas/case";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: "basics", label: "פרטי יסוד" },
|
||||||
|
{ key: "parties", label: "צדדים" },
|
||||||
|
{ key: "details", label: "השלמות" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StepKey = (typeof STEPS)[number]["key"];
|
||||||
|
|
||||||
|
/* Fields validated at each step — lets the user fix just what's on screen
|
||||||
|
* before moving forward, instead of surfacing all errors from page 1. */
|
||||||
|
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
|
||||||
|
basics: ["case_number", "title", "committee_type"],
|
||||||
|
parties: ["appellants", "respondents"],
|
||||||
|
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldError({ message }: { message?: string }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseWizard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState<StepKey>("basics");
|
||||||
|
const mutate = useCreateCase();
|
||||||
|
|
||||||
|
const form = useForm<CaseCreateInput>({
|
||||||
|
resolver: zodResolver(caseCreateSchema),
|
||||||
|
mode: "onBlur",
|
||||||
|
defaultValues: {
|
||||||
|
case_number: "",
|
||||||
|
title: "",
|
||||||
|
appellants: [],
|
||||||
|
respondents: [],
|
||||||
|
subject: "",
|
||||||
|
property_address: "",
|
||||||
|
permit_number: "",
|
||||||
|
committee_type: "ועדה מקומית",
|
||||||
|
hearing_date: "",
|
||||||
|
notes: "",
|
||||||
|
expected_outcome: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||||
|
const isLast = stepIndex === STEPS.length - 1;
|
||||||
|
|
||||||
|
const goNext = async () => {
|
||||||
|
const ok = await form.trigger(STEP_FIELDS[step]);
|
||||||
|
if (!ok) return;
|
||||||
|
setStep(STEPS[stepIndex + 1].key);
|
||||||
|
};
|
||||||
|
const goBack = () => setStep(STEPS[stepIndex - 1].key);
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
try {
|
||||||
|
const res = await mutate.mutateAsync(values);
|
||||||
|
toast.success("תיק חדש נוצר");
|
||||||
|
const created = res?.case_number || values.case_number;
|
||||||
|
router.push(`/cases/${encodeURIComponent(created)}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה ביצירת תיק");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm max-w-3xl">
|
||||||
|
<CardContent className="px-6 py-6 space-y-6">
|
||||||
|
{/* Stepper */}
|
||||||
|
<ol className="flex items-center gap-2 text-sm">
|
||||||
|
{STEPS.map((s, i) => {
|
||||||
|
const active = i === stepIndex;
|
||||||
|
const done = i < stepIndex;
|
||||||
|
return (
|
||||||
|
<li key={s.key} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex items-center justify-center w-7 h-7 rounded-full
|
||||||
|
font-display font-bold text-sm tabular-nums transition-colors
|
||||||
|
${done ? "bg-success text-parchment" : active ? "bg-navy text-parchment" : "bg-rule text-ink-muted"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{done ? "✓" : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={active ? "text-navy font-semibold" : "text-ink-muted"}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<span className="w-8 h-px bg-rule mx-1" aria-hidden />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
|
{step === "basics" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="case_number" className="text-navy">
|
||||||
|
מספר תיק <span className="text-danger">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="case_number"
|
||||||
|
placeholder="1234 או 8001/2026"
|
||||||
|
{...form.register("case_number")}
|
||||||
|
className="mt-1 tabular-nums"
|
||||||
|
/>
|
||||||
|
<FieldError message={form.formState.errors.case_number?.message} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title" className="text-navy">
|
||||||
|
כותרת <span className="text-danger">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="title" {...form.register("title")} className="mt-1" />
|
||||||
|
<FieldError message={form.formState.errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy">סוג ועדה</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="committee_type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{committeeTypes.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "parties" && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="appellants"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<PartiesField
|
||||||
|
label="עוררים *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="h-px bg-rule" />
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="respondents"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<PartiesField
|
||||||
|
label="משיבים *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "details" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject" className="text-navy">נושא</Label>
|
||||||
|
<Input id="subject" {...form.register("subject")} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
|
||||||
|
<Input id="property_address" {...form.register("property_address")} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
|
||||||
|
<Input id="permit_number" {...form.register("permit_number")} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
|
||||||
|
<Input
|
||||||
|
id="hearing_date"
|
||||||
|
type="date"
|
||||||
|
{...form.register("hearing_date")}
|
||||||
|
className="mt-1 tabular-nums"
|
||||||
|
/>
|
||||||
|
<FieldError message={form.formState.errors.hearing_date?.message} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy">תוצאה צפויה</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="expected_outcome"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value || "__none__"}
|
||||||
|
onValueChange={(v) => field.onChange(v === "__none__" ? "" : v)}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{expectedOutcomes.map((o) => (
|
||||||
|
<SelectItem key={o.value || "none"} value={o.value || "__none__"}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes" className="text-navy">הערות</Label>
|
||||||
|
<Textarea id="notes" rows={4} {...form.register("notes")} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={goBack}
|
||||||
|
disabled={stepIndex === 0 || mutate.isPending}
|
||||||
|
>
|
||||||
|
← הקודם
|
||||||
|
</Button>
|
||||||
|
{isLast ? (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutate.isPending}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{mutate.isPending ? "יוצר תיק…" : "צור תיק"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={goNext}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
הבא →
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
web-ui/src/components/wizard/parties-field.tsx
Normal file
89
web-ui/src/components/wizard/parties-field.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X, Plus } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Minimal tag-style editor for a list of party names (appellants / respondents).
|
||||||
|
* Backed by a controlled string[] — submits as the same shape the FastAPI
|
||||||
|
* CaseCreateRequest expects. Enter adds the current draft; X removes a chip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function PartiesField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string[];
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (value.includes(trimmed)) {
|
||||||
|
setDraft("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...value, trimmed]);
|
||||||
|
setDraft("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (name: string) => {
|
||||||
|
onChange(value.filter((v) => v !== name));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-navy mb-1.5">{label}</label>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{value.map((name) => (
|
||||||
|
<li
|
||||||
|
key={name}
|
||||||
|
className="
|
||||||
|
inline-flex items-center gap-1.5 rounded-full
|
||||||
|
bg-gold-wash text-gold-deep border border-gold/40
|
||||||
|
px-3 py-1 text-sm
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span>{name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(name)}
|
||||||
|
className="hover:text-danger transition-colors"
|
||||||
|
aria-label={`הסר ${name}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
add();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="שם מלא של הצד"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={add}>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
web-ui/src/lib/api/cases.ts
Normal file
143
web-ui/src/lib/api/cases.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Cases domain hooks.
|
||||||
|
*
|
||||||
|
* Note on types: the FastAPI `/api/cases` endpoint doesn't declare a response
|
||||||
|
* model, so openapi-typescript emits `unknown` for its payload. Until the
|
||||||
|
* backend is annotated (see out-of-scope in the rewrite plan), we maintain a
|
||||||
|
* small local type that matches what the running API returns today. Any drift
|
||||||
|
* surfaces as a runtime TypeScript error the first time a property is touched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
|
||||||
|
|
||||||
|
export type CaseStatus =
|
||||||
|
| "new"
|
||||||
|
| "uploading"
|
||||||
|
| "processing"
|
||||||
|
| "documents_ready"
|
||||||
|
| "outcome_set"
|
||||||
|
| "brainstorming"
|
||||||
|
| "direction_approved"
|
||||||
|
| "drafting"
|
||||||
|
| "qa_review"
|
||||||
|
| "drafted"
|
||||||
|
| "exported"
|
||||||
|
| "reviewed"
|
||||||
|
| "final";
|
||||||
|
|
||||||
|
export type Case = {
|
||||||
|
case_number: string;
|
||||||
|
title: string;
|
||||||
|
status: CaseStatus;
|
||||||
|
subject?: string | null;
|
||||||
|
expected_outcome?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
/* Present when loaded with detail=true */
|
||||||
|
document_count?: number;
|
||||||
|
processing_count?: number;
|
||||||
|
committee_type?: string | null;
|
||||||
|
hearing_date?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseDetail = Case & {
|
||||||
|
documents?: Array<{
|
||||||
|
id: number | string;
|
||||||
|
filename: string;
|
||||||
|
category?: string | null;
|
||||||
|
status?: string;
|
||||||
|
uploaded_at?: string;
|
||||||
|
size_bytes?: number;
|
||||||
|
}>;
|
||||||
|
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const casesKeys = {
|
||||||
|
all: ["cases"] as const,
|
||||||
|
list: (detail: boolean) => [...casesKeys.all, "list", { detail }] as const,
|
||||||
|
detail: (caseNumber: string) =>
|
||||||
|
[...casesKeys.all, "detail", caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCases(detail = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: casesKeys.list(detail),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<Case[]>(`/api/cases${detail ? "?detail=true" : ""}`, {
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCase(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: casesKeys.detail(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<CaseDetail>(`/api/cases/${caseNumber}/details`, { signal }),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
/* Replaces the old 5s polling from vanilla index.html */
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowStatus = {
|
||||||
|
case_number?: string;
|
||||||
|
status?: CaseStatus | string;
|
||||||
|
current_step?: string;
|
||||||
|
steps?: Array<{
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
status: "done" | "current" | "pending" | string;
|
||||||
|
}>;
|
||||||
|
/* FastAPI returns free-form JSON; keep it permissive */
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCreateCase() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CaseCreateInput) =>
|
||||||
|
apiRequest<{ case_number?: string; message?: string; [k: string]: unknown }>(
|
||||||
|
`/api/cases/create`,
|
||||||
|
{ method: "POST", body: input },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCase(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CaseUpdateInput) =>
|
||||||
|
apiRequest<CaseDetail>(`/api/cases/${caseNumber}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: input,
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
/* Patch cached detail and nudge the list to refetch on next focus */
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.setQueryData<CaseDetail | undefined>(
|
||||||
|
casesKeys.detail(caseNumber),
|
||||||
|
(prev) => (prev ? { ...prev, ...data } : prev),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<WorkflowStatus>(`/api/cases/${caseNumber}/status`, { signal }),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
77
web-ui/src/lib/api/client.ts
Normal file
77
web-ui/src/lib/api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* API client — typed fetch wrapper + TanStack Query setup.
|
||||||
|
*
|
||||||
|
* All requests hit relative URLs (e.g. `/api/cases`) which next.config.ts
|
||||||
|
* rewrites transparently proxy to legal-ai.nautilus.marcusgroup.org. No CORS
|
||||||
|
* gymnastics, no direct public-URL references in component code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly body: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
body?: unknown;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed JSON request. Throws ApiError on non-2xx responses with the parsed body.
|
||||||
|
* Always returns parsed JSON — callers pass the expected type parameter.
|
||||||
|
*/
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
path: string,
|
||||||
|
{ method = "GET", body, signal }: RequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
const parsed = contentType.includes("application/json")
|
||||||
|
? await res.json().catch(() => null)
|
||||||
|
: await res.text().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
`${method} ${path} failed with ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
parsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared TanStack Query client. Defaults are tuned for a dashboard that polls
|
||||||
|
* for case progress — stale for 5 seconds, 1 automatic retry, no background
|
||||||
|
* refetch on window focus (preserves editor state during long sessions).
|
||||||
|
*/
|
||||||
|
export function makeQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
111
web-ui/src/lib/api/documents.ts
Normal file
111
web-ui/src/lib/api/documents.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Document upload + progress hooks.
|
||||||
|
*
|
||||||
|
* Upload hits `POST /api/cases/{n}/documents/upload-tagged` as multipart
|
||||||
|
* form-data (FastAPI UploadFile), and receives a `task_id` that streams
|
||||||
|
* progress events via `GET /api/progress/{task_id}` (SSE). We expose
|
||||||
|
* both as a single `useUploadDocument` mutation returning the task id
|
||||||
|
* plus a `useProgress(taskId)` hook that subscribes to the stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "./client";
|
||||||
|
import { casesKeys } from "./cases";
|
||||||
|
import { openSSE } from "@/lib/sse";
|
||||||
|
|
||||||
|
export type UploadTaggedResponse = {
|
||||||
|
task_id: string;
|
||||||
|
filename: string;
|
||||||
|
original_name: string;
|
||||||
|
doc_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProgressEvent = {
|
||||||
|
status: "queued" | "processing" | "completed" | "failed" | string;
|
||||||
|
filename?: string;
|
||||||
|
step?: string;
|
||||||
|
error?: string;
|
||||||
|
result?: unknown;
|
||||||
|
case_number?: string;
|
||||||
|
doc_type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadVars = {
|
||||||
|
caseNumber: string;
|
||||||
|
file: File;
|
||||||
|
docType?: string;
|
||||||
|
partyName?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function uploadTagged({
|
||||||
|
caseNumber,
|
||||||
|
file,
|
||||||
|
docType = "auto",
|
||||||
|
partyName = "",
|
||||||
|
title = "",
|
||||||
|
}: UploadVars): Promise<UploadTaggedResponse> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("doc_type", docType);
|
||||||
|
fd.append("party_name", partyName);
|
||||||
|
fd.append("title", title);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/cases/${encodeURIComponent(caseNumber)}/documents/upload-tagged`,
|
||||||
|
{ method: "POST", body: fd },
|
||||||
|
);
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
const parsed = contentType.includes("application/json")
|
||||||
|
? await res.json().catch(() => null)
|
||||||
|
: await res.text().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
`Upload failed with ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
parsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed as UploadTaggedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadDocument(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: Omit<UploadVars, "caseNumber">) =>
|
||||||
|
uploadTagged({ caseNumber, ...vars }),
|
||||||
|
onSuccess: () => {
|
||||||
|
/* Nudge the case detail to refetch so the new document row appears
|
||||||
|
* immediately — the actual "processing" badge will update once the
|
||||||
|
* SSE stream reports status=completed. */
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgress(taskId: string | null) {
|
||||||
|
const [event, setEvent] = useState<ProgressEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
setEvent(null);
|
||||||
|
const close = openSSE<ProgressEvent>(
|
||||||
|
`/api/progress/${encodeURIComponent(taskId)}`,
|
||||||
|
{
|
||||||
|
onMessage: (data) => {
|
||||||
|
setEvent(data);
|
||||||
|
if (data.status === "completed" || data.status === "failed") {
|
||||||
|
/* Close from within the callback — the backend ends the stream
|
||||||
|
* naturally, but closing eagerly avoids the auto-reconnect loop
|
||||||
|
* EventSource does after EOF. */
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => close();
|
||||||
|
}, [taskId]);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
95
web-ui/src/lib/api/research.ts
Normal file
95
web-ui/src/lib/api/research.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Research analysis hooks — reads and mutates the
|
||||||
|
* `analysis-and-research.md` file that backs each case's compose screen.
|
||||||
|
*
|
||||||
|
* Schema mirrors research_md.parse() in the FastAPI backend. Kept as
|
||||||
|
* hand-typed interfaces because the endpoint does not declare a
|
||||||
|
* response_model in the OpenAPI schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type ResearchField = {
|
||||||
|
label: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchSubsection = {
|
||||||
|
id: string; // e.g. "threshold_1" or "issue_3"
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
fields: ResearchField[];
|
||||||
|
chair_position?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchAnalysis = {
|
||||||
|
header?: {
|
||||||
|
date?: string;
|
||||||
|
modified_at?: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
represented_party?: string;
|
||||||
|
procedural_background?: string;
|
||||||
|
agreed_facts?: string;
|
||||||
|
disputed_facts?: string;
|
||||||
|
threshold_claims?: ResearchSubsection[];
|
||||||
|
issues?: ResearchSubsection[];
|
||||||
|
conclusions?: string;
|
||||||
|
other_sections?: Array<{ title: string; body: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const researchKeys = {
|
||||||
|
all: ["research"] as const,
|
||||||
|
analysis: (caseNumber: string) =>
|
||||||
|
[...researchKeys.all, "analysis", caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useResearchAnalysis(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: researchKeys.analysis(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<ResearchAnalysis>(
|
||||||
|
`/api/cases/${caseNumber}/research/analysis`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
/* No polling — the user is editing; refetching would clobber focus */
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveChairPosition(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: { sectionId: string; position: string }) =>
|
||||||
|
apiRequest<unknown>(
|
||||||
|
`/api/cases/${caseNumber}/research/analysis/chair-position`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: { section_id: vars.sectionId, position: vars.position },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onSuccess: (_res, vars) => {
|
||||||
|
/* Locally patch the cached analysis so other consumers stay in sync
|
||||||
|
without an immediate refetch that would steal focus from the editor. */
|
||||||
|
qc.setQueryData<ResearchAnalysis | undefined>(
|
||||||
|
researchKeys.analysis(caseNumber ?? ""),
|
||||||
|
(prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const patch = (arr?: ResearchSubsection[]) =>
|
||||||
|
arr?.map((s) =>
|
||||||
|
s.id === vars.sectionId
|
||||||
|
? { ...s, chair_position: vars.position }
|
||||||
|
: s,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
threshold_claims: patch(prev.threshold_claims),
|
||||||
|
issues: patch(prev.issues),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
2972
web-ui/src/lib/api/types.ts
Normal file
2972
web-ui/src/lib/api/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
21
web-ui/src/lib/providers.tsx
Normal file
21
web-ui/src/lib/providers.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { makeQueryClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side providers. Creates ONE QueryClient instance per browser session
|
||||||
|
* via useState — Next.js App Router recreates the function on every render,
|
||||||
|
* so a naive `const client = makeQueryClient()` would dump the cache each time.
|
||||||
|
*/
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
const [queryClient] = useState(() => makeQueryClient());
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-left" richColors closeButton dir="rtl" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
web-ui/src/lib/schemas/case.ts
Normal file
82
web-ui/src/lib/schemas/case.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Zod schemas for case mutations (create / update).
|
||||||
|
* Shapes mirror the FastAPI Pydantic models in web/app.py:
|
||||||
|
* - CaseCreateRequest
|
||||||
|
* - CaseUpdateRequest
|
||||||
|
*
|
||||||
|
* Validation rules are stricter on the UI than on the backend so the user
|
||||||
|
* gets Hebrew-localized error messages at the field level instead of a
|
||||||
|
* 422 blob from FastAPI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md —
|
||||||
|
* permissive regex that still catches obvious typos. */
|
||||||
|
const caseNumberRe = /^[1-9]\d{3,}(?:[-/][\w\u0590-\u05FF]+)*$/;
|
||||||
|
|
||||||
|
const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/;
|
||||||
|
|
||||||
|
const dateString = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), {
|
||||||
|
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const committeeTypes = [
|
||||||
|
"ועדה מקומית",
|
||||||
|
"ועדה מחוזית",
|
||||||
|
"ועדת ערר",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const expectedOutcomes = [
|
||||||
|
{ value: "", label: "— לא נקבע —" },
|
||||||
|
{ value: "rejection", label: "דחייה" },
|
||||||
|
{ value: "partial_acceptance", label: "קבלה חלקית" },
|
||||||
|
{ value: "full_acceptance", label: "קבלה מלאה" },
|
||||||
|
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const caseCreateSchema = z.object({
|
||||||
|
case_number: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "שדה חובה")
|
||||||
|
.regex(caseNumberRe, "מספר תיק לא תקין (למשל 1234 או 8001/2026)"),
|
||||||
|
title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"),
|
||||||
|
appellants: z
|
||||||
|
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
|
||||||
|
.min(1, "חייב להיות לפחות עורר אחד"),
|
||||||
|
respondents: z
|
||||||
|
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
|
||||||
|
.min(1, "חייב להיות לפחות משיב אחד"),
|
||||||
|
subject: z.string().trim().max(500),
|
||||||
|
property_address: z.string().trim().max(200),
|
||||||
|
permit_number: z.string().trim().max(100),
|
||||||
|
committee_type: z.enum(committeeTypes),
|
||||||
|
hearing_date: dateString,
|
||||||
|
notes: z.string().trim().max(2000),
|
||||||
|
expected_outcome: z.enum(
|
||||||
|
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
|
||||||
|
|
||||||
|
/* Update schema — all fields optional so the PUT can be used for a
|
||||||
|
* single-field edit. Empty strings are tolerated (they mean "no change"
|
||||||
|
* in the Pydantic model). */
|
||||||
|
export const caseUpdateSchema = z.object({
|
||||||
|
title: z.string().trim().max(200).optional(),
|
||||||
|
subject: z.string().trim().max(500).optional(),
|
||||||
|
notes: z.string().trim().max(2000).optional(),
|
||||||
|
hearing_date: dateString.optional(),
|
||||||
|
decision_date: dateString.optional(),
|
||||||
|
expected_outcome: z
|
||||||
|
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
|
||||||
|
.optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;
|
||||||
53
web-ui/src/lib/sse.ts
Normal file
53
web-ui/src/lib/sse.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Minimal SSE helper — wraps `EventSource` so consumers get typed event
|
||||||
|
* callbacks and a single `close()` for cleanup.
|
||||||
|
*
|
||||||
|
* Used by the upload flow to stream /api/progress/{task_id} events.
|
||||||
|
* Kept framework-agnostic so any component can drive it; a thin React
|
||||||
|
* hook layer sits on top in lib/api/documents.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SSEHandlers<T> = {
|
||||||
|
onMessage: (data: T) => void;
|
||||||
|
onError?: (err: Event) => void;
|
||||||
|
/* Called when the server closes the stream cleanly. EventSource has
|
||||||
|
* no native "closed" event, so the backend signals completion via a
|
||||||
|
* terminal payload and we close from the onMessage handler — callers
|
||||||
|
* can return `true` to trigger this path. */
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function openSSE<T>(
|
||||||
|
url: string,
|
||||||
|
{ onMessage, onError, onClose }: SSEHandlers<T>,
|
||||||
|
): () => void {
|
||||||
|
const es = new EventSource(url);
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
es.close();
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener("message", (ev) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(ev.data) as T;
|
||||||
|
onMessage(payload);
|
||||||
|
} catch {
|
||||||
|
/* backend sends heartbeats as comments — EventSource filters them,
|
||||||
|
* so any non-JSON message here is a protocol bug worth ignoring */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("error", (ev) => {
|
||||||
|
if (closed) return;
|
||||||
|
onError?.(ev);
|
||||||
|
/* EventSource auto-reconnects; we only close on an explicit terminal
|
||||||
|
* payload from the server, not on transient network errors */
|
||||||
|
});
|
||||||
|
|
||||||
|
return close;
|
||||||
|
}
|
||||||
6
web-ui/src/lib/utils.ts
Normal file
6
web-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
34
web-ui/tsconfig.json
Normal file
34
web-ui/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user