Compare commits
23 Commits
3541238239
...
ui-rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8502d46b | |||
| 0fef20e272 | |||
| ca6ec48580 | |||
| 4e418787cf | |||
| fdd12c6726 | |||
| e34d217345 | |||
| 6b8f002596 | |||
| aa0e608a4a | |||
| 916360e9b2 | |||
| cbe9d60901 | |||
| fb1f73fa25 | |||
| ac0a5ee30b | |||
| e483eba1a9 | |||
| d8a537e7aa | |||
| 75ea6825b2 | |||
| 10540a38b4 | |||
| b67dc47dc7 | |||
| 9fcf4f2dc7 | |||
| 03b25bc273 | |||
| d0daa0efe8 | |||
| 51064f3a03 | |||
|
|
0ee8e723bd | ||
|
|
64724656af |
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"models": {
|
"models": {
|
||||||
"main": {
|
"main": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-opus-4-20250514",
|
"modelId": "opus",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 32000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
},
|
},
|
||||||
"research": {
|
"research": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-sonnet-4-20250514",
|
"modelId": "opus",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 32000,
|
||||||
"temperature": 0.1
|
"temperature": 0.1
|
||||||
},
|
},
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-sonnet-4-20250514",
|
"modelId": "sonnet",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 64000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
}
|
}
|
||||||
|
|||||||
3
.taskmaster/state.json
Normal file
3
.taskmaster/state.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"migrationNoticeShown": true
|
||||||
|
}
|
||||||
@@ -703,13 +703,218 @@
|
|||||||
"status": "deferred",
|
"status": "deferred",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-04-03T10:19:26.779Z"
|
"updatedAt": "2026-04-03T10:19:26.779Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "83",
|
||||||
|
"title": "Phase 1 — Project setup (legal-ai UI rewrite)",
|
||||||
|
"description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"details": "**השלמות (2026-04-11):**\n\n✅ **Scaffold:** Next.js 16.2.3 (חדש יותר מ-v15 שתוכנן), React 19.2.4, Tailwind v4, Turbopack.\n\n✅ **תלויות מותקנות** (`web-ui/package.json:11-21`):\n- @tanstack/react-query ^5.97.0\n- @tanstack/react-table ^8.21.3\n- react-hook-form ^7.72.1 + @hookform/resolvers ^5.2.2\n- zod ^4.3.6\n- lucide-react ^1.8.0\n- react-dropzone ^15.0.0\n- openapi-typescript ^7.13.0 (devDep)\n\n✅ **Design tokens** (`web-ui/src/app/globals.css:10-107`): Tailwind v4 @theme עם כל הצבעים (navy, cream, parchment, gold, ink, status colors), radii, shadows, fonts, dark mode preserved.\n\n✅ **RTL Hebrew** (`web-ui/src/app/layout.tsx:5-10, 23`): Heebo עם hebrew+latin subsets, `lang=\"he\" dir=\"rtl\"` on html.\n\n✅ **AppShell** (`web-ui/src/components/app-shell.tsx:29-70`): Navy header עם gold border-b-3, RTL nav, parchment body.\n\n✅ **Home page placeholder** (`web-ui/src/app/page.tsx`).\n\n✅ **Build:** `npm run build` עובר ב-3.8s, 0 errors, static.\n\n**נותר:** אישור ויזואלי של המשתמש עם `npm run dev`.\n\n**תוכנית מלאה:** `~/.claude/plans/joyful-marinating-sutton.md`",
|
||||||
|
"testStrategy": "1. `cd web-ui && npm run dev` — פתיחה ב-http://localhost:3000\n2. וידוא ויזואלי: Header navy עם gold rule, RTL rendering, פונט Heebo טעון\n3. השוואה ל-legal-ai.nautilus.marcusgroup.org — אותו מראה header\n4. בדיקת dark mode (toggle class על html)\n5. אישור סופי מהמשתמש",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "יצירת Next.js 16 scaffold עם TypeScript + Tailwind v4 + App Router",
|
||||||
|
"description": "הרצת create-next-app ב-web-ui/ עם App Router, TypeScript, Tailwind v4, ESLint",
|
||||||
|
"dependencies": [],
|
||||||
|
"details": "Next.js 16.2.3 (חדש יותר מ-v15), React 19.2.4, Tailwind v4, Turbopack. קבצים: web-ui/package.json, tsconfig.json, next.config.ts",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "npm run build succeeds",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "התקנת כל התלויות הנדרשות",
|
||||||
|
"description": "npm install של @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript",
|
||||||
|
"dependencies": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"details": "ראה web-ui/package.json:11-21 לגרסאות המותקנות. כולל openapi-typescript כ-devDep לשלב 2.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "npm ls shows all packages",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "העברת design tokens ל-Tailwind v4 @theme",
|
||||||
|
"description": "פורט מלא של design-system.css ל-globals.css עם Tailwind v4 @theme syntax",
|
||||||
|
"dependencies": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/app/globals.css:10-107 כולל כל הצבעים (navy, cream, parchment, gold, ink), status colors, radii, shadows, fonts, dark mode. CSS variables עובדים עם Tailwind classes.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "Tailwind classes like bg-navy, text-gold work correctly",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "הגדרת RTL Hebrew עם Heebo font",
|
||||||
|
"description": "next/font/google Heebo עם hebrew+latin, lang=he dir=rtl על html",
|
||||||
|
"dependencies": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/app/layout.tsx:5-10 — Heebo עם weights 300-900, display swap. שורה 23: html lang=he dir=rtl.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "Page renders RTL, Heebo font loaded",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "בניית AppShell component עם navy header + gold rule",
|
||||||
|
"description": "רכיב shell עם header navy, gold border, RTL nav, parchment body",
|
||||||
|
"dependencies": [
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/components/app-shell.tsx:29-70 — Header עם bg-navy, border-b-3 border-gold, nav links (בית, העלאת מסמכים, אימון סגנון, מיומנויות, אבחון), main content area עם max-w-1400px.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "Visual match to current header",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "יצירת דף בית placeholder",
|
||||||
|
"description": "דף page.tsx עם AppShell ו-placeholder content",
|
||||||
|
"dependencies": [
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"details": "web-ui/src/app/page.tsx:1-27 — דף בית עם כותרת 'עוזר משפטי', תיאור המערכת, gold gradient divider, כרטיס סטטוס.",
|
||||||
|
"status": "done",
|
||||||
|
"testStrategy": "npm run build succeeds (done: 3.8s, 0 errors)",
|
||||||
|
"parentId": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "אישור ויזואלי מהמשתמש — npm run dev",
|
||||||
|
"description": "הרצת dev server ואישור סופי שה-UI תואם לציפיות — header, RTL, fonts, colors",
|
||||||
|
"dependencies": [
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"details": "המשתמש צריך להריץ 'cd web-ui && npm run dev' ולאשר שהכל נראה כמו legal-ai.nautilus.marcusgroup.org. בדיקת dark mode אופציונלית.",
|
||||||
|
"status": "pending",
|
||||||
|
"testStrategy": "User confirms visual parity with current site, RTL works, Heebo font loads",
|
||||||
|
"parentId": "undefined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-04-11T13:50:47.941Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "84",
|
||||||
|
"title": "Phase 2 — API client + generated TypeScript types",
|
||||||
|
"description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "useCases() hook returns typed array from live FastAPI. TypeScript errors if backend endpoint changes without frontend update.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"83"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T15:51:34.020Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "85",
|
||||||
|
"title": "Phase 3 — Core read views (home, case detail, compose)",
|
||||||
|
"description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "Users can browse case list, open a case detail, and view the compose screen with live data from FastAPI. All 3 screens visually match the existing legal-ai identity.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"84"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T16:09:18.006Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "86",
|
||||||
|
"title": "Phase 4 — Forms and wizards (new case, upload, inline edits)",
|
||||||
|
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||||
|
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||||
|
"testStrategy": "Users can create a new case via the multi-step wizard (case appears in Gitea + Paperclip), upload documents with live SSE progress, and edit case fields inline.",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"85"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T16:25:55.569Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"86"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T17:33:42.976Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"87"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T17:44:08.337Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "90",
|
||||||
|
"title": "Phase 4.5 — Practice area integration",
|
||||||
|
"description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md",
|
||||||
|
"details": "",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [
|
||||||
|
"86"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T17:15:57.831Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "91",
|
||||||
|
"title": "Precedent attachment in compose screen",
|
||||||
|
"description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md",
|
||||||
|
"details": "",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "done",
|
||||||
|
"dependencies": [],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T19:20:56.040Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-04-04T07:50:59.999Z",
|
"lastModified": "2026-04-11T19:20:56.040Z",
|
||||||
"taskCount": 51,
|
"taskCount": 60,
|
||||||
"completedCount": 49,
|
"completedCount": 57,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -108,6 +108,16 @@
|
|||||||
3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם
|
3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם
|
||||||
4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח
|
4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח
|
||||||
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
||||||
|
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
|
||||||
|
|
||||||
|
## הערות יו"ר (Chair Feedback)
|
||||||
|
|
||||||
|
מנגנון לתיעוד הערות דפנה על טיוטות:
|
||||||
|
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
|
||||||
|
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
|
||||||
|
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
|
||||||
|
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
|
||||||
|
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
||||||
|
|
||||||
## יו"ר: עו"ד דפנה תמיר
|
## יו"ר: עו"ד דפנה תמיר
|
||||||
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
||||||
|
|||||||
53
Dockerfile
53
Dockerfile
@@ -1,27 +1,40 @@
|
|||||||
FROM python:3.12-slim
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# Dockerfile — Next.js 16 web-ui (ui-rewrite branch only)
|
||||||
|
#
|
||||||
|
# This file REPLACES the FastAPI Dockerfile on this branch so that
|
||||||
|
# Coolify's default /Dockerfile lookup builds the new Next.js staging
|
||||||
|
# UI. The FastAPI Dockerfile lives on `main` and is unaffected.
|
||||||
|
#
|
||||||
|
# When the rewrite is merged to main, decide between:
|
||||||
|
# (a) keeping both via separate Dockerfiles + dockerfile_location config, or
|
||||||
|
# (b) a multi-stage Dockerfile that serves both, or
|
||||||
|
# (c) fully replacing FastAPI's StaticFiles with this Next.js front end.
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY web-ui/package.json web-ui/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
# System deps for PyMuPDF and document processing
|
FROM node:20-alpine AS builder
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
WORKDIR /app
|
||||||
gcc libmupdf-dev libfreetype6-dev libharfbuzz-dev libjpeg62-turbo-dev \
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
libopenjp2-7-dev curl git && rm -rf /var/lib/apt/lists/* && \
|
COPY web-ui/ ./
|
||||||
git config --global init.defaultBranch main
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Copy Ezer Mishpati MCP server source
|
FROM node:20-alpine AS runner
|
||||||
COPY mcp-server/pyproject.toml /app/mcp-server/pyproject.toml
|
WORKDIR /app
|
||||||
COPY mcp-server/src/ /app/mcp-server/src/
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Install MCP server + web deps
|
# next.config.ts uses output: 'standalone', so we copy only the minimal runtime
|
||||||
RUN pip install --no-cache-dir /app/mcp-server && \
|
COPY --from=builder /app/public ./public
|
||||||
pip install --no-cache-dir fastapi uvicorn python-multipart
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Copy web app
|
EXPOSE 3000
|
||||||
COPY web/ /app/web/
|
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/mcp-server/src
|
CMD ["node", "server.js"]
|
||||||
ENV DOTENV_PATH=/dev/null
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8080"]
|
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
|
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
|
||||||
- MUST: מענה לכל טענה שהוצגה בבלוק ז
|
- MUST: מענה לכל טענה שהוצגה בבלוק ז
|
||||||
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
|
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
|
||||||
|
- MUST: **צ'קליסט תוכן** — הפרומפט מזריק `{content_checklist}` אוטומטית לפי סוג הערר (מתוך `lessons.py: CONTENT_CHECKLISTS`). ראה `docs/corpus-analysis.md` לדפוסי תוכן לפי סוג.
|
||||||
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
|
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
|
||||||
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
|
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
|
||||||
- Dependencies: **ALL** previous blocks (ה-ט)
|
- Dependencies: **ALL** previous blocks (ה-ט)
|
||||||
|
|||||||
@@ -161,3 +161,44 @@ Our skill was "over-indexed" on one case type (הכט = rejected appeal). The co
|
|||||||
- Created `create-decision-structure.cjs` script for generating structure DOCX
|
- Created `create-decision-structure.cjs` script for generating structure DOCX
|
||||||
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
|
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
|
||||||
- "Judge Test": every block written as if administrative court judge reads cold
|
- "Judge Test": every block written as if administrative court judge reads cold
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons from Systematic Corpus Analysis (24 decisions, April 2026)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- All 24 proofread decisions in `/data/training/proofread/`
|
||||||
|
- Full analysis: [`docs/corpus-analysis.md`](corpus-analysis.md)
|
||||||
|
- Date: April 2026
|
||||||
|
|
||||||
|
### 12. System Learned Style but Not Substantive Content
|
||||||
|
- **Problem:** Dafna reviewed Kiryat Yearim draft and noted missing planning discussion in block-yod
|
||||||
|
- **Root cause:** The block-yod prompt taught CREAC methodology and "answer all claims" but never said "in licensing cases, include comprehensive planning discussion"
|
||||||
|
- **Fix:** Content checklists added to `lessons.py` (`CONTENT_CHECKLISTS`), injected into block-yod prompt via `{content_checklist}`
|
||||||
|
- **Applied to:** `lessons.py`, `block_writer.py`
|
||||||
|
|
||||||
|
### 13. Corpus Composition — All Licensing, No Betterment Levy
|
||||||
|
- All 24 training decisions are licensing/construction (1xxx)
|
||||||
|
- Zero betterment levy (8xxx) decisions in corpus
|
||||||
|
- Not a current priority gap — focusing on licensing first
|
||||||
|
|
||||||
|
### 14. Planning Discussion Patterns in Licensing Decisions
|
||||||
|
- **Always present** when the appeal reaches substantive planning questions
|
||||||
|
- **Never present** when the appeal is purely jurisdictional or property-based
|
||||||
|
- **Structure**: broad planning context → direct plan provision citations (200-600 words) → application to specific case → planning conclusion
|
||||||
|
- **Deepest planning**: פרומר (pure plan interpretation), לבנון (height/building appendix), בית הכרם (multi-plan TAMA 38)
|
||||||
|
- **No planning**: טלי-אביב (property only), גבאי (jurisdiction only)
|
||||||
|
|
||||||
|
### 15. Five Appeal Subtypes Identified (Not Just Three)
|
||||||
|
Licensing appeals are not homogeneous — the discussion structure varies significantly:
|
||||||
|
1. **Substantive licensing** — full planning discussion + legal analysis (majority of cases)
|
||||||
|
2. **Threshold/jurisdiction** — legal analysis only, no planning
|
||||||
|
3. **Property-focused** — תימוכין קנייניים, minimal planning
|
||||||
|
4. **TAMA 38** — balancing public interest + planning + neighbor impact
|
||||||
|
5. **Deviant use (שימוש חורג)** — deep plan interpretation across multiple plans
|
||||||
|
|
||||||
|
### 16. Chair Feedback System Established
|
||||||
|
- DB table `chair_feedback` records Dafna's comments on drafts
|
||||||
|
- Categories: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
||||||
|
- MCP tools + UI page for recording and reviewing feedback
|
||||||
|
- First entry: Kiryat Yearim — missing planning discussion (2026-04-12)
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ mcp = FastMCP(
|
|||||||
|
|
||||||
# ── Import and register tools ───────────────────────────────────────
|
# ── Import and register tools ───────────────────────────────────────
|
||||||
|
|
||||||
from legal_mcp.tools import ( # noqa: E402
|
from legal_mcp.tools import cases, documents, search, drafting, workflow # noqa: E402
|
||||||
cases, documents, search, drafting, workflow, precedents,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Case management
|
# Case management
|
||||||
@@ -104,48 +102,6 @@ async def case_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|
||||||
"""מחיקת תיק ערר. קבצים בדיסק נשארים אלא אם remove_files=true."""
|
|
||||||
return await cases.case_delete(case_number, remove_files)
|
|
||||||
|
|
||||||
|
|
||||||
# Precedent attachments (user-supplied legal support for the compose phase)
|
|
||||||
@mcp.tool()
|
|
||||||
async def precedent_attach(
|
|
||||||
case_number: str,
|
|
||||||
quote: str,
|
|
||||||
citation: str,
|
|
||||||
section_id: str = "",
|
|
||||||
chair_note: str = "",
|
|
||||||
pdf_document_id: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""צירוף פסיקה תומכת לתיק. section_id ריק = כללי לתיק; אחרת threshold_1/issue_3."""
|
|
||||||
return await precedents.precedent_attach(
|
|
||||||
case_number, quote, citation, section_id, chair_note, pdf_document_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def precedent_list(case_number: str) -> str:
|
|
||||||
"""רשימת כל הפסיקות שצורפו לתיק."""
|
|
||||||
return await precedents.precedent_list(case_number)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def precedent_remove(precedent_id: str) -> str:
|
|
||||||
"""הסרת פסיקה מצורפת."""
|
|
||||||
return await precedents.precedent_remove(precedent_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def precedent_search_library(
|
|
||||||
query: str, practice_area: str = "", limit: int = 10,
|
|
||||||
) -> str:
|
|
||||||
"""חיפוש בספרייה הרוחבית של ציטוטים שנצברו בין תיקים."""
|
|
||||||
return await precedents.precedent_search_library(query, practice_area, limit)
|
|
||||||
|
|
||||||
|
|
||||||
# Documents
|
# Documents
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def document_upload(
|
async def document_upload(
|
||||||
|
|||||||
@@ -489,17 +489,12 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
|||||||
case = await db.get_case(case_id)
|
case = await db.get_case(case_id)
|
||||||
case_number = case.get("case_number", "") if case else ""
|
case_number = case.get("case_number", "") if case else ""
|
||||||
subject = case.get("subject", "") if case else ""
|
subject = case.get("subject", "") if case else ""
|
||||||
practice_area = case.get("practice_area") if case else None
|
|
||||||
appeal_subtype = case.get("appeal_subtype") if case else None
|
|
||||||
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
|
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
|
|
||||||
# Search 1: paragraph_embeddings (from other decisions by Dafna).
|
# Search 1: paragraph_embeddings (from other decisions by Dafna)
|
||||||
# Filter by practice_area + appeal_subtype so we don't pull a
|
|
||||||
# betterment-levy paragraph when writing a building-permit decision.
|
|
||||||
para_results = await db.search_similar_paragraphs(
|
para_results = await db.search_similar_paragraphs(
|
||||||
query_embedding=query_emb, limit=10, block_type="block-yod",
|
query_embedding=query_emb, limit=10, block_type="block-yod",
|
||||||
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
|
||||||
)
|
)
|
||||||
# Filter out same case
|
# Filter out same case
|
||||||
para_results = [r for r in para_results if r.get("case_number", "") != case_number]
|
para_results = [r for r in para_results if r.get("case_number", "") != case_number]
|
||||||
|
|||||||
@@ -200,110 +200,6 @@ CREATE TABLE IF NOT EXISTS appeal_type_rules (
|
|||||||
ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]';
|
ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]';
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ── Phase 4: Practice area separation (multi-tenant axis) ──────────
|
|
||||||
|
|
||||||
SCHEMA_V4_SQL = """
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
-- practice_area = top-level legal domain (multi-tenant axis):
|
|
||||||
-- appeals_committee | national_insurance | labor_law | ...
|
|
||||||
-- appeal_subtype = refines within practice_area:
|
|
||||||
-- building_permit | betterment_levy | compensation_197 | unknown
|
|
||||||
-- Both columns are denormalized to documents/chunks/decisions/style_corpus
|
|
||||||
-- so vector searches can filter without expensive JOINs.
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
|
||||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
|
||||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
|
||||||
ALTER TABLE document_chunks ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
|
||||||
ALTER TABLE document_chunks ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
|
||||||
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
|
||||||
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
|
||||||
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
|
||||||
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cases_practice
|
|
||||||
ON cases(practice_area, appeal_subtype);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chunks_practice
|
|
||||||
ON document_chunks(practice_area);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corpus_practice
|
|
||||||
ON style_corpus(practice_area, appeal_subtype);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_decisions_practice
|
|
||||||
ON decisions(practice_area);
|
|
||||||
|
|
||||||
-- Backfill (idempotent — only fills NULLs)
|
|
||||||
UPDATE cases SET practice_area = 'appeals_committee' WHERE practice_area IS NULL;
|
|
||||||
UPDATE cases SET appeal_subtype = CASE
|
|
||||||
WHEN case_number ~ '^1[0-9]{3}' THEN 'building_permit'
|
|
||||||
WHEN case_number ~ '^8[0-9]{3}' THEN 'betterment_levy'
|
|
||||||
WHEN case_number ~ '^9[0-9]{3}' THEN 'compensation_197'
|
|
||||||
ELSE 'unknown'
|
|
||||||
END WHERE appeal_subtype IS NULL;
|
|
||||||
|
|
||||||
UPDATE documents d
|
|
||||||
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
|
|
||||||
FROM cases c
|
|
||||||
WHERE d.case_id = c.id AND d.practice_area IS NULL;
|
|
||||||
|
|
||||||
UPDATE document_chunks dc
|
|
||||||
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
|
|
||||||
FROM cases c
|
|
||||||
WHERE dc.case_id = c.id AND dc.practice_area IS NULL;
|
|
||||||
|
|
||||||
UPDATE decisions de
|
|
||||||
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
|
|
||||||
FROM cases c
|
|
||||||
WHERE de.case_id = c.id AND de.practice_area IS NULL;
|
|
||||||
|
|
||||||
-- All existing style_corpus entries are דפנה's appeals-committee decisions
|
|
||||||
UPDATE style_corpus SET practice_area = 'appeals_committee' WHERE practice_area IS NULL;
|
|
||||||
|
|
||||||
-- Training corpus documents/chunks have case_id = NULL. All historical
|
|
||||||
-- training material is from דפנה's appeals committee, so default them.
|
|
||||||
UPDATE documents SET practice_area = 'appeals_committee'
|
|
||||||
WHERE case_id IS NULL AND practice_area IS NULL;
|
|
||||||
|
|
||||||
UPDATE document_chunks dc
|
|
||||||
SET practice_area = d.practice_area, appeal_subtype = d.appeal_subtype
|
|
||||||
FROM documents d
|
|
||||||
WHERE dc.document_id = d.id AND dc.practice_area IS NULL;
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ── Phase 5: case_precedents (user-attached legal quotes) ──────────
|
|
||||||
|
|
||||||
SCHEMA_V5_SQL = """
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
-- case_precedents: legal support the chair attaches to a case / section
|
|
||||||
-- during the compose phase. Self-contained — quote + citation are
|
|
||||||
-- stored inline, with an optional FK to an archived PDF in documents.
|
|
||||||
-- Not linked to case_law (which has UNIQUE(case_number)) to keep the
|
|
||||||
-- citation as free-text. A backfill pass into case_law is a future
|
|
||||||
-- follow-up once the UI stabilizes.
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS case_precedents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
|
||||||
section_id TEXT, -- NULL = case-level
|
|
||||||
-- else "threshold_1" / "issue_3"
|
|
||||||
quote TEXT NOT NULL,
|
|
||||||
citation TEXT NOT NULL, -- free-text "מראה מקום"
|
|
||||||
chair_note TEXT DEFAULT '',
|
|
||||||
pdf_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
|
||||||
practice_area TEXT, -- denormalized from cases
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_case
|
|
||||||
ON case_precedents(case_id, section_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_library
|
|
||||||
ON case_precedents(citation);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_area
|
|
||||||
ON case_precedents(practice_area);
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ── Phase 2: Decision + Knowledge + RAG layers ────────────────────
|
# ── Phase 2: Decision + Knowledge + RAG layers ────────────────────
|
||||||
|
|
||||||
SCHEMA_V2_SQL = """
|
SCHEMA_V2_SQL = """
|
||||||
@@ -508,9 +404,7 @@ async def init_schema() -> None:
|
|||||||
await conn.execute(MIGRATIONS_SQL)
|
await conn.execute(MIGRATIONS_SQL)
|
||||||
await conn.execute(SCHEMA_V2_SQL)
|
await conn.execute(SCHEMA_V2_SQL)
|
||||||
await conn.execute(SCHEMA_V3_SQL)
|
await conn.execute(SCHEMA_V3_SQL)
|
||||||
await conn.execute(SCHEMA_V4_SQL)
|
logger.info("Database schema initialized (v1 + v2 + v3)")
|
||||||
await conn.execute(SCHEMA_V5_SQL)
|
|
||||||
logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Case CRUD ───────────────────────────────────────────────────────
|
# ── Case CRUD ───────────────────────────────────────────────────────
|
||||||
@@ -527,8 +421,6 @@ async def create_case(
|
|||||||
hearing_date: date | None = None,
|
hearing_date: date | None = None,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
practice_area: str = "appeals_committee",
|
|
||||||
appeal_subtype: str | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
case_id = uuid4()
|
case_id = uuid4()
|
||||||
@@ -536,43 +428,17 @@ async def create_case(
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
|
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
|
||||||
subject, property_address, permit_number, committee_type,
|
subject, property_address, permit_number, committee_type,
|
||||||
hearing_date, notes, expected_outcome,
|
hearing_date, notes, expected_outcome)
|
||||||
practice_area, appeal_subtype)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
|
|
||||||
case_id, case_number, title,
|
case_id, case_number, title,
|
||||||
json.dumps(appellants or []),
|
json.dumps(appellants or []),
|
||||||
json.dumps(respondents or []),
|
json.dumps(respondents or []),
|
||||||
subject, property_address, permit_number, committee_type,
|
subject, property_address, permit_number, committee_type,
|
||||||
hearing_date, notes, expected_outcome,
|
hearing_date, notes, expected_outcome,
|
||||||
practice_area, appeal_subtype,
|
|
||||||
)
|
)
|
||||||
return await get_case(case_id)
|
return await get_case(case_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_case_practice_area(case_id: UUID) -> tuple[str | None, str | None]:
|
|
||||||
"""Return (practice_area, appeal_subtype) for a case, or (None, None) if missing."""
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1", case_id
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
return None, None
|
|
||||||
return row["practice_area"], row["appeal_subtype"]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_case_practice_area_by_number(case_number: str) -> tuple[str | None, str | None]:
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area, appeal_subtype FROM cases WHERE case_number = $1",
|
|
||||||
case_number,
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
return None, None
|
|
||||||
return row["practice_area"], row["appeal_subtype"]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_case(case_id: UUID) -> dict | None:
|
async def get_case(case_id: UUID) -> dict | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -608,16 +474,6 @@ async def list_cases(status: str | None = None, limit: int = 50) -> list[dict]:
|
|||||||
return [_row_to_case(r) for r in rows]
|
return [_row_to_case(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_case(case_id: UUID) -> bool:
|
|
||||||
"""Delete a case. Dependent rows in documents/document_chunks/qa_results
|
|
||||||
cascade automatically (schema-level ON DELETE CASCADE); audit_log rows
|
|
||||||
nullify their case_id reference. Returns True if a row was deleted."""
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await conn.execute("DELETE FROM cases WHERE id = $1", case_id)
|
|
||||||
return result.endswith(" 1")
|
|
||||||
|
|
||||||
|
|
||||||
async def update_case(case_id: UUID, **fields) -> dict | None:
|
async def update_case(case_id: UUID, **fields) -> dict | None:
|
||||||
if not fields:
|
if not fields:
|
||||||
return await get_case(case_id)
|
return await get_case(case_id)
|
||||||
@@ -648,34 +504,19 @@ def _row_to_case(row: asyncpg.Record) -> dict:
|
|||||||
# ── Document CRUD ───────────────────────────────────────────────────
|
# ── Document CRUD ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_document(
|
async def create_document(
|
||||||
case_id: UUID | None,
|
case_id: UUID,
|
||||||
doc_type: str,
|
doc_type: str,
|
||||||
title: str,
|
title: str,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
page_count: int | None = None,
|
page_count: int | None = None,
|
||||||
practice_area: str | None = None,
|
|
||||||
appeal_subtype: str | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
doc_id = uuid4()
|
doc_id = uuid4()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# If practice_area not explicitly given, inherit from the parent case
|
|
||||||
# (for case-bound documents). Training corpus passes case_id=None and
|
|
||||||
# provides the practice_area directly.
|
|
||||||
if practice_area is None and case_id is not None:
|
|
||||||
case_row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1",
|
|
||||||
case_id,
|
|
||||||
)
|
|
||||||
if case_row:
|
|
||||||
practice_area = case_row["practice_area"]
|
|
||||||
appeal_subtype = case_row["appeal_subtype"]
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO documents (id, case_id, doc_type, title, file_path,
|
"""INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count)
|
||||||
page_count, practice_area, appeal_subtype)
|
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""",
|
|
||||||
doc_id, case_id, doc_type, title, file_path, page_count,
|
doc_id, case_id, doc_type, title, file_path, page_count,
|
||||||
practice_area, appeal_subtype,
|
|
||||||
)
|
)
|
||||||
row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
|
row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
|
||||||
return _row_to_doc(row)
|
return _row_to_doc(row)
|
||||||
@@ -731,113 +572,6 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
# ── case_precedents CRUD ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def _row_to_precedent(row: asyncpg.Record) -> dict:
|
|
||||||
d = dict(row)
|
|
||||||
for k in ("id", "case_id"):
|
|
||||||
if d.get(k) is not None:
|
|
||||||
d[k] = str(d[k])
|
|
||||||
if d.get("pdf_document_id") is not None:
|
|
||||||
d["pdf_document_id"] = str(d["pdf_document_id"])
|
|
||||||
for ts in ("created_at", "updated_at"):
|
|
||||||
if d.get(ts) is not None:
|
|
||||||
d[ts] = d[ts].isoformat()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
async def create_case_precedent(
|
|
||||||
case_id: UUID,
|
|
||||||
quote: str,
|
|
||||||
citation: str,
|
|
||||||
section_id: str | None = None,
|
|
||||||
chair_note: str = "",
|
|
||||||
pdf_document_id: UUID | None = None,
|
|
||||||
practice_area: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Insert a new attached precedent. practice_area is inherited from
|
|
||||||
the parent case when not explicitly supplied, so the cross-case
|
|
||||||
library search can filter without a JOIN."""
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
if practice_area is None:
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area FROM cases WHERE id = $1", case_id
|
|
||||||
)
|
|
||||||
practice_area = row["practice_area"] if row else None
|
|
||||||
inserted = await conn.fetchrow(
|
|
||||||
"""INSERT INTO case_precedents
|
|
||||||
(case_id, section_id, quote, citation, chair_note,
|
|
||||||
pdf_document_id, practice_area)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING *""",
|
|
||||||
case_id, section_id, quote, citation, chair_note,
|
|
||||||
pdf_document_id, practice_area,
|
|
||||||
)
|
|
||||||
return _row_to_precedent(inserted)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"SELECT * FROM case_precedents WHERE case_id = $1 "
|
|
||||||
"ORDER BY section_id NULLS FIRST, created_at",
|
|
||||||
case_id,
|
|
||||||
)
|
|
||||||
return [_row_to_precedent(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_case_precedent(precedent_id: UUID) -> bool:
|
|
||||||
pool = await get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
"DELETE FROM case_precedents WHERE id = $1", precedent_id
|
|
||||||
)
|
|
||||||
return result.endswith(" 1")
|
|
||||||
|
|
||||||
|
|
||||||
async def search_precedent_library(
|
|
||||||
query: str, practice_area: str = "", limit: int = 10,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Cross-case typeahead for the citation field. Returns one row per
|
|
||||||
distinct citation so the user sees each precedent once even if they
|
|
||||||
previously attached it to multiple cases/sections. No embeddings —
|
|
||||||
simple ILIKE is fine at this scale."""
|
|
||||||
pool = await get_pool()
|
|
||||||
pattern = f"%{query}%"
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
if practice_area:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""SELECT DISTINCT ON (citation)
|
|
||||||
id, citation, quote, chair_note, practice_area, created_at
|
|
||||||
FROM case_precedents
|
|
||||||
WHERE practice_area = $1
|
|
||||||
AND (citation ILIKE $2 OR quote ILIKE $2)
|
|
||||||
ORDER BY citation, created_at DESC
|
|
||||||
LIMIT $3""",
|
|
||||||
practice_area, pattern, limit,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""SELECT DISTINCT ON (citation)
|
|
||||||
id, citation, quote, chair_note, practice_area, created_at
|
|
||||||
FROM case_precedents
|
|
||||||
WHERE citation ILIKE $1 OR quote ILIKE $1
|
|
||||||
ORDER BY citation, created_at DESC
|
|
||||||
LIMIT $2""",
|
|
||||||
pattern, limit,
|
|
||||||
)
|
|
||||||
out = []
|
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
d["id"] = str(d["id"])
|
|
||||||
if d.get("created_at"):
|
|
||||||
d["created_at"] = d["created_at"].isoformat()
|
|
||||||
out.append(d)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ── Claims ─────────────────────────────────────────────────────────
|
# ── Claims ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
|
async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
|
||||||
@@ -904,20 +638,12 @@ async def create_decision(
|
|||||||
)
|
)
|
||||||
version = (existing["version"] + 1) if existing else 1
|
version = (existing["version"] + 1) if existing else 1
|
||||||
|
|
||||||
case_row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1", case_id
|
|
||||||
)
|
|
||||||
practice_area = case_row["practice_area"] if case_row else None
|
|
||||||
appeal_subtype = case_row["appeal_subtype"] if case_row else None
|
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
|
"""INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
|
||||||
outcome_reasoning, direction_doc,
|
outcome_reasoning, direction_doc)
|
||||||
practice_area, appeal_subtype)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
|
||||||
decision_id, case_id, version, outcome, outcome_summary,
|
decision_id, case_id, version, outcome, outcome_summary,
|
||||||
outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
|
outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
|
||||||
practice_area, appeal_subtype,
|
|
||||||
)
|
)
|
||||||
return await get_decision(decision_id)
|
return await get_decision(decision_id)
|
||||||
|
|
||||||
@@ -991,37 +717,12 @@ async def store_chunks(
|
|||||||
document_id: UUID,
|
document_id: UUID,
|
||||||
case_id: UUID | None,
|
case_id: UUID | None,
|
||||||
chunks: list[dict],
|
chunks: list[dict],
|
||||||
practice_area: str | None = None,
|
|
||||||
appeal_subtype: str | None = None,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Store document chunks with embeddings. Each chunk dict has:
|
"""Store document chunks with embeddings. Each chunk dict has:
|
||||||
content, section_type, embedding (list[float]), page_number, chunk_index.
|
content, section_type, embedding (list[float]), page_number, chunk_index
|
||||||
|
|
||||||
practice_area defaults to the parent case's value, or — when case_id is
|
|
||||||
None (training corpus) — falls back to the parent document's value so
|
|
||||||
vector search can still filter cleanly.
|
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Resolve practice_area in priority order: explicit > case > document.
|
|
||||||
if practice_area is None:
|
|
||||||
if case_id is not None:
|
|
||||||
case_row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1",
|
|
||||||
case_id,
|
|
||||||
)
|
|
||||||
if case_row:
|
|
||||||
practice_area = case_row["practice_area"]
|
|
||||||
appeal_subtype = case_row["appeal_subtype"]
|
|
||||||
if practice_area is None:
|
|
||||||
doc_row = await conn.fetchrow(
|
|
||||||
"SELECT practice_area, appeal_subtype FROM documents WHERE id = $1",
|
|
||||||
document_id,
|
|
||||||
)
|
|
||||||
if doc_row:
|
|
||||||
practice_area = doc_row["practice_area"]
|
|
||||||
appeal_subtype = doc_row["appeal_subtype"]
|
|
||||||
|
|
||||||
# Delete existing chunks for this document
|
# Delete existing chunks for this document
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"DELETE FROM document_chunks WHERE document_id = $1", document_id
|
"DELETE FROM document_chunks WHERE document_id = $1", document_id
|
||||||
@@ -1029,16 +730,14 @@ async def store_chunks(
|
|||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO document_chunks
|
"""INSERT INTO document_chunks
|
||||||
(document_id, case_id, chunk_index, content, section_type,
|
(document_id, case_id, chunk_index, content, section_type, embedding, page_number)
|
||||||
embedding, page_number, practice_area, appeal_subtype)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
|
||||||
document_id, case_id,
|
document_id, case_id,
|
||||||
chunk["chunk_index"],
|
chunk["chunk_index"],
|
||||||
chunk["content"],
|
chunk["content"],
|
||||||
chunk.get("section_type", "other"),
|
chunk.get("section_type", "other"),
|
||||||
chunk["embedding"],
|
chunk["embedding"],
|
||||||
chunk.get("page_number"),
|
chunk.get("page_number"),
|
||||||
practice_area, appeal_subtype,
|
|
||||||
)
|
)
|
||||||
return len(chunks)
|
return len(chunks)
|
||||||
|
|
||||||
@@ -1048,15 +747,8 @@ async def search_similar(
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
case_id: UUID | None = None,
|
case_id: UUID | None = None,
|
||||||
section_type: str | None = None,
|
section_type: str | None = None,
|
||||||
practice_area: str | None = None,
|
|
||||||
appeal_subtype: str | None = None,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Cosine similarity search on document chunks.
|
"""Cosine similarity search on document chunks."""
|
||||||
|
|
||||||
Filter by practice_area to keep precedents from the same legal domain
|
|
||||||
(e.g. don't surface betterment-levy chunks when working on building
|
|
||||||
permits). Uses the denormalized column on document_chunks — no JOIN.
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
conditions = []
|
conditions = []
|
||||||
params: list = [query_embedding, limit]
|
params: list = [query_embedding, limit]
|
||||||
@@ -1070,14 +762,6 @@ async def search_similar(
|
|||||||
conditions.append(f"dc.section_type = ${param_idx}")
|
conditions.append(f"dc.section_type = ${param_idx}")
|
||||||
params.append(section_type)
|
params.append(section_type)
|
||||||
param_idx += 1
|
param_idx += 1
|
||||||
if practice_area:
|
|
||||||
conditions.append(f"dc.practice_area = ${param_idx}")
|
|
||||||
params.append(practice_area)
|
|
||||||
param_idx += 1
|
|
||||||
if appeal_subtype:
|
|
||||||
conditions.append(f"dc.appeal_subtype = ${param_idx}")
|
|
||||||
params.append(appeal_subtype)
|
|
||||||
param_idx += 1
|
|
||||||
|
|
||||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
|
||||||
@@ -1110,8 +794,6 @@ async def add_to_style_corpus(
|
|||||||
summary: str = "",
|
summary: str = "",
|
||||||
outcome: str = "",
|
outcome: str = "",
|
||||||
key_principles: list[str] | None = None,
|
key_principles: list[str] | None = None,
|
||||||
practice_area: str = "appeals_committee",
|
|
||||||
appeal_subtype: str | None = None,
|
|
||||||
) -> UUID:
|
) -> UUID:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
corpus_id = uuid4()
|
corpus_id = uuid4()
|
||||||
@@ -1119,13 +801,11 @@ async def add_to_style_corpus(
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO style_corpus
|
"""INSERT INTO style_corpus
|
||||||
(id, document_id, decision_number, decision_date,
|
(id, document_id, decision_number, decision_date,
|
||||||
subject_categories, full_text, summary, outcome, key_principles,
|
subject_categories, full_text, summary, outcome, key_principles)
|
||||||
practice_area, appeal_subtype)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
|
|
||||||
corpus_id, document_id, decision_number, decision_date,
|
corpus_id, document_id, decision_number, decision_date,
|
||||||
json.dumps(subject_categories), full_text, summary, outcome,
|
json.dumps(subject_categories), full_text, summary, outcome,
|
||||||
json.dumps(key_principles or []),
|
json.dumps(key_principles or []),
|
||||||
practice_area, appeal_subtype,
|
|
||||||
)
|
)
|
||||||
return corpus_id
|
return corpus_id
|
||||||
|
|
||||||
@@ -1229,15 +909,8 @@ async def search_similar_paragraphs(
|
|||||||
query_embedding: list[float],
|
query_embedding: list[float],
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
block_type: str | None = None,
|
block_type: str | None = None,
|
||||||
practice_area: str | None = None,
|
|
||||||
appeal_subtype: str | None = None,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Search decision paragraphs by semantic similarity.
|
"""Search decision paragraphs by semantic similarity."""
|
||||||
|
|
||||||
Filtering by practice_area uses the denormalized column on `decisions`
|
|
||||||
so we don't pull, e.g., betterment-levy paragraphs when writing a
|
|
||||||
building-permit decision.
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
conditions = []
|
conditions = []
|
||||||
params: list = [query_embedding, limit]
|
params: list = [query_embedding, limit]
|
||||||
@@ -1247,14 +920,6 @@ async def search_similar_paragraphs(
|
|||||||
conditions.append(f"db.block_id = ${param_idx}")
|
conditions.append(f"db.block_id = ${param_idx}")
|
||||||
params.append(block_type)
|
params.append(block_type)
|
||||||
param_idx += 1
|
param_idx += 1
|
||||||
if practice_area:
|
|
||||||
conditions.append(f"d.practice_area = ${param_idx}")
|
|
||||||
params.append(practice_area)
|
|
||||||
param_idx += 1
|
|
||||||
if appeal_subtype:
|
|
||||||
conditions.append(f"d.appeal_subtype = ${param_idx}")
|
|
||||||
params.append(appeal_subtype)
|
|
||||||
param_idx += 1
|
|
||||||
|
|
||||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
"""Practice area + appeal subtype: derivation, validation, constants.
|
|
||||||
|
|
||||||
Two orthogonal axes used to separate legal domains across the system:
|
|
||||||
|
|
||||||
practice_area — top-level domain (multi-tenant axis). Examples:
|
|
||||||
appeals_committee, national_insurance, labor_law.
|
|
||||||
appeal_subtype — refines within a domain. For appeals_committee:
|
|
||||||
building_permit (1xxx), betterment_levy (8xxx),
|
|
||||||
compensation_197 (9xxx), unknown.
|
|
||||||
|
|
||||||
Both columns are denormalized into documents/chunks/decisions/style_corpus
|
|
||||||
so vector searches can filter cheaply.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# ── Enums ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
PRACTICE_AREAS: set[str] = {
|
|
||||||
"appeals_committee",
|
|
||||||
"national_insurance",
|
|
||||||
"labor_law",
|
|
||||||
}
|
|
||||||
|
|
||||||
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
|
|
||||||
"building_permit",
|
|
||||||
"betterment_levy",
|
|
||||||
"compensation_197",
|
|
||||||
"unknown",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
|
||||||
|
|
||||||
# Subtypes per practice_area (extend when adding domains)
|
|
||||||
SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
|
||||||
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
|
|
||||||
"national_insurance": {"unknown"},
|
|
||||||
"labor_law": {"unknown"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Derivation ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_FIRST_DIGIT = re.compile(r"^\s*(\d)")
|
|
||||||
|
|
||||||
_APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
|
|
||||||
"1": "building_permit",
|
|
||||||
"8": "betterment_levy",
|
|
||||||
"9": "compensation_197",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
|
||||||
"""Infer the appeal_subtype from case_number.
|
|
||||||
|
|
||||||
For appeals_committee, the convention is:
|
|
||||||
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
|
||||||
|
|
||||||
For other practice areas there is no public numbering convention yet,
|
|
||||||
so we return 'unknown' until a real rule is defined.
|
|
||||||
"""
|
|
||||||
if practice_area != "appeals_committee":
|
|
||||||
return "unknown"
|
|
||||||
m = _FIRST_DIGIT.match(case_number or "")
|
|
||||||
if not m:
|
|
||||||
return "unknown"
|
|
||||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(m.group(1), "unknown")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Validation ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def validate(practice_area: str, appeal_subtype: str | None) -> None:
|
|
||||||
"""Raise ValueError on unknown values. appeal_subtype=None is allowed."""
|
|
||||||
if practice_area not in PRACTICE_AREAS:
|
|
||||||
raise ValueError(
|
|
||||||
f"unknown practice_area: {practice_area!r}. "
|
|
||||||
f"expected one of {sorted(PRACTICE_AREAS)}"
|
|
||||||
)
|
|
||||||
if appeal_subtype is None:
|
|
||||||
return
|
|
||||||
allowed = SUBTYPES_BY_AREA.get(practice_area, {"unknown"})
|
|
||||||
if appeal_subtype not in allowed:
|
|
||||||
raise ValueError(
|
|
||||||
f"unknown appeal_subtype {appeal_subtype!r} for practice_area "
|
|
||||||
f"{practice_area!r}. expected one of {sorted(allowed)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
|
|
||||||
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
|
||||||
would have produced (and the derived value is not 'unknown')."""
|
|
||||||
derived = derive_subtype(case_number, practice_area)
|
|
||||||
return derived != "unknown" and derived != appeal_subtype
|
|
||||||
@@ -3,13 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import audit, db, practice_area as pa
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
|
||||||
async def case_create(
|
async def case_create(
|
||||||
@@ -24,8 +23,6 @@ async def case_create(
|
|||||||
hearing_date: str = "",
|
hearing_date: str = "",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
practice_area: str = "appeals_committee",
|
|
||||||
appeal_subtype: str = "",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""יצירת תיק ערר חדש.
|
"""יצירת תיק ערר חדש.
|
||||||
|
|
||||||
@@ -41,9 +38,6 @@ async def case_create(
|
|||||||
hearing_date: תאריך דיון (YYYY-MM-DD)
|
hearing_date: תאריך דיון (YYYY-MM-DD)
|
||||||
notes: הערות
|
notes: הערות
|
||||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
|
||||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
|
||||||
ריק = יוסק אוטומטית ממספר התיק
|
|
||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
@@ -51,12 +45,6 @@ async def case_create(
|
|||||||
if hearing_date:
|
if hearing_date:
|
||||||
h_date = date_type.fromisoformat(hearing_date)
|
h_date = date_type.fromisoformat(hearing_date)
|
||||||
|
|
||||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
|
|
||||||
derived_subtype = pa.derive_subtype(case_number, practice_area)
|
|
||||||
if not appeal_subtype:
|
|
||||||
appeal_subtype = derived_subtype
|
|
||||||
pa.validate(practice_area, appeal_subtype)
|
|
||||||
|
|
||||||
case = await db.create_case(
|
case = await db.create_case(
|
||||||
case_number=case_number,
|
case_number=case_number,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -69,22 +57,6 @@ async def case_create(
|
|||||||
hearing_date=h_date,
|
hearing_date=h_date,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
expected_outcome=expected_outcome,
|
expected_outcome=expected_outcome,
|
||||||
practice_area=practice_area,
|
|
||||||
appeal_subtype=appeal_subtype,
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the user overrode the case-number convention (e.g. case 8500 marked
|
|
||||||
# as building_permit), record it so we can audit later.
|
|
||||||
if pa.is_override(case_number, practice_area, appeal_subtype):
|
|
||||||
await audit.log_action(
|
|
||||||
action="case_subtype_override",
|
|
||||||
case_id=UUID(case["id"]),
|
|
||||||
details={
|
|
||||||
"case_number": case_number,
|
|
||||||
"derived_subtype": derived_subtype,
|
|
||||||
"chosen_subtype": appeal_subtype,
|
|
||||||
"practice_area": practice_area,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize git repo for the case
|
# Initialize git repo for the case
|
||||||
@@ -215,37 +187,3 @@ async def case_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return json.dumps(updated, default=str, ensure_ascii=False, indent=2)
|
return json.dumps(updated, default=str, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
|
||||||
"""מחיקת תיק ערר. מסיר את התיק מ-DB עם cascade לכל המסמכים והטענות.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
case_number: מספר תיק הערר
|
|
||||||
remove_files: האם למחוק גם את תיקיית הדיסק (drafts, git repo).
|
|
||||||
ברירת מחדל False — ה-DB נמחק אבל הקבצים נשמרים לגיבוי.
|
|
||||||
"""
|
|
||||||
case = await db.get_case_by_number(case_number)
|
|
||||||
if not case:
|
|
||||||
return json.dumps(
|
|
||||||
{"deleted": False, "reason": f"תיק {case_number} לא נמצא."},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
case_id = UUID(case["id"])
|
|
||||||
ok = await db.delete_case(case_id)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"deleted": ok,
|
|
||||||
"case_number": case_number,
|
|
||||||
"case_id": str(case_id),
|
|
||||||
"removed_files": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
if ok and remove_files:
|
|
||||||
case_dir = config.find_case_dir(case_number)
|
|
||||||
if case_dir.exists():
|
|
||||||
shutil.rmtree(case_dir, ignore_errors=True)
|
|
||||||
result["removed_files"] = True
|
|
||||||
|
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ async def document_upload_training(
|
|||||||
decision_date: str = "",
|
decision_date: str = "",
|
||||||
subject_categories: list[str] | None = None,
|
subject_categories: list[str] | None = None,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
practice_area: str = "appeals_committee",
|
|
||||||
appeal_subtype: str = "",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון (training).
|
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון (training).
|
||||||
|
|
||||||
@@ -116,13 +114,10 @@ async def document_upload_training(
|
|||||||
decision_date: תאריך ההחלטה (YYYY-MM-DD)
|
decision_date: תאריך ההחלטה (YYYY-MM-DD)
|
||||||
subject_categories: קטגוריות - אפשר לבחור כמה (בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197)
|
subject_categories: קטגוריות - אפשר לבחור כמה (בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197)
|
||||||
title: שם המסמך
|
title: שם המסמך
|
||||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
|
||||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
|
||||||
ריק = יוסק אוטומטית ממספר ההחלטה
|
|
||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
from legal_mcp.services import chunker, embeddings, extractor, practice_area as pa
|
from legal_mcp.services import extractor, embeddings, chunker
|
||||||
|
|
||||||
source = Path(file_path)
|
source = Path(file_path)
|
||||||
if not source.exists():
|
if not source.exists():
|
||||||
@@ -131,11 +126,6 @@ async def document_upload_training(
|
|||||||
if not title:
|
if not title:
|
||||||
title = source.stem
|
title = source.stem
|
||||||
|
|
||||||
# Resolve subtype: explicit > derived from decision_number > 'unknown'
|
|
||||||
if not appeal_subtype:
|
|
||||||
appeal_subtype = pa.derive_subtype(decision_number, practice_area)
|
|
||||||
pa.validate(practice_area, appeal_subtype)
|
|
||||||
|
|
||||||
# Copy to training directory (skip if already there)
|
# Copy to training directory (skip if already there)
|
||||||
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
|
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
dest = config.TRAINING_DIR / source.name
|
dest = config.TRAINING_DIR / source.name
|
||||||
@@ -150,29 +140,25 @@ async def document_upload_training(
|
|||||||
if decision_date:
|
if decision_date:
|
||||||
d_date = date_type.fromisoformat(decision_date)
|
d_date = date_type.fromisoformat(decision_date)
|
||||||
|
|
||||||
# Add to style corpus (tagged by domain so block-writer can filter)
|
# Add to style corpus
|
||||||
corpus_id = await db.add_to_style_corpus(
|
corpus_id = await db.add_to_style_corpus(
|
||||||
document_id=None,
|
document_id=None,
|
||||||
decision_number=decision_number,
|
decision_number=decision_number,
|
||||||
decision_date=d_date,
|
decision_date=d_date,
|
||||||
subject_categories=subject_categories or [],
|
subject_categories=subject_categories or [],
|
||||||
full_text=text,
|
full_text=text,
|
||||||
practice_area=practice_area,
|
|
||||||
appeal_subtype=appeal_subtype,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Chunk and embed for RAG search over training corpus
|
# Chunk and embed for RAG search over training corpus
|
||||||
chunks = chunker.chunk_document(text)
|
chunks = chunker.chunk_document(text)
|
||||||
if chunks:
|
if chunks:
|
||||||
# Create a document record (no case association — tag explicitly)
|
# Create a document record (no case association)
|
||||||
doc = await db.create_document(
|
doc = await db.create_document(
|
||||||
case_id=None,
|
case_id=None,
|
||||||
doc_type="decision",
|
doc_type="decision",
|
||||||
title=f"[קורפוס] {title}",
|
title=f"[קורפוס] {title}",
|
||||||
file_path=str(dest),
|
file_path=str(dest),
|
||||||
page_count=page_count,
|
page_count=page_count,
|
||||||
practice_area=practice_area,
|
|
||||||
appeal_subtype=appeal_subtype,
|
|
||||||
)
|
)
|
||||||
doc_id = UUID(doc["id"])
|
doc_id = UUID(doc["id"])
|
||||||
await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
|
await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
|
||||||
@@ -190,10 +176,7 @@ async def document_upload_training(
|
|||||||
}
|
}
|
||||||
for c, emb in zip(chunks, embs)
|
for c, emb in zip(chunks, embs)
|
||||||
]
|
]
|
||||||
await db.store_chunks(
|
await db.store_chunks(doc_id, None, chunk_dicts)
|
||||||
doc_id, None, chunk_dicts,
|
|
||||||
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
|
||||||
)
|
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"corpus_id": str(corpus_id),
|
"corpus_id": str(corpus_id),
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
"""MCP tools for attached legal precedents (user-supplied case-law quotes).
|
|
||||||
|
|
||||||
These complement the existing `case_law` table (which is populated from
|
|
||||||
structured sources and is what the block-writer RAG searches) by storing
|
|
||||||
free-text citations the chair attaches during the compose phase.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from legal_mcp.services import db
|
|
||||||
|
|
||||||
|
|
||||||
async def precedent_attach(
|
|
||||||
case_number: str,
|
|
||||||
quote: str,
|
|
||||||
citation: str,
|
|
||||||
section_id: str = "",
|
|
||||||
chair_note: str = "",
|
|
||||||
pdf_document_id: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""צירוף פסיקה תומכת לתיק ערר.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
case_number: מספר תיק הערר
|
|
||||||
quote: הציטוט המדויק שיוכנס להחלטה
|
|
||||||
citation: מראה המקום (ערר 1126-08-25 ... נ' ... (נבו 9.3.2026))
|
|
||||||
section_id: מזהה הטענה/סוגיה (threshold_1, issue_3); ריק = כללי לתיק
|
|
||||||
chair_note: הערה אופציונלית — למה הציטוט תומך בעמדה
|
|
||||||
pdf_document_id: מזהה קובץ PDF מצורף (אופציונלי)
|
|
||||||
"""
|
|
||||||
case = await db.get_case_by_number(case_number)
|
|
||||||
if not case:
|
|
||||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
|
||||||
|
|
||||||
pdf_uuid: UUID | None = None
|
|
||||||
if pdf_document_id:
|
|
||||||
try:
|
|
||||||
pdf_uuid = UUID(pdf_document_id)
|
|
||||||
except ValueError:
|
|
||||||
return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False)
|
|
||||||
|
|
||||||
row = await db.create_case_precedent(
|
|
||||||
case_id=UUID(case["id"]),
|
|
||||||
quote=quote,
|
|
||||||
citation=citation,
|
|
||||||
section_id=section_id or None,
|
|
||||||
chair_note=chair_note,
|
|
||||||
pdf_document_id=pdf_uuid,
|
|
||||||
practice_area=case.get("practice_area"),
|
|
||||||
)
|
|
||||||
return json.dumps(row, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def precedent_list(case_number: str) -> str:
|
|
||||||
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
|
|
||||||
case = await db.get_case_by_number(case_number)
|
|
||||||
if not case:
|
|
||||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
|
||||||
|
|
||||||
rows = await db.list_case_precedents(UUID(case["id"]))
|
|
||||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def precedent_remove(precedent_id: str) -> str:
|
|
||||||
"""הסרת פסיקה מצורפת. קובץ ה-PDF (אם צורף) נשאר ב-documents לצורך audit."""
|
|
||||||
try:
|
|
||||||
pid = UUID(precedent_id)
|
|
||||||
except ValueError:
|
|
||||||
return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False)
|
|
||||||
|
|
||||||
ok = await db.delete_case_precedent(pid)
|
|
||||||
return json.dumps(
|
|
||||||
{"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def precedent_search_library(
|
|
||||||
query: str, practice_area: str = "", limit: int = 10,
|
|
||||||
) -> str:
|
|
||||||
"""חיפוש בספרייה הרוחבית — כל הפסיקות שצורפו אי-פעם בכל התיקים.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: מחרוזת חיפוש (מתחרה מול citation ומול quote)
|
|
||||||
practice_area: אופציונלי — סינון לתחום משפטי מסוים
|
|
||||||
limit: מספר תוצאות מקסימלי
|
|
||||||
"""
|
|
||||||
if not query or len(query.strip()) < 2:
|
|
||||||
return json.dumps([], ensure_ascii=False)
|
|
||||||
|
|
||||||
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
|
|
||||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
|
||||||
@@ -3,52 +3,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import db, embeddings
|
from legal_mcp.services import db, embeddings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def search_decisions(
|
async def search_decisions(
|
||||||
query: str,
|
query: str,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
section_type: str = "",
|
section_type: str = "",
|
||||||
practice_area: str = "",
|
|
||||||
appeal_subtype: str = "",
|
|
||||||
case_number: str = "",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.
|
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: שאילתת חיפוש בעברית
|
query: שאילתת חיפוש בעברית (לדוגמה: "שימוש חורג למסחר באזור מגורים")
|
||||||
limit: מספר תוצאות מקסימלי
|
limit: מספר תוצאות מקסימלי
|
||||||
section_type: סינון לפי סוג סעיף (facts, legal_analysis, ...)
|
section_type: סינון לפי סוג סעיף (facts, legal_analysis, conclusion, ruling, וכו'). ריק = הכל
|
||||||
practice_area: תחום משפטי לסינון (appeals_committee/national_insurance/...)
|
|
||||||
appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197)
|
|
||||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
|
||||||
"""
|
"""
|
||||||
# Auto-resolve practice_area from case_number if available
|
|
||||||
if case_number and not practice_area:
|
|
||||||
case = await db.get_case_by_number(case_number)
|
|
||||||
if case:
|
|
||||||
practice_area = case.get("practice_area") or ""
|
|
||||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
|
||||||
|
|
||||||
if not practice_area:
|
|
||||||
logger.warning(
|
|
||||||
"search_decisions called without practice_area filter — "
|
|
||||||
"results may mix legal domains"
|
|
||||||
)
|
|
||||||
|
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
results = await db.search_similar(
|
results = await db.search_similar(
|
||||||
query_embedding=query_emb,
|
query_embedding=query_emb,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
section_type=section_type or None,
|
section_type=section_type or None,
|
||||||
practice_area=practice_area or None,
|
|
||||||
appeal_subtype=appeal_subtype or None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
@@ -85,7 +61,6 @@ async def search_case_documents(
|
|||||||
return f"תיק {case_number} לא נמצא."
|
return f"תיק {case_number} לא נמצא."
|
||||||
|
|
||||||
query_emb = await embeddings.embed_query(query)
|
query_emb = await embeddings.embed_query(query)
|
||||||
# Restricted to case_id — practice_area filter would be redundant.
|
|
||||||
results = await db.search_similar(
|
results = await db.search_similar(
|
||||||
query_embedding=query_emb,
|
query_embedding=query_emb,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -111,37 +86,17 @@ async def search_case_documents(
|
|||||||
async def find_similar_cases(
|
async def find_similar_cases(
|
||||||
description: str,
|
description: str,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
practice_area: str = "",
|
|
||||||
appeal_subtype: str = "",
|
|
||||||
case_number: str = "",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.
|
"""מציאת תיקים דומים על בסיס תיאור.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: תיאור התיק או הנושא
|
description: תיאור התיק או הנושא (לדוגמה: "ערר על סירוב להיתר בנייה לתוספת קומה")
|
||||||
limit: מספר תוצאות מקסימלי
|
limit: מספר תוצאות מקסימלי
|
||||||
practice_area: תחום משפטי לסינון
|
|
||||||
appeal_subtype: סוג ערר לסינון
|
|
||||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
|
||||||
"""
|
"""
|
||||||
if case_number and not practice_area:
|
|
||||||
case = await db.get_case_by_number(case_number)
|
|
||||||
if case:
|
|
||||||
practice_area = case.get("practice_area") or ""
|
|
||||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
|
||||||
|
|
||||||
if not practice_area:
|
|
||||||
logger.warning(
|
|
||||||
"find_similar_cases called without practice_area filter — "
|
|
||||||
"results may mix legal domains"
|
|
||||||
)
|
|
||||||
|
|
||||||
query_emb = await embeddings.embed_query(description)
|
query_emb = await embeddings.embed_query(description)
|
||||||
results = await db.search_similar(
|
results = await db.search_similar(
|
||||||
query_embedding=query_emb,
|
query_embedding=query_emb,
|
||||||
limit=limit * 3, # Get more to deduplicate by case
|
limit=limit * 3, # Get more to deduplicate by case
|
||||||
practice_area=practice_area or None,
|
|
||||||
appeal_subtype=appeal_subtype or None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
|
|||||||
@@ -499,3 +499,39 @@ description: This skill should be used when writing legal decisions (החלטו
|
|||||||
| תיבות תמונה | מסגרת עם shading אפור בהיר (fill: "F0F0F0"), טקסט "📷 תמונה: [תיאור]" | ShadingType.CLEAR |
|
| תיבות תמונה | מסגרת עם shading אפור בהיר (fill: "F0F0F0"), טקסט "📷 תמונה: [תיאור]" | ShadingType.CLEAR |
|
||||||
| חתימות | טבלה ללא גבולות (`visuallyRightToLeft: true`), 2 טורים | כמו בתבנית ב-create-legal-doc.js |
|
| חתימות | טבלה ללא גבולות (`visuallyRightToLeft: true`), 2 טורים | כמו בתבנית ב-create-legal-doc.js |
|
||||||
| כותרת מוסדית | טבלה ללא גבולות, 2 טורים: ימין=מוסד, שמאל=מספרי תיק | `visuallyRightToLeft: true` |
|
| כותרת מוסדית | טבלה ללא גבולות, 2 טורים: ימין=מוסד, שמאל=מספרי תיק | `visuallyRightToLeft: true` |
|
||||||
|
|
||||||
|
|
||||||
|
## 12. צ'קליסט תוכן לפי סוג ערר
|
||||||
|
|
||||||
|
> נוסף אפריל 2026 בעקבות ניתוח שיטתי של 24 החלטות. ראה: `docs/corpus-analysis.md`
|
||||||
|
|
||||||
|
הפרומפט של בלוק י מקבל **צ'קליסט תוכן** אוטומטי לפי סוג הערר (`lessons.py: CONTENT_CHECKLISTS`). זה מבטיח שהדיון יכסה את הנושאים הנדרשים — לא רק סגנון ומתודולוגיה, אלא תוכן ענייני.
|
||||||
|
|
||||||
|
### 12.1 חמישה תת-סוגי רישוי (לא שלושה)
|
||||||
|
ניתוח הקורפוס חשף שלתיקי רישוי יש 5 תת-סוגים שונים מבחינת מבנה הדיון:
|
||||||
|
|
||||||
|
| תת-סוג | מה בדיון | דוגמאות |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **רישוי מהותי** | דיון תכנוני מקיף + משפטי | רוב ההחלטות |
|
||||||
|
| **סף/סמכות** | משפטי בלבד, ללא תכנון | גבאי, ירושלים שקופה |
|
||||||
|
| **קנייני** | תימוכין קנייניים, מינימום תכנון | טלי-אביב, הראל 1043 |
|
||||||
|
| **תמ"א 38** | איזון אינטרסים + תכנון + שכנות | בית הכרם |
|
||||||
|
| **שימוש חורג** | פרשנות תכניות מרובות | תורן |
|
||||||
|
|
||||||
|
### 12.2 דיון תכנוני — מתי ואיך
|
||||||
|
**מתי חובה:** כשהערר מגיע לדיון מהותי (לא סף/סמכות, לא קנייני טהור).
|
||||||
|
|
||||||
|
**מבנה טיפוסי (מהקורפוס):**
|
||||||
|
1. הקשר תכנוני רחב — תכניות חלות, ייעוד, סביבה (2-8 סעיפים)
|
||||||
|
2. ציטוט ישיר מהוראות תכנית — בלוקים של 200-600 מילים עם "הדגשת הח"מ"
|
||||||
|
3. יישום על המקרה — הוראה → עובדה → מסקנה
|
||||||
|
4. מסקנה תכנונית — תואם/סוטה, מוצדק/לא
|
||||||
|
|
||||||
|
**נושאים שמופיעים בתדירות גבוהה:**
|
||||||
|
- חניה (8/24 החלטות) — הנושא התכנוני הנפוץ ביותר, עומק של 5-15 סעיפים
|
||||||
|
- קווי בניין (7/24) — כולל ניתוח סטייה ניכרת
|
||||||
|
- ניתוח הוראות תכנית (18/24) — כמעט תמיד
|
||||||
|
- פגיעה בשכנים (5/24) — צל, פרטיות, רעש
|
||||||
|
|
||||||
|
### 12.3 הערות יו"ר
|
||||||
|
הערות דפנה על טיוטות מתועדות במערכת `chair_feedback` (DB + API + UI ב-`/feedback`). כל הערה מסווגת לקטגוריה ומפיקה לקח שמשפר את ההחלטות הבאות.
|
||||||
|
|||||||
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
|
||||||
175
web-ui/README.md
Normal file
175
web-ui/README.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# עוזר משפטי — Web UI (Next.js rewrite)
|
||||||
|
|
||||||
|
The Next.js 16 rewrite of `legal-ai.nautilus.marcusgroup.org`, currently hosted side-by-side with the legacy vanilla `index.html` at:
|
||||||
|
|
||||||
|
- **Staging:** https://legal-ai-next.nautilus.marcusgroup.org (auto-deployed from `ui-rewrite` branch via Coolify)
|
||||||
|
- **Production FastAPI:** https://legal-ai.nautilus.marcusgroup.org (same backend, old UI still default)
|
||||||
|
|
||||||
|
The rewrite talks to the existing FastAPI via proxy rewrites in `next.config.ts` — no CORS setup, no duplicated backend.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 16.2.3 (App Router, Turbopack, `output: "standalone"`)
|
||||||
|
- React 19.2 · TypeScript · Tailwind v4 · shadcn/ui (radix-nova preset)
|
||||||
|
- TanStack Query v5 + TanStack Table v8
|
||||||
|
- react-hook-form + zod for mutations
|
||||||
|
- react-dropzone for uploads; EventSource for SSE progress
|
||||||
|
- Heebo via `next/font/google`; design tokens in `src/app/globals.css`
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
npm run build # full type check + production build
|
||||||
|
npm run lint
|
||||||
|
npm run api:types # regenerate src/lib/api/types.ts from FastAPI's OpenAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
### API connection
|
||||||
|
|
||||||
|
By default the dev server proxies to production FastAPI (`https://legal-ai.nautilus.marcusgroup.org`). To point at a different backend, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NEXT_PUBLIC_API_ORIGIN=http://localhost:8000
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Route segments (App Router)
|
||||||
|
│ ├── layout.tsx # Root: Providers + RTL html + fonts
|
||||||
|
│ ├── page.tsx # Home: KPIs + status donut + cases table
|
||||||
|
│ ├── error.tsx # Route-segment error boundary
|
||||||
|
│ ├── global-error.tsx # Root crash fallback
|
||||||
|
│ ├── not-found.tsx # 404
|
||||||
|
│ ├── cases/
|
||||||
|
│ │ ├── new/ # 3-step create wizard
|
||||||
|
│ │ └── [caseNumber]/
|
||||||
|
│ │ ├── page.tsx # Case detail (tabs + workflow timeline)
|
||||||
|
│ │ └── compose/ # Research analysis + chair-position editor
|
||||||
|
│ ├── training/ # Style portrait + corpus + compare (3 tabs)
|
||||||
|
│ ├── skills/ # Paperclip skills inventory
|
||||||
|
│ └── diagnostics/ # DB health + failed/stuck docs
|
||||||
|
├── components/
|
||||||
|
│ ├── app-shell.tsx # Header + nav with aria-current
|
||||||
|
│ ├── cases/ # Home + detail screens
|
||||||
|
│ ├── compose/ # Research analysis editor
|
||||||
|
│ ├── documents/ # UploadSheet
|
||||||
|
│ ├── training/ # Style report / corpus / compare panels
|
||||||
|
│ ├── wizard/ # Case create wizard + parties-field
|
||||||
|
│ └── ui/ # shadcn primitives
|
||||||
|
├── lib/
|
||||||
|
│ ├── api/ # Typed hooks per domain (cases, documents, research,
|
||||||
|
│ │ # system, skills, training)
|
||||||
|
│ ├── schemas/ # zod schemas (case create / update)
|
||||||
|
│ ├── practice-area.ts # Multi-tenant axis enum + deriveSubtype()
|
||||||
|
│ ├── sse.ts # EventSource wrapper
|
||||||
|
│ ├── providers.tsx # QueryClient + Toaster
|
||||||
|
│ └── utils.ts # cn()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test (run after every deploy)
|
||||||
|
|
||||||
|
Use any browser at the staging URL. Every step should be doable **without console errors** and each mutation should produce a visible toast.
|
||||||
|
|
||||||
|
### 1. Home · `/`
|
||||||
|
- [ ] Header nav shows 5 items; the current page is underlined in gold
|
||||||
|
- [ ] 4 KPI cards render real numbers (סה״כ · בהכנה · בכתיבה · מוכנים)
|
||||||
|
- [ ] Cases table lists existing cases; search filters by case number or title
|
||||||
|
- [ ] "פיזור סטטוסים" donut renders with a legend
|
||||||
|
- [ ] "+ תיק חדש" button in the top-left navigates to `/cases/new`
|
||||||
|
|
||||||
|
### 2. Create case · `/cases/new`
|
||||||
|
- [ ] 3-step wizard: פרטי יסוד → צדדים → השלמות
|
||||||
|
- [ ] Type `1500-25` → appeal_subtype auto-fills to "רישוי ובנייה"
|
||||||
|
- [ ] Type `8500-25` → subtype auto-fills to "היטל השבחה"
|
||||||
|
- [ ] Manually pick a different subtype → auto-fill stops
|
||||||
|
- [ ] Submitting with invalid case number shows a zod field error (no crash)
|
||||||
|
- [ ] Successful create → toast "תיק חדש נוצר" → router pushes to `/cases/{number}`
|
||||||
|
|
||||||
|
### 3. Case detail · `/cases/[caseNumber]`
|
||||||
|
- [ ] Header shows status badge + gold "ועדת ערר · X" practice-area badge
|
||||||
|
- [ ] Tabs switch cleanly: סקירה / מסמכים / פעולות
|
||||||
|
- [ ] Workflow timeline on the right shows the current phase highlighted in gold
|
||||||
|
- [ ] פעולות tab → "עריכת פרטי תיק" dialog opens; submitting updates the header without full reload (optimistic cache patch)
|
||||||
|
- [ ] "העלאת מסמכים" sheet opens from the tab row; drag-drop fires a POST and a live progress bar appears via SSE
|
||||||
|
|
||||||
|
### 4. Compose · `/cases/[caseNumber]/compose`
|
||||||
|
- [ ] If analysis-and-research.md exists: threshold claims + issues render as collapsible cards
|
||||||
|
- [ ] Chair-position textarea auto-saves on blur with "✓ נשמר {time}" indicator
|
||||||
|
- [ ] If 404 (no analysis yet): empty state card renders, no error toast
|
||||||
|
|
||||||
|
### 5. Training · `/training`
|
||||||
|
- [ ] **Report tab:** headline card, 4 KPIs, subject donut, anatomy bars, top-12 signature phrases
|
||||||
|
- [ ] **Corpus tab:** table of corpus decisions with a trash icon per row (aria-label present)
|
||||||
|
- [ ] Deleting a decision refreshes both the corpus table and the report KPIs
|
||||||
|
- [ ] **Compare tab:** two Selects, pick 2 different decisions, side-by-side panels + shared/only-A/only-B pattern lists
|
||||||
|
|
||||||
|
### 6. Skills · `/skills`
|
||||||
|
- [ ] Card grid of Paperclip skills with sync-status badges (מסונכרן / DB בלבד / לא סונכרן)
|
||||||
|
- [ ] Chars + file counts render; "לא ידוע" doesn't appear for installed skills
|
||||||
|
|
||||||
|
### 7. Diagnostics · `/diagnostics`
|
||||||
|
- [ ] DB status card shows "מחובר" in green
|
||||||
|
- [ ] Table counts populate for cases / documents / chunks / corpus / patterns
|
||||||
|
- [ ] Failed + stuck document lists render (empty states OK)
|
||||||
|
- [ ] Page self-refreshes every 10s — check the network tab for recurring calls
|
||||||
|
|
||||||
|
### 8. Error boundary
|
||||||
|
- [ ] Visit `/cases/NOT-REAL-999-99` → case detail shows an error card with the FastAPI message and "חזרה לרשימת התיקים" button (no white screen)
|
||||||
|
- [ ] Visit `/anything-broken-xyz` → custom 404 page with "חזרה לבית" button
|
||||||
|
|
||||||
|
### 9. Keyboard + RTL
|
||||||
|
- [ ] Tab through the home page — focus rings are gold, visible
|
||||||
|
- [ ] Wizard progresses via Enter on the "הבא" button
|
||||||
|
- [ ] Screen reader announces nav items with "עמוד נוכחי" on the active one
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```
|
||||||
|
git push # → Coolify auto-build on branch ui-rewrite (~90 s)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Known issue:** the Gitea → Coolify webhook is not firing at the time of writing. Trigger a manual deploy via the Coolify MCP (`mcp__coolify__deploy` with app UUID `l146g36mtlp0k03vrwkyrgkk`) or the Coolify UI until the webhook is fixed.
|
||||||
|
|
||||||
|
## Phase tracking
|
||||||
|
|
||||||
|
See `~/.claude/plans/joyful-marinating-sutton.md` for the 7-phase rewrite plan and `~/legal-ai-ui-rewrite/.taskmaster/tasks/tasks.json` for the task board.
|
||||||
|
|
||||||
|
| Phase | Scope | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Scaffold + Coolify staging | ✅ |
|
||||||
|
| 2 | API client + typed hooks + probe | ✅ |
|
||||||
|
| 3 | Read views (home, case detail, compose) | ✅ |
|
||||||
|
| 4 | Mutations (wizard, edit, upload+SSE) | ✅ |
|
||||||
|
| 4.5 | Practice-area integration | ✅ |
|
||||||
|
| 5 | Secondary screens (training, skills, diagnostics) | ✅ |
|
||||||
|
| 6 | Polish, a11y, error boundaries, smoke test | ✅ |
|
||||||
|
| 7 | DNS cutover to production | pending |
|
||||||
|
|
||||||
|
## Backend contract
|
||||||
|
|
||||||
|
The new UI consumes the existing FastAPI at `legal-ai/web/app.py`. Key endpoints currently relied on:
|
||||||
|
|
||||||
|
| Endpoint | Hook | Used by |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/cases?detail=true` | `useCases` | home table, KPIs |
|
||||||
|
| `GET /api/cases/{n}/details` | `useCase` | case detail |
|
||||||
|
| `POST /api/cases/create` | `useCreateCase` | wizard |
|
||||||
|
| `PUT /api/cases/{n}` | `useUpdateCase` | inline edit |
|
||||||
|
| `DELETE /api/cases?case_number=...` | (MCP only so far) | admin cleanup |
|
||||||
|
| `POST /api/cases/{n}/documents/upload-tagged` | `useUploadDocument` | upload sheet |
|
||||||
|
| `GET /api/progress/{task_id}` (SSE) | `useProgress` | upload progress |
|
||||||
|
| `GET /api/cases/{n}/research/analysis` | `useResearchAnalysis` | compose |
|
||||||
|
| `PATCH .../chair-position` | `useSaveChairPosition` | chair editor |
|
||||||
|
| `GET /api/training/style-report` | `useStyleReport` | training/report tab |
|
||||||
|
| `GET /api/training/corpus` | `useCorpus` | training/corpus tab |
|
||||||
|
| `GET /api/training/compare` | `useCompare` | training/compare tab |
|
||||||
|
| `DELETE /api/training/corpus/{id}` | `useDeleteCorpusEntry` | corpus tab |
|
||||||
|
| `GET /api/system/diagnostics` | `useDiagnostics` | diagnostics page |
|
||||||
|
| `GET /api/admin/skills` | `useSkills` | skills page |
|
||||||
|
|
||||||
|
Any new endpoint should get a typed hook in `src/lib/api/` — do not reach into `fetch` from component code.
|
||||||
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;
|
||||||
13235
web-ui/package-lock.json
generated
Normal file
13235
web-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
web-ui/package.json
Normal file
45
web-ui/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.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 |
229
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
229
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"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 { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||||
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import { useCase } from "@/lib/api/cases";
|
||||||
|
import { useResearchAnalysis } from "@/lib/api/research";
|
||||||
|
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Markdown content={content.trim()} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComposePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ caseNumber: string }>;
|
||||||
|
}) {
|
||||||
|
const { caseNumber } = use(params);
|
||||||
|
const caseQuery = useCase(caseNumber);
|
||||||
|
const analysis = useResearchAnalysis(caseNumber);
|
||||||
|
const precedentsQuery = useCasePrecedents(caseNumber);
|
||||||
|
|
||||||
|
/* Partition the flat list into scopes so each child renders its own slice
|
||||||
|
* without re-fetching. Done once at the page level. */
|
||||||
|
const allPrecedents = precedentsQuery.data ?? [];
|
||||||
|
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
|
||||||
|
const precedentsBySection = new Map<string, typeof allPrecedents>();
|
||||||
|
for (const p of allPrecedents) {
|
||||||
|
if (p.section_id) {
|
||||||
|
const existing = precedentsBySection.get(p.section_id) ?? [];
|
||||||
|
existing.push(p);
|
||||||
|
precedentsBySection.set(p.section_id, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const practiceArea = caseQuery.data?.practice_area ?? null;
|
||||||
|
|
||||||
|
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="space-y-6">
|
||||||
|
{/* Case-level general precedents */}
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-navy text-xl mb-1">פסיקה כללית לדיון</h2>
|
||||||
|
<p className="text-[0.78rem] text-ink-muted mb-4">
|
||||||
|
ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון).
|
||||||
|
</p>
|
||||||
|
<PrecedentsSection
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
sectionId={null}
|
||||||
|
precedents={caseLevelPrecedents}
|
||||||
|
practiceArea={practiceArea}
|
||||||
|
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<SubsectionCard
|
||||||
|
key={tc.id}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
item={tc}
|
||||||
|
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||||
|
practiceArea={practiceArea}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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}
|
||||||
|
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||||
|
practiceArea={practiceArea}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background prose — moved below the issues so it reads as
|
||||||
|
supporting context after the chair has seen the main
|
||||||
|
decision points, not as a wall of text beside them. */}
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-5">
|
||||||
|
<h2 className="text-navy text-xl 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-6 py-5 space-y-3">
|
||||||
|
<h2 className="text-gold-deep text-xl mb-0">מסקנות</h2>
|
||||||
|
<Markdown content={analysis.data.conclusions.trim()} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
136
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"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 { expectedOutcomes } from "@/lib/schemas/case";
|
||||||
|
import { useCase } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
const expectedOutcomeLabel = data?.expected_outcome
|
||||||
|
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||||
|
: null;
|
||||||
|
|
||||||
|
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">
|
||||||
|
{expectedOutcomeLabel ?? "לא נקבעה תוצאה צפויה."}
|
||||||
|
</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="flex items-center gap-3 flex-wrap">
|
||||||
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
|
<Link href={`/cases/${caseNumber}/compose`}>
|
||||||
|
פתח בעורך ההחלטה
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{data && <CaseEditDialog data={data} />}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
web-ui/src/app/diagnostics/page.tsx
Normal file
219
web-ui/src/app/diagnostics/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
||||||
|
|
||||||
|
const TABLE_LABELS: Record<string, string> = {
|
||||||
|
cases: "תיקים",
|
||||||
|
documents: "מסמכים",
|
||||||
|
document_chunks: "chunks",
|
||||||
|
style_corpus: "קורפוס סגנון",
|
||||||
|
style_patterns: "דפוסי סגנון",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRelativeTime(iso: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const then = new Date(iso);
|
||||||
|
const diffMs = Date.now() - then.getTime();
|
||||||
|
const min = Math.floor(diffMs / 60000);
|
||||||
|
if (min < 1) return "עכשיו";
|
||||||
|
if (min < 60) return `לפני ${min} דקות`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `לפני ${hr} שעות`;
|
||||||
|
const days = Math.floor(hr / 24);
|
||||||
|
if (days < 30) return `לפני ${days} ימים`;
|
||||||
|
return then.toLocaleDateString("he-IL");
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
||||||
|
const cls =
|
||||||
|
tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={`rounded border px-3 py-2 flex items-center gap-3 text-sm ${cls}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-ink font-medium truncate" title={doc.title}>
|
||||||
|
{doc.title || "(ללא כותרת)"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted flex gap-3 mt-0.5">
|
||||||
|
{doc.case_number && (
|
||||||
|
<Link href={`/cases/${doc.case_number}`} className="hover:text-gold-deep">
|
||||||
|
ערר {doc.case_number}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span>{formatRelativeTime(doc.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">{doc.status}</Badge>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiagnosticsPage() {
|
||||||
|
const { data, isPending, error } = useDiagnostics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted 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, מסמכים שנכשלו או תקועים, ומשימות רקע פעילות. מתעדכן כל 10
|
||||||
|
שניות.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-6 text-center text-danger">
|
||||||
|
{error.message}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* DB status + table counts */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 flex flex-col items-start gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
<Database className="w-3.5 h-3.5" />
|
||||||
|
מצב DB
|
||||||
|
</div>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
) : data?.db_ok ? (
|
||||||
|
<div className="flex items-center gap-2 text-success font-semibold">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span>מחובר</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-danger font-semibold">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
<span>מנותק</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<div className="text-ink-muted text-[0.72rem] uppercase tracking-wider mb-3">
|
||||||
|
ספירת טבלאות
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-5 gap-y-2 gap-x-4">
|
||||||
|
{Object.keys(TABLE_LABELS).map((key) => (
|
||||||
|
<div key={key} className="space-y-0.5">
|
||||||
|
<dt className="text-[0.72rem] text-ink-muted">
|
||||||
|
{TABLE_LABELS[key]}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||||||
|
{isPending ? "—" : (data?.tables[key] ?? "—")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active tasks */}
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
משימות רקע פעילות
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{data?.active_tasks.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</h2>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
) : data?.active_tasks.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין משימות פעילות</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data?.active_tasks.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.task_id}
|
||||||
|
className="flex items-center gap-3 text-sm rounded bg-rule-soft/60 border border-rule px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-ink truncate">
|
||||||
|
{t.filename || t.task_id}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
{t.step || t.status}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Failed + stuck docs */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-danger text-lg mb-3 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
מסמכים שנכשלו
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{data?.failed_documents.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</h2>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
) : data?.failed_documents.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין כשלונות</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data?.failed_documents.map((d) => (
|
||||||
|
<DocRow key={d.id} doc={d} tone="danger" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-warn text-lg mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
תקועים (> 10 דק׳)
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{data?.stuck_documents.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</h2>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
) : data?.stuck_documents.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין מסמכים תקועים</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data?.stuck_documents.map((d) => (
|
||||||
|
<DocRow key={d.id} doc={d} tone="warn" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
web-ui/src/app/error.tsx
Normal file
57
web-ui/src/app/error.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Route-segment error boundary. Next 16 App Router convention: this file
|
||||||
|
* catches render-time errors thrown below the root layout. `reset` clears
|
||||||
|
* the error and re-renders the segment; we keep the user's nav in place
|
||||||
|
* (no AppShell here — the shell re-renders from the layout above us).
|
||||||
|
*/
|
||||||
|
export default function ErrorPage({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("Route error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||||
|
<div className="max-w-xl mx-auto text-center space-y-5 py-16">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-danger mx-auto" aria-hidden="true" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-navy">משהו השתבש</h1>
|
||||||
|
<p className="text-ink-muted leading-relaxed">
|
||||||
|
נכשלה טעינת המסך. זה עשוי להיות כשל זמני ברשת או באחד מה-endpoints
|
||||||
|
של FastAPI.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="text-[0.72rem] text-ink-light tabular-nums">
|
||||||
|
error id: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error.message && (
|
||||||
|
<code className="text-[0.78rem] text-ink-soft bg-rule-soft/60 rounded px-3 py-1 inline-block mt-2">
|
||||||
|
{error.message}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Button onClick={reset} className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
|
נסה שוב
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/">חזרה לבית</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 |
329
web-ui/src/app/feedback/page.tsx
Normal file
329
web-ui/src/app/feedback/page.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
useFeedbackList,
|
||||||
|
useCreateFeedback,
|
||||||
|
useResolveFeedback,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
BLOCK_LABELS,
|
||||||
|
type FeedbackCategory,
|
||||||
|
} from "@/lib/api/feedback";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||||
|
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
|
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
||||||
|
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
factual_error: "bg-red-100 text-red-800 border-red-200",
|
||||||
|
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
other: "bg-gray-100 text-gray-800 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedbackPage() {
|
||||||
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: feedbacks, isLoading } = useFeedbackList({
|
||||||
|
category: filterCategory || undefined,
|
||||||
|
unresolved_only: !showResolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveMutation = useResolveFeedback();
|
||||||
|
|
||||||
|
function handleResolve(id: string) {
|
||||||
|
resolveMutation.mutate(
|
||||||
|
{ feedbackId: id, applied_to: [] },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||||
|
onError: () => toast.error("שגיאה בעדכון"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted 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">
|
||||||
|
תיעוד הערות דפנה על טיוטות החלטות. כל הערה מנותחת ומשפיעה על שיפור
|
||||||
|
כתיבת ההחלטות העתידיות.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<NewFeedbackDialog />
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
className="rounded-md border border-rule bg-surface px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">כל הקטגוריות</option>
|
||||||
|
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink-muted cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showResolved}
|
||||||
|
onChange={(e) => setShowResolved(e.target.checked)}
|
||||||
|
className="rounded border-rule"
|
||||||
|
/>
|
||||||
|
הצג גם מטופלות
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{feedbacks && (
|
||||||
|
<span className="text-sm text-ink-muted me-auto">
|
||||||
|
{feedbacks.length} הערות
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||||
|
טוען...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : !feedbacks?.length ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||||
|
אין הערות{!showResolved ? " פתוחות" : ""}
|
||||||
|
{filterCategory ? ` בקטגוריה ${CATEGORY_LABELS[filterCategory as FeedbackCategory]}` : ""}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{feedbacks.map((fb) => (
|
||||||
|
<Card
|
||||||
|
key={fb.id}
|
||||||
|
className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="border-b pb-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
className={`text-[0.7rem] border ${CATEGORY_COLORS[fb.category]}`}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[fb.category]}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
||||||
|
</Badge>
|
||||||
|
{fb.case_number && (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${fb.case_number}`}
|
||||||
|
className="text-[0.7rem] text-gold-deep hover:underline"
|
||||||
|
>
|
||||||
|
תיק {fb.case_number}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{fb.resolved && (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 text-[0.7rem] border border-emerald-200">
|
||||||
|
טופל
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.7rem] text-ink-muted me-auto">
|
||||||
|
{fb.created_at
|
||||||
|
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 py-4 space-y-3">
|
||||||
|
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||||
|
|
||||||
|
{fb.lesson_extracted && (
|
||||||
|
<div className="bg-gold/5 border border-gold/20 rounded-md px-4 py-3">
|
||||||
|
<p className="text-[0.7rem] font-semibold text-gold-deep mb-1">
|
||||||
|
לקח שהופק:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-ink-muted leading-relaxed">
|
||||||
|
{fb.lesson_extracted}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fb.resolved && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleResolve(fb.id)}
|
||||||
|
disabled={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
סמן כמטופל
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── New feedback dialog ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function NewFeedbackDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const createMutation = useCreateFeedback();
|
||||||
|
|
||||||
|
const [caseNumber, setCaseNumber] = useState("");
|
||||||
|
const [blockId, setBlockId] = useState("block-yod");
|
||||||
|
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
||||||
|
const [feedbackText, setFeedbackText] = useState("");
|
||||||
|
const [lesson, setLesson] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!feedbackText.trim()) return;
|
||||||
|
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
case_number: caseNumber || undefined,
|
||||||
|
block_id: blockId,
|
||||||
|
feedback_text: feedbackText,
|
||||||
|
category,
|
||||||
|
lesson_extracted: lesson || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("ההערה נרשמה בהצלחה");
|
||||||
|
setOpen(false);
|
||||||
|
setCaseNumber("");
|
||||||
|
setFeedbackText("");
|
||||||
|
setLesson("");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה ברישום ההערה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>+ הערה חדשה</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>רישום הערת יו״ר</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-case">מספר תיק (אופציונלי)</Label>
|
||||||
|
<Input
|
||||||
|
id="fb-case"
|
||||||
|
value={caseNumber}
|
||||||
|
onChange={(e) => setCaseNumber(e.target.value)}
|
||||||
|
placeholder="1130-25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-block">בלוק</Label>
|
||||||
|
<select
|
||||||
|
id="fb-block"
|
||||||
|
value={blockId}
|
||||||
|
onChange={(e) => setBlockId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-category">קטגוריה</Label>
|
||||||
|
<select
|
||||||
|
id="fb-category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
|
||||||
|
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-text">ההערה</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fb-text"
|
||||||
|
value={feedbackText}
|
||||||
|
onChange={(e) => setFeedbackText(e.target.value)}
|
||||||
|
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fb-lesson"
|
||||||
|
value={lesson}
|
||||||
|
onChange={(e) => setLesson(e.target.value)}
|
||||||
|
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
web-ui/src/app/global-error.tsx
Normal file
60
web-ui/src/app/global-error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Root-level error boundary. Renders only when the root layout itself
|
||||||
|
* crashes, so it must include its own <html> / <body>. No AppShell here —
|
||||||
|
* the Providers that wrap AppShell are also above the crash boundary and
|
||||||
|
* may themselves be the thing that failed.
|
||||||
|
*/
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="he" dir="rtl">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
background: "#f5f1e8",
|
||||||
|
color: "#1a1a2e",
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 520, textAlign: "center" }}>
|
||||||
|
<h1 style={{ color: "#0f172a", fontSize: "2rem", marginBottom: 8 }}>
|
||||||
|
שגיאה חמורה
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#6b7280", lineHeight: 1.6, marginBottom: 16 }}>
|
||||||
|
האפליקציה נכשלה לטעון. נסה לרענן את הדף.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p style={{ fontSize: 12, color: "#9ca3af", marginBottom: 16 }}>
|
||||||
|
error id: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#fbf8f0",
|
||||||
|
border: 0,
|
||||||
|
padding: "0.6rem 1.25rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
נסה שוב
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
web-ui/src/app/layout.tsx
Normal file
36
web-ui/src/app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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: {
|
||||||
|
default: "עוזר משפטי — ניהול תיקים",
|
||||||
|
template: "%s · עוזר משפטי",
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
web-ui/src/app/not-found.tsx
Normal file
24
web-ui/src/app/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="max-w-xl mx-auto text-center py-16 space-y-5">
|
||||||
|
<div className="font-display text-gold text-6xl leading-none" aria-hidden="true">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-navy">הדף לא נמצא</h1>
|
||||||
|
<p className="text-ink-muted leading-relaxed">
|
||||||
|
הכתובת שביקשת אינה קיימת או שהוזזה לדף אחר.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
|
<Link href="/">חזרה לבית</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
web-ui/src/app/skills/page.tsx
Normal file
128
web-ui/src/app/skills/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Plug, HardDrive, Database, FileText } from "lucide-react";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useSkills, type Skill } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
function formatSize(bytes: number | null) {
|
||||||
|
if (bytes == null) 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 statusBadge(s: Skill) {
|
||||||
|
if (s.not_in_db) {
|
||||||
|
return <Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">לא סונכרן</Badge>;
|
||||||
|
}
|
||||||
|
if (s.db_markdown_chars > 0 && s.disk_exists) {
|
||||||
|
return <Badge variant="outline" className="bg-success-bg text-success border-success/40">מסונכרן</Badge>;
|
||||||
|
}
|
||||||
|
if (s.db_markdown_chars > 0) {
|
||||||
|
return <Badge variant="outline" className="bg-info-bg text-info border-info/40">DB בלבד</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="outline">לא ידוע</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillCard({ skill }: { skill: Skill }) {
|
||||||
|
const fileCount = skill.file_inventory?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Plug className="w-4 h-4 text-gold-deep shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-navy font-semibold text-base mb-0 truncate">
|
||||||
|
{skill.name || skill.slug}
|
||||||
|
</h3>
|
||||||
|
<code className="text-[0.72rem] text-ink-muted tabular-nums">
|
||||||
|
{skill.slug}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{statusBadge(skill)}
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-3 gap-2 text-[0.72rem] text-ink-muted mt-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
<span className="tabular-nums">{fileCount}</span>
|
||||||
|
<span>קבצים</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Database className="w-3 h-3" />
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{(skill.db_markdown_chars / 1000).toFixed(1)}K
|
||||||
|
</span>
|
||||||
|
<span>תווים</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<HardDrive className="w-3 h-3" />
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatSize(skill.disk_skill_md_bytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{skill.updated_at && (
|
||||||
|
<p className="text-[0.7rem] text-ink-light mt-2">
|
||||||
|
עודכן: {new Date(skill.updated_at).toLocaleDateString("he-IL")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillsPage() {
|
||||||
|
const { data, isPending, error } = useSkills();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted 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">מיומנויות Paperclip</h1>
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||||
|
רשימת ה-skills המותקנים במערכת Paperclip ומצב הסנכרון שלהם בין ה-DB
|
||||||
|
לדיסק.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-6 text-center text-danger">
|
||||||
|
{error.message}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : isPending ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-32 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : data?.length === 0 ? (
|
||||||
|
<Card className="bg-surface border-rule">
|
||||||
|
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||||||
|
<div className="text-gold text-3xl mb-2" aria-hidden>❦</div>
|
||||||
|
אין skills מותקנים
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data?.map((s) => <SkillCard key={s.slug} skill={s} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web-ui/src/app/training/page.tsx
Normal file
56
web-ui/src/app/training/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 { StyleReportPanel } from "@/components/training/style-report-panel";
|
||||||
|
import { CorpusPanel } from "@/components/training/corpus-panel";
|
||||||
|
import { ComparePanel } from "@/components/training/compare-panel";
|
||||||
|
|
||||||
|
export default function TrainingPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted 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">
|
||||||
|
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
||||||
|
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<Tabs defaultValue="report" dir="rtl">
|
||||||
|
<TabsList className="bg-rule-soft/60">
|
||||||
|
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
|
||||||
|
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
|
||||||
|
<TabsTrigger value="compare">השוואה</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="report" className="mt-5">
|
||||||
|
<StyleReportPanel />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="corpus" className="mt-5">
|
||||||
|
<CorpusPanel />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="compare" className="mt-5">
|
||||||
|
<ComparePanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
web-ui/src/components/app-shell.tsx
Normal file
101
web-ui/src/components/app-shell.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
*
|
||||||
|
* Nav items pick up an `aria-current="page"` and a gold underline when
|
||||||
|
* the current route matches, so screen readers announce the active
|
||||||
|
* section and sighted users can see where they are.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ href: "/", label: "בית" },
|
||||||
|
{ href: "/cases/new", label: "תיק חדש" },
|
||||||
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
|
{ href: "/feedback", label: "הערות יו״ר" },
|
||||||
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
|
{ href: "/diagnostics", label: "אבחון" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(pathname: string, href: string): boolean {
|
||||||
|
if (href === "/") return pathname === "/";
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
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"
|
||||||
|
aria-label="ניווט ראשי"
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(pathname, item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`
|
||||||
|
relative px-3 py-1.5 rounded text-sm transition-colors
|
||||||
|
${
|
||||||
|
active
|
||||||
|
? "text-parchment font-semibold bg-navy-soft/80"
|
||||||
|
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main
|
||||||
|
id="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
web-ui/src/components/cases/case-header.tsx
Normal file
79
web-ui/src/components/cases/case-header.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import {
|
||||||
|
PRACTICE_AREA_LABELS,
|
||||||
|
APPEAL_SUBTYPE_LABELS,
|
||||||
|
} from "@/lib/practice-area";
|
||||||
|
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 flex-wrap">
|
||||||
|
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||||
|
ערר {data?.case_number ?? "—"}
|
||||||
|
</span>
|
||||||
|
{data?.status && <StatusBadge status={data.status} />}
|
||||||
|
{data?.practice_area && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-gold-wash text-gold-deep border-gold/40"
|
||||||
|
>
|
||||||
|
{PRACTICE_AREA_LABELS[data.practice_area]}
|
||||||
|
{data.appeal_subtype && data.appeal_subtype !== "unknown" && (
|
||||||
|
<> · {APPEAL_SUBTYPE_LABELS[data.appeal_subtype]}</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
web-ui/src/components/cases/cases-table.tsx
Normal file
201
web-ui/src/components/cases/cases-table.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"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 { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
|
||||||
|
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: "appeal_subtype",
|
||||||
|
header: "תחום",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const s = row.original.appeal_subtype;
|
||||||
|
if (!s || s === "unknown") return <span className="text-ink-muted">—</span>;
|
||||||
|
return <span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
web-ui/src/components/cases/documents-panel.tsx
Normal file
117
web-ui/src/components/cases/documents-panel.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||||
|
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
|
||||||
|
* and the `documents` schema in legal_mcp/services/db.py:
|
||||||
|
* id · case_id · doc_type · title · file_path · extraction_status ·
|
||||||
|
* page_count · created_at · practice_area · appeal_subtype
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
appeal: "כתב ערר",
|
||||||
|
response: "כתב תשובה",
|
||||||
|
protocol: "פרוטוקול",
|
||||||
|
decision: "החלטת ועדה מקומית",
|
||||||
|
plan: "תכנית",
|
||||||
|
reference: "חומר רקע",
|
||||||
|
auto: "—",
|
||||||
|
};
|
||||||
|
|
||||||
|
function doctypeLabel(t: string): string {
|
||||||
|
return DOC_TYPE_LABELS[t] ?? t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doctypeTone(t: string): string {
|
||||||
|
switch (t) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: "בהמתנה",
|
||||||
|
processing: "בעיבוד",
|
||||||
|
completed: "הושלם",
|
||||||
|
proofread: "הוגה",
|
||||||
|
failed: "נכשל",
|
||||||
|
error: "שגיאה",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("he-IL");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromPath(path: string): string {
|
||||||
|
const parts = path.split("/");
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
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="true">❦</div>
|
||||||
|
<p className="text-sm">אין מסמכים בתיק זה</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="max-h-[520px]" dir="rtl">
|
||||||
|
<ul className="divide-y divide-rule" dir="rtl">
|
||||||
|
{docs.map((doc) => {
|
||||||
|
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||||
|
const statusDone =
|
||||||
|
doc.extraction_status === "completed" ||
|
||||||
|
doc.extraction_status === "proofread";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={doc.id}
|
||||||
|
className="py-3 flex items-start gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
|
||||||
|
>
|
||||||
|
{/* Title + meta — flex-1 keeps it glued to the start (right in RTL) */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5 text-right">
|
||||||
|
<div className="text-ink font-medium truncate" title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3 flex-wrap">
|
||||||
|
{doc.page_count != null && (
|
||||||
|
<span className="tabular-nums">{doc.page_count} עמ׳</span>
|
||||||
|
)}
|
||||||
|
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||||
|
{!statusDone && doc.extraction_status && (
|
||||||
|
<span className="text-warn">
|
||||||
|
{STATUS_LABELS[doc.extraction_status] ?? doc.extraction_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Type badge — ms-auto forces it to the inline-end (= left in RTL) */}
|
||||||
|
{doc.doc_type && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ms-auto ${doctypeTone(doc.doc_type)}`}
|
||||||
|
>
|
||||||
|
{doctypeLabel(doc.doc_type)}
|
||||||
|
</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>;
|
||||||
|
}
|
||||||
229
web-ui/src/components/compose/precedent-attacher.tsx
Normal file
229
web-ui/src/components/compose/precedent-attacher.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Paperclip } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Popover, PopoverContent, PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
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 {
|
||||||
|
useCreatePrecedent,
|
||||||
|
usePrecedentLibrarySearch,
|
||||||
|
uploadPrecedentPdf,
|
||||||
|
} from "@/lib/api/precedents";
|
||||||
|
import type { PracticeArea } from "@/lib/practice-area";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inline form for adding a new precedent. Opens in a Popover adjacent
|
||||||
|
* to the trigger button so the user can see the surrounding context
|
||||||
|
* (the threshold_claim body, the chair editor) while they fill it in.
|
||||||
|
*
|
||||||
|
* The citation field has cross-case typeahead: once the user types
|
||||||
|
* 2+ characters, we hit /api/precedents/search and show distinct
|
||||||
|
* matches. Picking one prefills quote + chair_note but keeps them
|
||||||
|
* editable — the new row is a copy, so a customized quote for this
|
||||||
|
* case doesn't affect the library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function PrecedentAttacher({
|
||||||
|
caseNumber,
|
||||||
|
sectionId,
|
||||||
|
practiceArea,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
sectionId: string | null;
|
||||||
|
practiceArea: PracticeArea | null | undefined;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [citation, setCitation] = useState("");
|
||||||
|
const [quote, setQuote] = useState("");
|
||||||
|
const [chairNote, setChairNote] = useState("");
|
||||||
|
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [picked, setPicked] = useState(false);
|
||||||
|
|
||||||
|
const create = useCreatePrecedent(caseNumber);
|
||||||
|
const library = usePrecedentLibrarySearch(
|
||||||
|
citation,
|
||||||
|
practiceArea,
|
||||||
|
/* pause typeahead once the user has picked one and we're just editing */
|
||||||
|
!picked,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setCitation("");
|
||||||
|
setQuote("");
|
||||||
|
setChairNote("");
|
||||||
|
setPdfFile(null);
|
||||||
|
setPicked(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!quote.trim() || !citation.trim()) {
|
||||||
|
toast.error("ציטוט ומראה-מקום חובה");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let pdfDocumentId: string | undefined;
|
||||||
|
if (pdfFile) {
|
||||||
|
const res = await uploadPrecedentPdf(caseNumber, pdfFile);
|
||||||
|
pdfDocumentId = res.document_id;
|
||||||
|
}
|
||||||
|
await create.mutateAsync({
|
||||||
|
quote: quote.trim(),
|
||||||
|
citation: citation.trim(),
|
||||||
|
chair_note: chairNote.trim(),
|
||||||
|
section_id: sectionId ?? undefined,
|
||||||
|
pdf_document_id: pdfDocumentId,
|
||||||
|
});
|
||||||
|
toast.success("נוספה פסיקה");
|
||||||
|
reset();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "שגיאה בשמירה");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-dashed border-gold/50 text-gold-deep hover:bg-gold-wash"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 me-1" aria-hidden="true" />
|
||||||
|
הוסף פסיקה תומכת
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[520px] max-w-[90vw] p-5"
|
||||||
|
align="start"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3" dir="rtl">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="prec-citation" className="text-navy">
|
||||||
|
מראה מקום <span className="text-danger">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="prec-citation"
|
||||||
|
value={citation}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCitation(e.target.value);
|
||||||
|
setPicked(false);
|
||||||
|
}}
|
||||||
|
placeholder="ערר (ירושלים) 1126-08-25 ... נ' ... (נבו 9.3.2026)"
|
||||||
|
autoComplete="off"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{!picked && library.data && library.data.length > 0 && citation.length >= 2 && (
|
||||||
|
<ul
|
||||||
|
className="
|
||||||
|
mt-1 rounded border border-rule bg-surface shadow-sm
|
||||||
|
max-h-44 overflow-y-auto divide-y divide-rule
|
||||||
|
"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{library.data.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCitation(m.citation);
|
||||||
|
setQuote(m.quote);
|
||||||
|
setChairNote(m.chair_note || "");
|
||||||
|
setPicked(true);
|
||||||
|
}}
|
||||||
|
className="w-full text-right px-3 py-2 hover:bg-gold-wash/60 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-[0.78rem] text-gold-deep font-semibold truncate">
|
||||||
|
{m.citation}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted truncate">
|
||||||
|
{m.quote}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="prec-quote" className="text-navy">
|
||||||
|
ציטוט <span className="text-danger">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prec-quote"
|
||||||
|
value={quote}
|
||||||
|
onChange={(e) => setQuote(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder="הטקסט המדויק שישולב בהחלטה"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="prec-note" className="text-navy">
|
||||||
|
הערה (אופציונלי)
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prec-note"
|
||||||
|
value={chairNote}
|
||||||
|
onChange={(e) => setChairNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="למה הציטוט הזה תומך בעמדה"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy flex items-center gap-2">
|
||||||
|
<Paperclip className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
צירוף קובץ המקור (אופציונלי, לארכיון)
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.docx,.doc"
|
||||||
|
onChange={(e) => setPdfFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="mt-1 w-full text-sm file:me-3 file:rounded file:border-0 file:bg-rule-soft file:px-3 file:py-1.5 file:text-navy file:text-sm hover:file:bg-rule"
|
||||||
|
/>
|
||||||
|
{pdfFile && (
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mt-1">
|
||||||
|
{pdfFile.name} · {(pdfFile.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setOpen(false); reset(); }}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{submitting ? "שומר…" : "שמור פסיקה"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
web-ui/src/components/compose/precedent-card.tsx
Normal file
77
web-ui/src/components/compose/precedent-card.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2, FileText } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useDeletePrecedent, type CasePrecedent } from "@/lib/api/precedents";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Read-only display of a single attached precedent. Layout is:
|
||||||
|
*
|
||||||
|
* ┌───────────────────────────────────────────┐
|
||||||
|
* │ citation (gold semibold) [🗑] │
|
||||||
|
* │ ┌──┐ │
|
||||||
|
* │ │╎ │ "quote text…" │
|
||||||
|
* │ └──┘ │
|
||||||
|
* │ chair_note (muted) │
|
||||||
|
* │ 📄 קובץ מצורף │
|
||||||
|
* └───────────────────────────────────────────┘
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function PrecedentCard({
|
||||||
|
caseNumber,
|
||||||
|
precedent,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
precedent: CasePrecedent;
|
||||||
|
}) {
|
||||||
|
const del = useDeletePrecedent(caseNumber);
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
if (!window.confirm("להסיר פסיקה זו מהתיק?")) return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(precedent.id);
|
||||||
|
toast.success("הפסיקה הוסרה");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה בהסרה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<p className="flex-1 text-[0.82rem] text-gold-deep font-semibold leading-snug">
|
||||||
|
{precedent.citation}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={del.isPending}
|
||||||
|
aria-label="הסר פסיקה זו"
|
||||||
|
className="text-danger hover:text-danger hover:bg-danger-bg shrink-0 -mt-1 -me-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<blockquote className="border-e-2 border-gold pe-3 text-sm text-ink leading-relaxed whitespace-pre-line">
|
||||||
|
{precedent.quote}
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
{precedent.chair_note && (
|
||||||
|
<p className="text-[0.78rem] text-ink-muted italic">
|
||||||
|
{precedent.chair_note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{precedent.pdf_document_id && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[0.72rem] text-ink-muted">
|
||||||
|
<FileText className="w-3 h-3" aria-hidden="true" />
|
||||||
|
<span>קובץ מצורף</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
web-ui/src/components/compose/precedents-section.tsx
Normal file
50
web-ui/src/components/compose/precedents-section.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PrecedentCard } from "@/components/compose/precedent-card";
|
||||||
|
import { PrecedentAttacher } from "@/components/compose/precedent-attacher";
|
||||||
|
import type { CasePrecedent } from "@/lib/api/precedents";
|
||||||
|
import type { PracticeArea } from "@/lib/practice-area";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wrapper that renders the list of precedents for one scope — either
|
||||||
|
* case-level (sectionId=null) or a specific threshold_claim / issue.
|
||||||
|
* The parent page fetches useCasePrecedents(caseNumber) once and
|
||||||
|
* passes a pre-filtered slice down, so each section doesn't re-query.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function PrecedentsSection({
|
||||||
|
caseNumber,
|
||||||
|
sectionId,
|
||||||
|
precedents,
|
||||||
|
practiceArea,
|
||||||
|
emptyHelperText,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
sectionId: string | null;
|
||||||
|
precedents: CasePrecedent[];
|
||||||
|
practiceArea: PracticeArea | null | undefined;
|
||||||
|
emptyHelperText?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{precedents.length === 0 ? (
|
||||||
|
emptyHelperText && (
|
||||||
|
<p className="text-[0.78rem] text-ink-muted">{emptyHelperText}</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{precedents.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<PrecedentCard caseNumber={caseNumber} precedent={p} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<PrecedentAttacher
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
sectionId={sectionId}
|
||||||
|
practiceArea={practiceArea}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
web-ui/src/components/compose/subsection-card.tsx
Normal file
108
web-ui/src/components/compose/subsection-card.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { ChairEditor } from "@/components/compose/chair-editor";
|
||||||
|
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||||
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import type { ResearchSubsection } from "@/lib/api/research";
|
||||||
|
import type { CasePrecedent } from "@/lib/api/precedents";
|
||||||
|
import type { PracticeArea } from "@/lib/practice-area";
|
||||||
|
|
||||||
|
export function SubsectionCard({
|
||||||
|
caseNumber,
|
||||||
|
item,
|
||||||
|
defaultOpen = false,
|
||||||
|
precedents = [],
|
||||||
|
practiceArea,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
item: ResearchSubsection;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
precedents?: CasePrecedent[];
|
||||||
|
practiceArea?: PracticeArea | null;
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
<Markdown content={f.content} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<ChairEditor
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
sectionId={item.id}
|
||||||
|
initialValue={item.chair_position ?? ""}
|
||||||
|
/>
|
||||||
|
<div className="border-t border-rule pt-4 mt-2">
|
||||||
|
<h4 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-2">
|
||||||
|
פסיקה תומכת
|
||||||
|
</h4>
|
||||||
|
<PrecedentsSection
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
sectionId={item.id}
|
||||||
|
precedents={precedents}
|
||||||
|
practiceArea={practiceArea}
|
||||||
|
emptyHelperText="עדיין לא צורפה פסיקה לסעיף זה"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
web-ui/src/components/training/compare-panel.tsx
Normal file
189
web-ui/src/components/training/compare-panel.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
useCorpus, useCompare, type CompareSide, type PatternEntry,
|
||||||
|
} from "@/lib/api/training";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compare two decisions from the style corpus side-by-side. Uses the
|
||||||
|
* training/compare endpoint which already does the heavy lifting (pattern
|
||||||
|
* extraction, section stats, shared/unique pattern sets). Our job is
|
||||||
|
* layout: two columns of metadata + section bars, plus a third "shared"
|
||||||
|
* section listing patterns that appear in both.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function SideColumn({ side }: { side: CompareSide }) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 space-y-3">
|
||||||
|
<header>
|
||||||
|
<h3 className="text-navy text-lg mb-0 tabular-nums">
|
||||||
|
{side.decision_number || "—"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[0.78rem] text-ink-muted tabular-nums">
|
||||||
|
{side.decision_date || "—"} · {(side.chars / 1000).toFixed(1)}K תווים
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
{side.subjects.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{side.subjects.map((s) => (
|
||||||
|
<Badge
|
||||||
|
key={s}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{side.sections.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1.5">
|
||||||
|
חלוקה לחלקים
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 text-[0.78rem]">
|
||||||
|
{side.sections.map((sec) => (
|
||||||
|
<li key={sec.type} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-ink-soft truncate">{sec.type}</span>
|
||||||
|
<span className="text-ink-muted tabular-nums shrink-0">
|
||||||
|
{(sec.chars / 1000).toFixed(1)}K
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[0.78rem] text-ink-muted">
|
||||||
|
דפוסי סגנון שנמצאו: <span className="text-navy font-semibold tabular-nums">{side.patterns_count}</span>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatternList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: PatternEntry[];
|
||||||
|
tone: "shared" | "a" | "b";
|
||||||
|
}) {
|
||||||
|
const toneClass =
|
||||||
|
tone === "shared"
|
||||||
|
? "bg-success-bg border-success/40"
|
||||||
|
: tone === "a"
|
||||||
|
? "bg-info-bg border-info/40"
|
||||||
|
: "bg-gold-wash border-gold/40";
|
||||||
|
const toneHeading =
|
||||||
|
tone === "shared"
|
||||||
|
? "text-success"
|
||||||
|
: tone === "a"
|
||||||
|
? "text-info"
|
||||||
|
: "text-gold-deep";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`${toneClass} shadow-sm`}>
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<h4 className={`${toneHeading} text-sm font-semibold mb-3 flex items-center gap-2`}>
|
||||||
|
{title}
|
||||||
|
<span className="text-[0.7rem] tabular-nums opacity-70">{items.length}</span>
|
||||||
|
</h4>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-[0.78rem]">—</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5 text-[0.78rem] max-h-60 overflow-y-auto">
|
||||||
|
{items.slice(0, 20).map((p) => (
|
||||||
|
<li key={p.id} className="text-ink leading-relaxed">
|
||||||
|
{p.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparePanel() {
|
||||||
|
const { data: corpus, isPending } = useCorpus();
|
||||||
|
const [a, setA] = useState<string | null>(null);
|
||||||
|
const [b, setB] = useState<string | null>(null);
|
||||||
|
const cmp = useCompare(a, b);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<h3 className="text-navy text-base mb-3">בחר שתי החלטות להשוואה</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{(["a", "b"] as const).map((slot) => (
|
||||||
|
<div key={slot}>
|
||||||
|
<label className="block text-[0.78rem] font-medium text-navy mb-1">
|
||||||
|
{slot === "a" ? "החלטה א" : "החלטה ב"}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
|
value={(slot === "a" ? a : b) ?? ""}
|
||||||
|
onValueChange={(v) => (slot === "a" ? setA(v) : setB(v))}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={isPending ? "טוען…" : "בחר החלטה"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[300px]">
|
||||||
|
{corpus?.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.decision_number || "—"}
|
||||||
|
{c.decision_date ? ` · ${c.decision_date}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{a && b && a === b && (
|
||||||
|
<p className="text-ink-muted text-sm text-center">בחר שתי החלטות שונות</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cmp.error && (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-5 text-danger text-center">
|
||||||
|
{cmp.error.message}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cmp.isPending && a && b && a !== b && (
|
||||||
|
<Skeleton className="h-60 w-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cmp.data && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<SideColumn side={cmp.data.a} />
|
||||||
|
<SideColumn side={cmp.data.b} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<PatternList title="דפוסים משותפים" items={cmp.data.shared} tone="shared" />
|
||||||
|
<PatternList title="רק בהחלטה א" items={cmp.data.only_a} tone="a" />
|
||||||
|
<PatternList title="רק בהחלטה ב" items={cmp.data.only_b} tone="b" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
web-ui/src/components/training/corpus-panel.tsx
Normal file
140
web-ui/src/components/training/corpus-panel.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Corpus tab: table of all decisions currently in the style corpus, with a
|
||||||
|
* single destructive action (remove from corpus). Uses browser confirm() for
|
||||||
|
* the confirmation — a full shadcn AlertDialog would be overkill for an
|
||||||
|
* admin-only destructive action with a server-side safety net.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatChars(n: number) {
|
||||||
|
return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("he-IL");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ item }: { item: CorpusDecision }) {
|
||||||
|
const del = useDeleteCorpusEntry();
|
||||||
|
const onDelete = async () => {
|
||||||
|
if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(item.id);
|
||||||
|
toast.success("נמחק מהקורפוס");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "שגיאה במחיקה");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow className="border-rule hover:bg-gold-wash/30">
|
||||||
|
<TableCell className="font-semibold text-navy tabular-nums">
|
||||||
|
{item.decision_number || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted tabular-nums">
|
||||||
|
{formatDate(item.decision_date)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.subject_categories.length === 0 ? (
|
||||||
|
<span className="text-ink-light">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.subject_categories.map((s) => (
|
||||||
|
<Badge
|
||||||
|
key={s}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-soft tabular-nums">
|
||||||
|
{formatChars(item.chars)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-ink-muted tabular-nums text-[0.78rem]">
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={del.isPending}
|
||||||
|
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
|
||||||
|
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorpusPanel() {
|
||||||
|
const { data, isPending, error } = useCorpus();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-rule-soft/60">
|
||||||
|
<TableRow className="border-rule">
|
||||||
|
<TableHead className="text-navy text-right">מס׳ החלטה</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">נושאים</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">תווים</TableHead>
|
||||||
|
<TableHead className="text-navy text-right">נוסף בתאריך</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isPending ? (
|
||||||
|
[...Array(4)].map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-rule">
|
||||||
|
{[...Array(6)].map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : data?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-ink-muted py-12">
|
||||||
|
הקורפוס ריק
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.map((item) => <Row key={item.id} item={item} />)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
web-ui/src/components/training/style-report-panel.tsx
Normal file
195
web-ui/src/components/training/style-report-panel.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SubjectDonut } from "@/components/training/subject-donut";
|
||||||
|
import { useStyleReport } from "@/lib/api/training";
|
||||||
|
|
||||||
|
function KPICard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
caption,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
caption?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 flex flex-col gap-0.5">
|
||||||
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[2rem] font-black leading-none text-navy">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{caption && (
|
||||||
|
<span className="text-[0.78rem] text-ink-muted mt-1">{caption}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StyleReportPanel() {
|
||||||
|
const { data, isPending, error } = useStyleReport();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-5 text-center text-danger">
|
||||||
|
{error.message}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = data.corpus;
|
||||||
|
const dateRange =
|
||||||
|
c.date_range[0] && c.date_range[1]
|
||||||
|
? `${c.date_range[0]} – ${c.date_range[1]}`
|
||||||
|
: undefined;
|
||||||
|
const total = c.decision_count;
|
||||||
|
const totalSubjects = c.subject_distribution.reduce((a, b) => a + b.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Headline */}
|
||||||
|
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||||
|
<CardContent className="px-6 py-4">
|
||||||
|
<p className="font-display text-gold-deep text-lg font-semibold leading-snug">
|
||||||
|
★ {c.headline}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KPICard label="החלטות בקורפוס" value={String(c.decision_count)} />
|
||||||
|
<KPICard
|
||||||
|
label="סך תווים"
|
||||||
|
value={`${(c.total_chars / 1000).toFixed(0)}K`}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="ממוצע להחלטה"
|
||||||
|
value={`${(c.avg_chars / 1000).toFixed(1)}K`}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="דפוסי סגנון"
|
||||||
|
value={String(data.signature_phrases.items.length)}
|
||||||
|
caption={`מתוך ${data.contribution.total_patterns} שחולצו`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subjects + anatomy */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-navy text-lg mb-4">פיזור נושאים</h3>
|
||||||
|
<SubjectDonut
|
||||||
|
segments={c.subject_distribution}
|
||||||
|
total={totalSubjects}
|
||||||
|
/>
|
||||||
|
{dateRange && (
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mt-4">
|
||||||
|
טווח תאריכים: {dateRange}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-navy text-lg mb-1">אנטומיה של החלטה ממוצעת</h3>
|
||||||
|
{data.anatomy.headline && (
|
||||||
|
<p className="text-[0.78rem] text-gold-deep mb-4">
|
||||||
|
{data.anatomy.headline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.anatomy.sections.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין נתונים על מבנה</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{data.anatomy.sections.map((s) => {
|
||||||
|
const pct = Math.round(s.pct * 100);
|
||||||
|
return (
|
||||||
|
<li key={s.type} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-[0.78rem]">
|
||||||
|
<span className="text-ink-soft font-medium">
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted tabular-nums">
|
||||||
|
{pct}% · {s.avg_chars.toLocaleString()} תווים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded bg-rule-soft overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-l from-gold to-gold-deep"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signature phrases */}
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-navy text-lg mb-1">ביטויי חתימה</h3>
|
||||||
|
{data.signature_phrases.headline && (
|
||||||
|
<p className="text-[0.78rem] text-gold-deep mb-4">
|
||||||
|
{data.signature_phrases.headline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.signature_phrases.items.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין ביטויים שחולצו עדיין</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{data.signature_phrases.items.slice(0, 12).map((p, i) => (
|
||||||
|
<li
|
||||||
|
key={`${p.type}-${i}`}
|
||||||
|
className="flex items-start gap-3 rounded border border-rule bg-parchment/40 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted tabular-nums shrink-0 mt-0.5">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-ink leading-relaxed text-sm">{p.text}</p>
|
||||||
|
{p.context && (
|
||||||
|
<p className="text-[0.7rem] text-ink-muted mt-0.5">
|
||||||
|
{p.context}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="
|
||||||
|
shrink-0 text-[0.72rem] rounded-full
|
||||||
|
bg-gold-wash text-gold-deep border border-gold/40
|
||||||
|
px-2 py-0.5 tabular-nums
|
||||||
|
"
|
||||||
|
>
|
||||||
|
×{p.frequency}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
web-ui/src/components/training/subject-donut.tsx
Normal file
72
web-ui/src/components/training/subject-donut.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Corpus subject-distribution donut.
|
||||||
|
*
|
||||||
|
* Pure CSS conic-gradient — same recipe as the cases StatusDonut, but
|
||||||
|
* uses a palette-of-gold instead of a status-tone palette. Ported from
|
||||||
|
* legal-ai/web/static/index.html `renderHero`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DONUT_COLORS = [
|
||||||
|
"var(--color-navy)",
|
||||||
|
"var(--color-gold)",
|
||||||
|
"var(--color-info)",
|
||||||
|
"var(--color-warn)",
|
||||||
|
"var(--color-success)",
|
||||||
|
"var(--color-ink-muted)",
|
||||||
|
"var(--color-gold-deep)",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SubjectDonut({
|
||||||
|
segments,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
segments: Array<{ label: string; count: number }>;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
let pct = 0;
|
||||||
|
const parts = segments.map((s, i) => {
|
||||||
|
const start = total === 0 ? 0 : (pct / total) * 360;
|
||||||
|
pct += s.count;
|
||||||
|
const end = total === 0 ? 360 : (pct / total) * 360;
|
||||||
|
return { ...s, start, end, color: DONUT_COLORS[i % DONUT_COLORS.length] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const background =
|
||||||
|
total === 0
|
||||||
|
? "conic-gradient(var(--color-rule-soft) 0deg 360deg)"
|
||||||
|
: `conic-gradient(${parts
|
||||||
|
.map((p) => `${p.color} ${p.start}deg ${p.end}deg`)
|
||||||
|
.join(", ")})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div
|
||||||
|
className="relative w-[140px] h-[140px] rounded-full shadow-sm shrink-0"
|
||||||
|
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 min-w-0">
|
||||||
|
{parts.map((p) => (
|
||||||
|
<li key={p.label} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ background: p.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-ink-soft truncate">{p.label}</span>
|
||||||
|
<span className="text-ink-muted tabular-nums ms-1">{p.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 }
|
||||||
116
web-ui/src/components/ui/markdown.tsx
Normal file
116
web-ui/src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tiny markdown renderer for Hebrew prose blocks — paragraphs, lists,
|
||||||
|
* emphasis, and GFM tables (the main reason this exists). The parsed
|
||||||
|
* research_md fields and the conclusions field both contain tables
|
||||||
|
* like "ציר דיוני" that we want to render as real <table>s, RTL, with
|
||||||
|
* auto-sized columns that line up row-to-row.
|
||||||
|
*
|
||||||
|
* Table styling uses `table-auto` + `whitespace-nowrap` on header cells
|
||||||
|
* so the column widths are dictated by the longest cell in that column,
|
||||||
|
* and every row's borders align exactly underneath each other. The
|
||||||
|
* overflow-x-auto wrapper catches extremely wide tables on narrow
|
||||||
|
* viewports without letting the parent card grow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function Markdown({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="prose-md text-sm text-ink-soft leading-relaxed" dir="rtl">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
p: ({ node: _n, ...props }) => (
|
||||||
|
<p className="mb-2 last:mb-0 text-justify" {...props} />
|
||||||
|
),
|
||||||
|
strong: ({ node: _n, ...props }) => (
|
||||||
|
<strong className="text-navy font-semibold" {...props} />
|
||||||
|
),
|
||||||
|
em: ({ node: _n, ...props }) => (
|
||||||
|
<em className="text-ink" {...props} />
|
||||||
|
),
|
||||||
|
a: ({ node: _n, ...props }) => (
|
||||||
|
<a
|
||||||
|
className="text-gold-deep hover:text-gold underline underline-offset-2"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ul: ({ node: _n, ...props }) => (
|
||||||
|
<ul className="list-disc ps-5 mb-2 space-y-1" {...props} />
|
||||||
|
),
|
||||||
|
ol: ({ node: _n, ...props }) => (
|
||||||
|
<ol className="list-decimal ps-5 mb-2 space-y-1" {...props} />
|
||||||
|
),
|
||||||
|
li: ({ node: _n, ...props }) => (
|
||||||
|
<li className="text-ink" {...props} />
|
||||||
|
),
|
||||||
|
h1: ({ node: _n, ...props }) => (
|
||||||
|
<h3 className="text-navy text-base font-semibold mt-3 mb-1" {...props} />
|
||||||
|
),
|
||||||
|
h2: ({ node: _n, ...props }) => (
|
||||||
|
<h4 className="text-navy text-sm font-semibold mt-3 mb-1" {...props} />
|
||||||
|
),
|
||||||
|
h3: ({ node: _n, ...props }) => (
|
||||||
|
<h5 className="text-navy text-sm font-semibold mt-2 mb-1" {...props} />
|
||||||
|
),
|
||||||
|
blockquote: ({ node: _n, ...props }) => (
|
||||||
|
<blockquote
|
||||||
|
className="border-e-2 border-gold-soft pe-3 text-ink italic my-2"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code: ({ node: _n, ...props }) => (
|
||||||
|
<code
|
||||||
|
className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem] text-ink"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
/* ── Tables ─────────────────────────────────────────────────
|
||||||
|
Wrapped in an overflow-x-auto so very wide tables don't push
|
||||||
|
the parent card out of its track. table-auto lets the browser
|
||||||
|
size columns by their longest cell (that's what keeps borders
|
||||||
|
aligned row-to-row) and whitespace-nowrap on the headers
|
||||||
|
ensures the header row sets column widths instead of
|
||||||
|
breaking mid-word. */
|
||||||
|
table: ({ node: _n, ...props }) => (
|
||||||
|
<div className="my-3 -mx-1 overflow-x-auto">
|
||||||
|
<table
|
||||||
|
className="w-full table-auto border-collapse border border-rule text-sm text-right"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ node: _n, ...props }) => (
|
||||||
|
<thead className="bg-rule-soft/70" {...props} />
|
||||||
|
),
|
||||||
|
tbody: ({ node: _n, ...props }) => <tbody {...props} />,
|
||||||
|
tr: ({ node: _n, ...props }) => (
|
||||||
|
<tr className="border-b border-rule last:border-b-0" {...props} />
|
||||||
|
),
|
||||||
|
th: ({ node: _n, ...props }) => (
|
||||||
|
<th
|
||||||
|
className="border border-rule px-3 py-2 text-right text-navy font-semibold whitespace-nowrap align-top"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
td: ({ node: _n, ...props }) => (
|
||||||
|
<td
|
||||||
|
className="border border-rule px-3 py-2 text-right text-ink align-top"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
hr: ({ node: _n, ...props }) => (
|
||||||
|
<hr className="my-3 border-rule" {...props} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
web-ui/src/components/ui/popover.tsx
Normal file
89
web-ui/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden 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}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-heading font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
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 }
|
||||||
344
web-ui/src/components/wizard/case-wizard.tsx
Normal file
344
web-ui/src/components/wizard/case-wizard.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, 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, expectedOutcomes,
|
||||||
|
type CaseCreateInput,
|
||||||
|
} from "@/lib/schemas/case";
|
||||||
|
import {
|
||||||
|
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
||||||
|
type AppealSubtype,
|
||||||
|
} from "@/lib/practice-area";
|
||||||
|
|
||||||
|
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", "practice_area", "appeal_subtype"],
|
||||||
|
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: "",
|
||||||
|
hearing_date: "",
|
||||||
|
notes: "",
|
||||||
|
expected_outcome: "",
|
||||||
|
practice_area: "appeals_committee",
|
||||||
|
appeal_subtype: "unknown",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Auto-fill appeal_subtype from the case number as the user types, but
|
||||||
|
* stop the moment they manually pick a value from the dropdown. Mirrors
|
||||||
|
* the wireSubtypeAutofill() behaviour of the vanilla UI
|
||||||
|
* (legal-ai/web/static/index.html around line 2770).
|
||||||
|
*/
|
||||||
|
const userTouchedSubtype = useRef(false);
|
||||||
|
const caseNumber = form.watch("case_number");
|
||||||
|
const practiceArea = form.watch("practice_area");
|
||||||
|
useEffect(() => {
|
||||||
|
if (userTouchedSubtype.current) return;
|
||||||
|
const derived = deriveSubtype(caseNumber, practiceArea);
|
||||||
|
if (derived !== form.getValues("appeal_subtype")) {
|
||||||
|
form.setValue("appeal_subtype", derived, { shouldValidate: false });
|
||||||
|
}
|
||||||
|
}, [caseNumber, practiceArea, form]);
|
||||||
|
|
||||||
|
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="1033-25 או 1000-04-26"
|
||||||
|
{...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 className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy">תחום משפטי</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="practice_area"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRACTICE_AREAS.map((p) => (
|
||||||
|
<SelectItem
|
||||||
|
key={p.value}
|
||||||
|
value={p.value}
|
||||||
|
disabled={!p.enabled}
|
||||||
|
>
|
||||||
|
{p.label}{!p.enabled && " (בקרוב)"}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-navy">סוג ערר</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="appeal_subtype"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
userTouchedSubtype.current = true;
|
||||||
|
field.onChange(v as AppealSubtype);
|
||||||
|
}}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{APPEAL_SUBTYPES.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-[0.7rem] text-ink-muted mt-1">
|
||||||
|
מזוהה אוטומטית ממספר התיק
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
web-ui/src/components/wizard/parties-field.tsx
Normal file
95
web-ui/src/components/wizard/parties-field.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"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}
|
||||||
|
aria-label={`הוסף ${label}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
web-ui/src/lib/api/cases.ts
Normal file
153
web-ui/src/lib/api/cases.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||||
|
|
||||||
|
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;
|
||||||
|
/* Multi-tenant axis — populated by backfill + server-side derive */
|
||||||
|
practice_area?: PracticeArea;
|
||||||
|
appeal_subtype?: AppealSubtype;
|
||||||
|
/* Present when loaded with detail=true */
|
||||||
|
document_count?: number;
|
||||||
|
processing_count?: number;
|
||||||
|
committee_type?: string | null;
|
||||||
|
hearing_date?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseDocument = {
|
||||||
|
id: string;
|
||||||
|
case_id: string;
|
||||||
|
doc_type: string;
|
||||||
|
title: string;
|
||||||
|
file_path: string;
|
||||||
|
page_count: number | null;
|
||||||
|
extraction_status: string;
|
||||||
|
created_at: string;
|
||||||
|
practice_area?: PracticeArea;
|
||||||
|
appeal_subtype?: AppealSubtype;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseDetail = Case & {
|
||||||
|
documents?: CaseDocument[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
112
web-ui/src/lib/api/feedback.ts
Normal file
112
web-ui/src/lib/api/feedback.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Chair feedback hooks — recording and managing Dafna's feedback on drafts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type FeedbackCategory =
|
||||||
|
| "missing_content"
|
||||||
|
| "wrong_tone"
|
||||||
|
| "wrong_structure"
|
||||||
|
| "factual_error"
|
||||||
|
| "style"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type ChairFeedback = {
|
||||||
|
id: string;
|
||||||
|
case_id: string | null;
|
||||||
|
case_number: string;
|
||||||
|
block_id: string;
|
||||||
|
category: FeedbackCategory;
|
||||||
|
feedback_text: string;
|
||||||
|
lesson_extracted: string;
|
||||||
|
resolved: boolean;
|
||||||
|
applied_to: string[];
|
||||||
|
created_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFeedbackInput = {
|
||||||
|
case_number?: string;
|
||||||
|
block_id?: string;
|
||||||
|
feedback_text: string;
|
||||||
|
category?: FeedbackCategory;
|
||||||
|
lesson_extracted?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedbackKeys = {
|
||||||
|
all: ["feedback"] as const,
|
||||||
|
list: (filters: { category?: string; unresolved_only?: boolean }) =>
|
||||||
|
[...feedbackKeys.all, "list", filters] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFeedbackList(filters: {
|
||||||
|
category?: string;
|
||||||
|
unresolved_only?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.category) params.set("category", filters.category);
|
||||||
|
if (filters.unresolved_only) params.set("unresolved_only", "true");
|
||||||
|
const qs = params.toString();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: feedbackKeys.list(filters),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<ChairFeedback[]>(`/api/feedback${qs ? `?${qs}` : ""}`, { signal }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFeedback() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateFeedbackInput) =>
|
||||||
|
apiRequest<{ id: string; status: string }>("/api/feedback/json", {
|
||||||
|
method: "POST",
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolveFeedback() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
feedbackId,
|
||||||
|
applied_to,
|
||||||
|
}: {
|
||||||
|
feedbackId: string;
|
||||||
|
applied_to: string[];
|
||||||
|
}) =>
|
||||||
|
apiRequest<{ status: string }>(
|
||||||
|
`/api/feedback/${feedbackId}/resolve`,
|
||||||
|
{ method: "PATCH", body: { applied_to } },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hebrew labels for feedback categories */
|
||||||
|
export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
||||||
|
missing_content: "תוכן חסר",
|
||||||
|
wrong_tone: "טון שגוי",
|
||||||
|
wrong_structure: "מבנה שגוי",
|
||||||
|
factual_error: "שגיאה עובדתית",
|
||||||
|
style: "סגנון",
|
||||||
|
other: "אחר",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Block ID labels */
|
||||||
|
export const BLOCK_LABELS: Record<string, string> = {
|
||||||
|
"block-he": "ה — פתיחה",
|
||||||
|
"block-vav": "ו — רקע עובדתי",
|
||||||
|
"block-zayin": "ז — טענות הצדדים",
|
||||||
|
"block-chet": "ח — הליכים",
|
||||||
|
"block-tet": "ט — תכניות חלות",
|
||||||
|
"block-yod": "י — דיון והכרעה",
|
||||||
|
"block-yod-alef": "יא — סיכום",
|
||||||
|
};
|
||||||
140
web-ui/src/lib/api/precedents.ts
Normal file
140
web-ui/src/lib/api/precedents.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Attached-precedent hooks — user-supplied case-law quotes that
|
||||||
|
* justify chair positions in the compose screen.
|
||||||
|
*
|
||||||
|
* Backed by POST/GET/DELETE /api/cases/{n}/precedents and the
|
||||||
|
* cross-case library search at GET /api/precedents/search. The
|
||||||
|
* optional PDF archive chains through POST .../upload-pdf before
|
||||||
|
* precedent creation; that's a plain async function, not a mutation
|
||||||
|
* hook, because it has no cache invalidation of its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest, ApiError } from "./client";
|
||||||
|
import type { PracticeArea } from "@/lib/practice-area";
|
||||||
|
|
||||||
|
export type CasePrecedent = {
|
||||||
|
id: string;
|
||||||
|
case_id: string;
|
||||||
|
section_id: string | null;
|
||||||
|
quote: string;
|
||||||
|
citation: string;
|
||||||
|
chair_note: string;
|
||||||
|
pdf_document_id: string | null;
|
||||||
|
practice_area: PracticeArea | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrecedentCreateInput = {
|
||||||
|
quote: string;
|
||||||
|
citation: string;
|
||||||
|
section_id?: string;
|
||||||
|
chair_note?: string;
|
||||||
|
pdf_document_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LibraryMatch = {
|
||||||
|
id: string;
|
||||||
|
citation: string;
|
||||||
|
quote: string;
|
||||||
|
chair_note: string;
|
||||||
|
practice_area: PracticeArea | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const precedentKeys = {
|
||||||
|
all: ["precedents"] as const,
|
||||||
|
forCase: (caseNumber: string) =>
|
||||||
|
[...precedentKeys.all, "case", caseNumber] as const,
|
||||||
|
librarySearch: (q: string, area: string) =>
|
||||||
|
[...precedentKeys.all, "library", area, q] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCasePrecedents(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: precedentKeys.forCase(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<CasePrecedent[]>(
|
||||||
|
`/api/cases/${caseNumber}/precedents`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePrecedent(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: PrecedentCreateInput) =>
|
||||||
|
apiRequest<CasePrecedent>(`/api/cases/${caseNumber}/precedents`, {
|
||||||
|
method: "POST",
|
||||||
|
body: input,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: precedentKeys.forCase(caseNumber) });
|
||||||
|
qc.invalidateQueries({ queryKey: [...precedentKeys.all, "library"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePrecedent(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (precedentId: string) =>
|
||||||
|
apiRequest<{ deleted: boolean }>(`/api/precedents/${precedentId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: precedentKeys.forCase(caseNumber) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrecedentLibrarySearch(
|
||||||
|
query: string,
|
||||||
|
practiceArea: PracticeArea | null | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: precedentKeys.librarySearch(query, practiceArea ?? ""),
|
||||||
|
queryFn: ({ signal }) => {
|
||||||
|
const params = new URLSearchParams({ q: query });
|
||||||
|
if (practiceArea) params.set("practice_area", practiceArea);
|
||||||
|
return apiRequest<LibraryMatch[]>(
|
||||||
|
`/api/precedents/search?${params.toString()}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: enabled && query.trim().length >= 2,
|
||||||
|
staleTime: 10_000,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot PDF archive upload. Returns the new document_id so the
|
||||||
|
* caller can pass it into useCreatePrecedent. No cache invalidation
|
||||||
|
* — we only care about the id as a handle.
|
||||||
|
*/
|
||||||
|
export async function uploadPrecedentPdf(
|
||||||
|
caseNumber: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<{ document_id: string; filename: string }> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/cases/${encodeURIComponent(caseNumber)}/precedents/upload-pdf`,
|
||||||
|
{ method: "POST", body: fd },
|
||||||
|
);
|
||||||
|
const parsed = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
`Upload failed with ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
parsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed as { document_id: string; filename: string };
|
||||||
|
}
|
||||||
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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
30
web-ui/src/lib/api/skills.ts
Normal file
30
web-ui/src/lib/api/skills.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Paperclip skills — listing + sync actions.
|
||||||
|
*
|
||||||
|
* Skills live in Paperclip's database (separate from the main legal-ai DB)
|
||||||
|
* and are exposed via /api/admin/skills. The UI just needs read access for
|
||||||
|
* Phase 5; install/sync/delete mutations can follow in Phase 6.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type Skill = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
db_markdown_chars: number;
|
||||||
|
file_inventory: Array<{ path: string; size?: number }> | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
disk_exists: boolean;
|
||||||
|
disk_skill_md_bytes: number | null;
|
||||||
|
not_in_db?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSkills() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["skills", "list"] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<Skill[]>("/api/admin/skills", { signal }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
42
web-ui/src/lib/api/system.ts
Normal file
42
web-ui/src/lib/api/system.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* System-level hooks: diagnostics + active task snapshot.
|
||||||
|
*
|
||||||
|
* The vanilla UI polled /api/system/diagnostics and /api/system/tasks on
|
||||||
|
* an interval. We replace the polling with TanStack Query's refetchInterval
|
||||||
|
* — same effect, but participates in the shared cache and survives route
|
||||||
|
* transitions without setting up its own setInterval bookkeeping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type DiagDoc = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
case_number: string;
|
||||||
|
created_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Diagnostics = {
|
||||||
|
db_ok: boolean;
|
||||||
|
tables: Record<string, number | null>;
|
||||||
|
failed_documents: DiagDoc[];
|
||||||
|
stuck_documents: DiagDoc[];
|
||||||
|
active_tasks: Array<{
|
||||||
|
task_id: string;
|
||||||
|
filename: string;
|
||||||
|
status: string;
|
||||||
|
step: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDiagnostics() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["system", "diagnostics"] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<Diagnostics>("/api/system/diagnostics", { signal }),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
staleTime: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
151
web-ui/src/lib/api/training.ts
Normal file
151
web-ui/src/lib/api/training.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Training / style corpus hooks.
|
||||||
|
*
|
||||||
|
* Endpoints touched (all under /api/training/):
|
||||||
|
* - GET /style-report → the dashboard payload (corpus stats + anatomy
|
||||||
|
* + signature phrases + per-decision contribution)
|
||||||
|
* - GET /corpus → flat list of decisions for the corpus tab / compare tool
|
||||||
|
* - GET /compare?a=UUID&b=UUID → side-by-side comparison
|
||||||
|
* - DELETE /corpus/{id} → remove a decision from the corpus
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type StyleReport = {
|
||||||
|
corpus: {
|
||||||
|
decision_count: number;
|
||||||
|
total_chars: number;
|
||||||
|
avg_chars: number;
|
||||||
|
date_range: [string | null, string | null];
|
||||||
|
decisions: Array<{
|
||||||
|
number: string;
|
||||||
|
date: string;
|
||||||
|
chars: number;
|
||||||
|
subjects: string[];
|
||||||
|
}>;
|
||||||
|
subject_distribution: Array<{ label: string; count: number }>;
|
||||||
|
headline: string;
|
||||||
|
};
|
||||||
|
anatomy: {
|
||||||
|
sections: Array<{
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
avg_chars: number;
|
||||||
|
pct: number;
|
||||||
|
coverage: number;
|
||||||
|
}>;
|
||||||
|
total_coverage: number;
|
||||||
|
headline: string;
|
||||||
|
};
|
||||||
|
signature_phrases: {
|
||||||
|
items: Array<{
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
context: string;
|
||||||
|
frequency: number;
|
||||||
|
examples: string[];
|
||||||
|
}>;
|
||||||
|
total_decisions: number;
|
||||||
|
top_display: string;
|
||||||
|
headline: string;
|
||||||
|
};
|
||||||
|
contribution: {
|
||||||
|
growth_curve: Array<{
|
||||||
|
decision_number: string;
|
||||||
|
date: string;
|
||||||
|
cumulative: number;
|
||||||
|
}>;
|
||||||
|
decision_contributions: unknown[];
|
||||||
|
total_patterns: number;
|
||||||
|
headline: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CorpusDecision = {
|
||||||
|
id: string;
|
||||||
|
decision_number: string;
|
||||||
|
decision_date: string;
|
||||||
|
subject_categories: string[];
|
||||||
|
chars: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompareResult = {
|
||||||
|
a: CompareSide;
|
||||||
|
b: CompareSide;
|
||||||
|
shared: PatternEntry[];
|
||||||
|
only_a: PatternEntry[];
|
||||||
|
only_b: PatternEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompareSide = {
|
||||||
|
id: string;
|
||||||
|
decision_number: string;
|
||||||
|
decision_date: string;
|
||||||
|
chars: number;
|
||||||
|
subjects: string[];
|
||||||
|
sections: Array<{ type: string; chars: number }>;
|
||||||
|
patterns_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatternEntry = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
context: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trainingKeys = {
|
||||||
|
all: ["training"] as const,
|
||||||
|
report: () => [...trainingKeys.all, "style-report"] as const,
|
||||||
|
corpus: () => [...trainingKeys.all, "corpus"] as const,
|
||||||
|
compare: (a: string, b: string) =>
|
||||||
|
[...trainingKeys.all, "compare", a, b] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useStyleReport() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: trainingKeys.report(),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<StyleReport>("/api/training/style-report", { signal }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCorpus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: trainingKeys.corpus(),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<CorpusDecision[]>("/api/training/corpus", { signal }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompare(a: string | null, b: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: trainingKeys.compare(a ?? "", b ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<CompareResult>(
|
||||||
|
`/api/training/compare?a=${encodeURIComponent(a!)}&b=${encodeURIComponent(b!)}`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(a && b && a !== b),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCorpusEntry() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiRequest<{ deleted: boolean }>(
|
||||||
|
`/api/training/corpus/${encodeURIComponent(id)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
|
||||||
|
qc.invalidateQueries({ queryKey: trainingKeys.report() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
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
72
web-ui/src/lib/practice-area.ts
Normal file
72
web-ui/src/lib/practice-area.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Client-side mirror of mcp-server/src/legal_mcp/services/practice_area.py.
|
||||||
|
*
|
||||||
|
* Keep the enum values and derivation logic in sync with the backend — the
|
||||||
|
* server is the authority, but the UI needs the labels and derivation for
|
||||||
|
* UX (auto-fill, badges, filters). If the server adds a new practice_area
|
||||||
|
* or subtype, extend the arrays below.
|
||||||
|
*
|
||||||
|
* See also: legal-ai/docs/practice-area-separation.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PracticeArea =
|
||||||
|
| "appeals_committee"
|
||||||
|
| "national_insurance"
|
||||||
|
| "labor_law";
|
||||||
|
|
||||||
|
export type AppealSubtype =
|
||||||
|
| "building_permit"
|
||||||
|
| "betterment_levy"
|
||||||
|
| "compensation_197"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export const PRACTICE_AREAS: ReadonlyArray<{
|
||||||
|
value: PracticeArea;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}> = [
|
||||||
|
{ value: "appeals_committee", label: "ועדת ערר", enabled: true },
|
||||||
|
{ value: "national_insurance", label: "ביטוח לאומי", enabled: false },
|
||||||
|
{ value: "labor_law", label: "דיני עבודה", enabled: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APPEAL_SUBTYPES: ReadonlyArray<{
|
||||||
|
value: AppealSubtype;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "building_permit", label: "רישוי ובנייה" },
|
||||||
|
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||||
|
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
|
||||||
|
{ value: "unknown", label: "לא ידוע" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRACTICE_AREA_LABELS: Record<PracticeArea, string> =
|
||||||
|
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
|
||||||
|
PracticeArea,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const APPEAL_SUBTYPE_LABELS: Record<AppealSubtype, string> =
|
||||||
|
Object.fromEntries(APPEAL_SUBTYPES.map((s) => [s.value, s.label])) as Record<
|
||||||
|
AppealSubtype,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Derive the appeal_subtype from a case number. Mirrors the Python
|
||||||
|
* `derive_subtype` in practice_area.py. The convention is the case-number
|
||||||
|
* first digit: 1xxx → building_permit, 8xxx → betterment_levy,
|
||||||
|
* 9xxx → compensation_197. Everything else, including non-appeals_committee
|
||||||
|
* domains, returns 'unknown'.
|
||||||
|
*/
|
||||||
|
export function deriveSubtype(
|
||||||
|
caseNumber: string,
|
||||||
|
practiceArea: PracticeArea = "appeals_committee",
|
||||||
|
): AppealSubtype {
|
||||||
|
if (practiceArea !== "appeals_committee") return "unknown";
|
||||||
|
const first = caseNumber.trim().match(/^(\d)/)?.[1];
|
||||||
|
if (first === "1") return "building_permit";
|
||||||
|
if (first === "8") return "betterment_levy";
|
||||||
|
if (first === "9") return "compensation_197";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
web-ui/src/lib/schemas/case.ts
Normal file
94
web-ui/src/lib/schemas/case.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||||
|
|
||||||
|
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
|
||||||
|
* Two accepted formats, both hyphen-separated:
|
||||||
|
* NNNN-YY → "1033-25" (case sequence + 2-digit year)
|
||||||
|
* NNNN-MM-YY → "1000-04-26" (case sequence + 2-digit month + year)
|
||||||
|
*
|
||||||
|
* Slashes are deliberately forbidden: FastAPI path routing can't capture
|
||||||
|
* a `/` inside a {case_number} segment even when URL-encoded as %2F, so
|
||||||
|
* any case with a slash becomes unreachable at
|
||||||
|
* GET /api/cases/{case_number}/details. */
|
||||||
|
const caseNumberRe = /^[1-9]\d{3}(?:-\d{2}){1,2}$/;
|
||||||
|
|
||||||
|
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 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, "פורמט: NNNN-YY או NNNN-MM-YY (למשל 1033-25 או 1000-04-26)"),
|
||||||
|
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),
|
||||||
|
hearing_date: dateString,
|
||||||
|
notes: z.string().trim().max(2000),
|
||||||
|
expected_outcome: z.enum(
|
||||||
|
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
|
||||||
|
),
|
||||||
|
practice_area: z.enum([
|
||||||
|
"appeals_committee",
|
||||||
|
"national_insurance",
|
||||||
|
"labor_law",
|
||||||
|
] as const satisfies readonly PracticeArea[]),
|
||||||
|
appeal_subtype: z.enum([
|
||||||
|
"building_permit",
|
||||||
|
"betterment_levy",
|
||||||
|
"compensation_197",
|
||||||
|
"unknown",
|
||||||
|
] as const satisfies readonly AppealSubtype[]),
|
||||||
|
});
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user