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