8 Commits

Author SHA1 Message Date
10540a38b4 Phase 4c: bulk document upload with live SSE progress
New UploadSheet on the case detail page wraps react-dropzone + a
selector for doc_type. Files post to
POST /api/cases/{n}/documents/upload-tagged as multipart form-data;
the returned task_id is streamed via GET /api/progress/{task_id}
through the new lib/sse.ts EventSource wrapper.

Each upload row shows a per-file progress bar that transitions to
success/error on the terminal SSE payload. Closing the stream inside
the message handler avoids EventSource's auto-reconnect after EOF.

Phase 4 (task 86) is now complete end-to-end: create, upload, edit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:25:44 +00:00
b67dc47dc7 Phase 4b: Case create wizard
New /cases/new route with a 3-step wizard (basics / parties / details)
backed by react-hook-form + the caseCreateSchema. Each step validates
only its own fields so the user fixes errors in context. On success
useCreateCase invalidates the case list and the router pushes to the
freshly created case detail page.

PartiesField is a small chip-style editor for the appellants/respondents
arrays. The Home page now has a navy "+ תיק חדש" button that links to
the wizard.

Dropped .default() from the create schema — zod's input/output type
mismatch broke the RHF zodResolver generics; dropping the defaults is
simpler than plumbing z.input vs z.output through the mutation hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:23:37 +00:00
9fcf4f2dc7 Phase 4a: shadcn form primitives + case inline edit
Add dialog/select/textarea/label/progress/sonner components and wire
a Toaster into Providers. New zod schemas in lib/schemas/case.ts
mirror CaseCreateRequest/CaseUpdateRequest and feed react-hook-form
validation.

CaseEditDialog on the case detail Actions tab posts PUT /api/cases/{n}
with optimistic cache patching via useUpdateCase, showing toast
feedback on success/error.

