Fix Paperclip integration (identifier→issue_prefix) + add settings page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
- Fix column name mismatch in paperclip_client.py and app.py: Paperclip's companies table uses `issue_prefix`, not `identifier` - Fix _LEGAL_DB_URL to read from POSTGRES_URL env var (used in container) - Add settings page (/settings) for managing tag → Paperclip company mappings - Replace "תיק חדש" nav item with "הגדרות" (new case is on home page) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
250
web-ui/src/app/settings/page.tsx
Normal file
250
web-ui/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useTagMappings,
|
||||
usePaperclipCompanies,
|
||||
useAddTagMapping,
|
||||
useDeleteTagMapping,
|
||||
} from "@/lib/api/settings";
|
||||
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: mappings, isPending: loadingMappings } = useTagMappings();
|
||||
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
|
||||
const addMapping = useAddTagMapping();
|
||||
const deleteMapping = useDeleteTagMapping();
|
||||
|
||||
const [tag, setTag] = useState("");
|
||||
const [tagLabel, setTagLabel] = useState("");
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
|
||||
function handleTagChange(value: string) {
|
||||
setTag(value);
|
||||
const match = APPEAL_SUBTYPES.find((s) => s.value === value);
|
||||
if (match) setTagLabel(match.label);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!tag || !companyId) {
|
||||
toast.error("יש לבחור תגית וחברה");
|
||||
return;
|
||||
}
|
||||
const company = companies?.find((c) => c.id === companyId);
|
||||
addMapping.mutate(
|
||||
{
|
||||
tag,
|
||||
tag_label: tagLabel,
|
||||
company_id: companyId,
|
||||
company_name: company?.name ?? "",
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("מיפוי נוסף בהצלחה");
|
||||
setTag("");
|
||||
setTagLabel("");
|
||||
setCompanyId("");
|
||||
},
|
||||
onError: (err) => toast.error(`שגיאה: ${err.message}`),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete(id: string, tag: string) {
|
||||
deleteMapping.mutate(id, {
|
||||
onSuccess: () => toast.success(`מיפוי "${tag}" נמחק`),
|
||||
onError: (err) => toast.error(`שגיאה: ${err.message}`),
|
||||
});
|
||||
}
|
||||
|
||||
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">
|
||||
ניהול מיפוי תגיות ערר לחברות ב-Paperclip. כל תיק חדש ישויך
|
||||
אוטומטית לפרויקט בחברה הנכונה לפי סוג הערר.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{/* Companies overview */}
|
||||
<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">
|
||||
<Building2 className="w-4 h-4" />
|
||||
חברות ב-Paperclip
|
||||
</h2>
|
||||
{loadingCompanies ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : !companies?.length ? (
|
||||
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{companies.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
|
||||
>
|
||||
<span className="text-sm font-medium text-ink">{c.name}</span>
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{c.prefix}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tag mappings */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
|
||||
<Tags className="w-4 h-4" />
|
||||
מיפוי תגיות
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{mappings?.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
|
||||
{/* Add form */}
|
||||
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
|
||||
<div className="flex flex-col gap-1.5 min-w-[180px]">
|
||||
<label className="text-[0.72rem] text-ink-muted">
|
||||
תגית (appeal subtype)
|
||||
</label>
|
||||
<Select value={tag} onValueChange={handleTagChange}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="בחר סוג ערר" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{APPEAL_SUBTYPES.filter((s) => s.value !== "unknown").map(
|
||||
(s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<label className="text-[0.72rem] text-ink-muted">תווית</label>
|
||||
<Input
|
||||
value={tagLabel}
|
||||
onChange={(e) => setTagLabel(e.target.value)}
|
||||
placeholder="שם לתצוגה"
|
||||
className="w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 min-w-[200px]">
|
||||
<label className="text-[0.72rem] text-ink-muted">
|
||||
חברה ב-Paperclip
|
||||
</label>
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="בחר חברה" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name} ({c.prefix})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={addMapping.isPending || !tag || !companyId}
|
||||
size="default"
|
||||
>
|
||||
<Plus className="w-4 h-4" data-icon="inline-start" />
|
||||
{addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loadingMappings ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : !mappings?.length ? (
|
||||
<p className="text-ink-muted text-sm">
|
||||
אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
|
||||
לפרויקט בחברה הנכונה.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
<th className="text-start py-2 px-3 font-medium">Tag</th>
|
||||
<th className="text-start py-2 px-3 font-medium">Label</th>
|
||||
<th className="text-start py-2 px-3 font-medium">Company</th>
|
||||
<th className="py-2 px-3 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.map((m) => (
|
||||
<tr
|
||||
key={m.id}
|
||||
className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 px-3">
|
||||
<Badge variant="outline" className="text-[0.75rem] font-mono">
|
||||
{m.tag}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
|
||||
<td className="py-2.5 px-3 text-ink">{m.company_name}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => handleDelete(m.id, m.tag)}
|
||||
disabled={deleteMapping.isPending}
|
||||
title="מחק מיפוי"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-danger" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -24,10 +24,10 @@ type NavItem = {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/cases/new", label: "תיק חדש" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
{ href: "/settings", label: "הגדרות" },
|
||||
];
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
|
||||
57
web-ui/src/lib/api/settings.ts
Normal file
57
web-ui/src/lib/api/settings.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Settings hooks: tag → Paperclip company mappings.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type PaperclipCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
export type TagMapping = {
|
||||
id: string;
|
||||
tag: string;
|
||||
tag_label: string;
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export function usePaperclipCompanies() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "paperclip-companies"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<PaperclipCompany[]>("/api/settings/paperclip-companies", { signal }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTagMappings() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "tag-mappings"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<TagMapping[]>("/api/settings/tag-mappings", { signal }),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTagMapping() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { tag: string; tag_label: string; company_id: string; company_name: string }) =>
|
||||
apiRequest<TagMapping>("/api/settings/tag-mappings", { method: "POST", body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTagMapping() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ ok: boolean }>(`/api/settings/tag-mappings/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
|
||||
});
|
||||
}
|
||||
@@ -2113,9 +2113,9 @@ async def api_paperclip_companies():
|
||||
conn = await asyncpg.connect(pc_url)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, name, identifier FROM companies ORDER BY name"
|
||||
"SELECT id, name, issue_prefix FROM companies ORDER BY name"
|
||||
)
|
||||
return [{"id": str(r["id"]), "name": r["name"], "identifier": r.get("identifier", "")} for r in rows]
|
||||
return [{"id": str(r["id"]), "name": r["name"], "prefix": r.get("issue_prefix", "")} for r in rows]
|
||||
finally:
|
||||
await conn.close()
|
||||
except Exception as e:
|
||||
|
||||
@@ -40,7 +40,7 @@ _FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||
}
|
||||
|
||||
# Legal-AI DB URL for reading tag_company_mappings
|
||||
_LEGAL_DB_URL = os.environ.get(
|
||||
_LEGAL_DB_URL = os.environ.get("POSTGRES_URL") or os.environ.get(
|
||||
"DATABASE_URL", "postgresql://legal:legal@127.0.0.1:5432/legal_ai"
|
||||
)
|
||||
|
||||
@@ -76,11 +76,11 @@ async def create_project(
|
||||
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
# Resolve prefix from company identifier in Paperclip DB
|
||||
# Resolve prefix from company issue_prefix in Paperclip DB
|
||||
comp_row = await conn.fetchrow(
|
||||
"SELECT identifier FROM companies WHERE id = $1::uuid", company_id,
|
||||
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", company_id,
|
||||
)
|
||||
prefix = comp_row["identifier"] if comp_row and comp_row["identifier"] else "CMP"
|
||||
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
||||
|
||||
# Check for existing project with this case number
|
||||
existing = await conn.fetchrow(
|
||||
@@ -244,9 +244,9 @@ async def get_project_url(case_number: str) -> str | None:
|
||||
)
|
||||
if row:
|
||||
comp_row = await conn.fetchrow(
|
||||
"SELECT identifier FROM companies WHERE id = $1::uuid", str(row["company_id"]),
|
||||
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", str(row["company_id"]),
|
||||
)
|
||||
prefix = comp_row["identifier"] if comp_row and comp_row["identifier"] else "CMP"
|
||||
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
||||
return f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues"
|
||||
return None
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user