shadcn's <Form> registry entry skipped at init (missing from the
nova preset); the dialog uses RHF directly against the same Input/
Textarea/Select primitives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:21:21 +00:00
03b25bc273 Phase 3c: Compose view with chair-position editor
New /cases/[caseNumber]/compose route ports the research analysis +
chair-position editing flow from the vanilla UI onto the Next.js
stack. Reads /api/cases/{n}/research/analysis, renders background
prose in the side column and threshold claims + issues as collapsible
cards in the main column, each with a blur-autosaved chair editor
wired through a TanStack Query mutation with optimistic cache patching
(so concurrent reads don't steal editor focus).

Handles the common "analysis not yet generated" 404 with a dedicated
empty state rather than an error card.

Phase 3 task 85 is now ready for review end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:09:09 +00:00
d0daa0efe8 Phase 3b: Case Detail view with workflow timeline
New dynamic route /cases/[caseNumber] wired to the FastAPI details
endpoint, using Next 16's async params pattern with React's use()
hook. TanStack Query handles 5s refetchInterval so the page
self-updates during long-running processing without manual polling.

New components: CaseHeader (breadcrumb + meta), WorkflowTimeline
(5-phase RTL pipeline view), DocumentsPanel (categorized list).
Tabs split overview/documents/actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:06:27 +00:00
51064f3a03 Phase 3a: shadcn init + Home/Case List view
Initialize shadcn/ui (radix-nova preset) and wire its semantic tokens
to the editorial navy/cream/gold palette so primitives inherit the
judicial voice without per-component overrides.

Replace the Phase 2 live-probe with a real dashboard: KPI tiles,
conic-gradient status donut (ported from the vanilla renderHero),
and a TanStack Table cases list with search + sort. Add useCase(n)
hook with 5s staleTime/refetchInterval to replace the old manual
polling loop when Case Detail ships next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:04:30 +00:00
Chaim
0ee8e723bd Phase 2: API client, typed hooks, live probe
- Add api:types script (openapi-typescript against live FastAPI)
- Generate src/lib/api/types.ts (2972 lines, 55 paths, 16 schemas)
- lib/api/client.ts: typed apiRequest + ApiError + makeQueryClient
  (staleTime 5s, no refetchOnWindowFocus to preserve editor state)
- lib/providers.tsx: QueryClientProvider client component, useState
  singleton so App Router re-renders don't dump the cache
- lib/api/cases.ts: Case type + casesKeys + useCases hook (pragmatic
  hand-typed Case pending backend response-model annotations)
- layout.tsx: wrap children with <Providers>
- Smoke test: CasesLiveProbe component on home page hitting live FastAPI
  via /api/cases rewrite proxy

Phase 2 deliverable check: useCases() returns typed Case[] from the
production FastAPI through the Next.js proxy. End-to-end wiring proven.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:49:24 +00:00
Chaim
64724656af Phase 1: scaffold Next.js 16 web-ui + Coolify staging
- create-next-app with TypeScript, Tailwind v4, App Router
- Port design-system.css tokens into Tailwind @theme (navy/gold/parchment, Heebo)
- Install TanStack Query, react-hook-form, zod, lucide-react, react-dropzone
- layout.tsx: RTL Hebrew + Heebo via next/font/google
- AppShell component with navy header + gold rule + nav
- next.config.ts: output:standalone + rewrites to proxy /api/* to production FastAPI
- Dockerfile: multi-stage Node 20 Alpine build for Next.js standalone
  (branch-local override of the FastAPI Dockerfile; main is unaffected)
- Switch .taskmaster to claude-code provider (no API key required)
- Add 7 phase tasks (83-89) tracking the full rewrite plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:47:05 +00:00
67 changed files with 19562 additions and 31 deletions

View File

@@ -1,20 +1,20 @@
{
"models": {
"main": {
"provider": "anthropic",
"modelId": "claude-opus-4-20250514",
"maxTokens": 64000,
"provider": "claude-code",
"modelId": "opus",
"maxTokens": 32000,
"temperature": 0.2
},
"research": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"maxTokens": 64000,
"provider": "claude-code",
"modelId": "opus",
"maxTokens": 32000,
"temperature": 0.1
},
"fallback": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 64000,
"temperature": 0.2
}

3
.taskmaster/state.json Normal file
View File

@@ -0,0 +1,3 @@
{
"migrationNoticeShown": true
}

View File

@@ -703,13 +703,190 @@
"status": "deferred",
"subtasks": [],
"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": {
"version": "1.0.0",
"lastModified": "2026-04-04T07:50:59.999Z",
"taskCount": 51,
"completedCount": 49,
"lastModified": "2026-04-11T16:18:28.714Z",
"taskCount": 58,
"completedCount": 52,
"tags": [
"master"
]

View File

@@ -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
COPY web-ui/package.json web-ui/package-lock.json ./
RUN npm ci --no-audit --no-fund
# System deps for PyMuPDF and document processing
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libmupdf-dev libfreetype6-dev libharfbuzz-dev libjpeg62-turbo-dev \
libopenjp2-7-dev curl git && rm -rf /var/lib/apt/lists/* && \
git config --global init.defaultBranch main
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY web-ui/ ./
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Copy Ezer Mishpati MCP server source
COPY mcp-server/pyproject.toml /app/mcp-server/pyproject.toml
COPY mcp-server/src/ /app/mcp-server/src/
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Install MCP server + web deps
RUN pip install --no-cache-dir /app/mcp-server && \
pip install --no-cache-dir fastapi uvicorn python-multipart
# next.config.ts uses output: 'standalone', so we copy only the minimal runtime
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Copy web app
COPY web/ /app/web/
EXPOSE 3000
ENV PYTHONPATH=/app/mcp-server/src
ENV DOTENV_PATH=/dev/null
EXPOSE 8080
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8080"]
CMD ["node", "server.js"]

41
web-ui/.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
@AGENTS.md

36
web-ui/README.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

43
web-ui/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
web-ui/public/file.svg Normal file
View 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
View 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
View 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
View 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
View 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

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

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

248
web-ui/src/app/globals.css Normal file
View 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
View 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
View 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>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,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 }

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

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

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

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

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

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

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 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,
}

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

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-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 }

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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