feat(ui): Phase 2 — issue detail tab + dashboard widget
Plugin now mounts two React components inside Paperclip via the
SDK's UI slot mechanism. Both are read-only views over data the
plugin worker fetches from legal-ai on demand.
## Slots
* **LegalCaseTab** (type: detailTab, entityTypes: ["issue"])
Mounted as a "ערר" tab on every issue page. Shows case summary
(status / practice_area / appeal_subtype), legal_arguments
grouped by party (עוררים/ועדה/משיבה/מבקשי היתר), attached
precedents, and open missing_precedents.
* **LegalCasesWidget** (type: dashboardWidget)
Dashboard tile with case counts by status + 7-day activity.
## Worker handlers (ctx.data.register)
Five handlers added at the end of setup() — all read-only over the
existing legal-ai HTTP API, all wrapped in try/catch so a transient
failure shows a placeholder instead of crashing the host:
- legal-case-summary → /api/cases/{n}/details
- legal-case-arguments → /api/cases/{n}/legal-arguments
- legal-case-precedents → /api/cases/{n}/precedents
- legal-case-missing-precedents → /api/missing-precedents?case_number=&status=open
- legal-dashboard-stats → in-memory aggregation over /api/cases
case_number is resolved from plugin state (scopeKind=issue,
stateKey=legal-case-number) — populated by legal_case_create.
## Build pipeline
- esbuild.ui.config.mjs uses createPluginBundlerPresets from the SDK
to build src/ui/index.tsx → dist/ui/index.js (13.5kb, react +
@paperclipai/plugin-sdk/ui externalized)
- package.json: build = "build:worker" (tsc) + "build:ui" (esbuild)
- tsconfig.json: jsx=react-jsx, lib += DOM
- New deps: react@19, @types/react, esbuild
## Manifest
- capabilities += ui.detailTab.register, ui.dashboardWidget.register
- entrypoints.ui = "dist/ui"
- ui.slots declared with entityTypes (not "entities" — fixed against
PluginUiSlotDeclaration validator)
## Verified
- tsc + esbuild + biome clean
- Plugin re-installs (20 capabilities) and activates with worker
+ 8 tools + 3 jobs + 1 webhook + 2 event subs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
248
src/ui/LegalCaseTab.tsx
Normal file
248
src/ui/LegalCaseTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||||
import type {
|
||||
LegalArgument,
|
||||
LegalArgumentsResponse,
|
||||
LegalCaseDetails,
|
||||
MissingPrecedentRow,
|
||||
PrecedentRow,
|
||||
} from "./types.js";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
padding: 16,
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||||
color: "inherit",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const headingStyle: React.CSSProperties = {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: "0 0 8px 0",
|
||||
borderBottom: "1px solid rgba(127,127,127,0.25)",
|
||||
paddingBottom: 4,
|
||||
};
|
||||
|
||||
const metaListStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
columnGap: 12,
|
||||
rowGap: 4,
|
||||
fontSize: 14,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const detailsBlockStyle: React.CSSProperties = {
|
||||
margin: "6px 0",
|
||||
padding: "6px 10px",
|
||||
background: "rgba(127,127,127,0.08)",
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const ulStyle: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
paddingInlineStart: 0,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const liStyle: React.CSSProperties = {
|
||||
padding: "4px 0",
|
||||
fontSize: 14,
|
||||
borderBottom: "1px dotted rgba(127,127,127,0.2)",
|
||||
};
|
||||
|
||||
const mutedStyle: React.CSSProperties = {
|
||||
color: "rgba(127,127,127,0.8)",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
function PartySection({
|
||||
party,
|
||||
args,
|
||||
}: {
|
||||
party: string;
|
||||
args: LegalArgument[];
|
||||
}) {
|
||||
if (!args || args.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong style={{ fontSize: 14 }}>{party}</strong>
|
||||
{args.map((a, idx) => {
|
||||
const key = a.id ?? `${party}-${a.claim_index ?? idx}`;
|
||||
const title =
|
||||
a.argument_title ||
|
||||
(a.argument_body || "").slice(0, 80) ||
|
||||
"(ללא כותרת)";
|
||||
const body = a.argument_body || "";
|
||||
return (
|
||||
<details key={key} style={detailsBlockStyle}>
|
||||
<summary style={{ cursor: "pointer", fontSize: 14 }}>
|
||||
{title}
|
||||
</summary>
|
||||
{body && (
|
||||
<p style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap" }}>
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalCaseTab({ context }: PluginDetailTabProps) {
|
||||
const issueId = context.entityId;
|
||||
|
||||
const summary = usePluginData<LegalCaseDetails | null>("legal-case-summary", {
|
||||
issueId,
|
||||
});
|
||||
const args = usePluginData<LegalArgumentsResponse | null>(
|
||||
"legal-case-arguments",
|
||||
{ issueId },
|
||||
);
|
||||
const precedents = usePluginData<PrecedentRow[] | null>(
|
||||
"legal-case-precedents",
|
||||
{ issueId },
|
||||
);
|
||||
const missing = usePluginData<MissingPrecedentRow[] | null>(
|
||||
"legal-case-missing-precedents",
|
||||
{ issueId },
|
||||
);
|
||||
|
||||
if (summary.loading) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p>טוען נתוני תיק…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.error) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p style={{ color: "crimson" }}>
|
||||
שגיאה בטעינת התיק: {summary.error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary.data) {
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<p style={mutedStyle}>
|
||||
אין תיק ערר מקושר ל-issue זה. ניתן ליצור תיק חדש דרך CEO או דרך כלי
|
||||
המערכת.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const caseDetails = summary.data;
|
||||
const byParty = args.data?.by_party ?? {};
|
||||
const argsTotal = args.data?.total ?? 0;
|
||||
const precedentsList = precedents.data ?? [];
|
||||
const missingList = missing.data ?? [];
|
||||
|
||||
return (
|
||||
<div dir="rtl" style={containerStyle}>
|
||||
<section style={sectionStyle}>
|
||||
<h2 style={{ margin: "0 0 8px 0", fontSize: 20 }}>
|
||||
תיק {caseDetails.case_number}
|
||||
</h2>
|
||||
<div style={metaListStyle}>
|
||||
<span style={mutedStyle}>כותרת:</span>
|
||||
<span>{caseDetails.title}</span>
|
||||
<span style={mutedStyle}>סטטוס:</span>
|
||||
<span>
|
||||
<code>{caseDetails.status}</code>
|
||||
</span>
|
||||
{caseDetails.practice_area && (
|
||||
<>
|
||||
<span style={mutedStyle}>תחום:</span>
|
||||
<span>{caseDetails.practice_area}</span>
|
||||
</>
|
||||
)}
|
||||
{caseDetails.appeal_subtype && (
|
||||
<>
|
||||
<span style={mutedStyle}>סוג ערר:</span>
|
||||
<span>{caseDetails.appeal_subtype}</span>
|
||||
</>
|
||||
)}
|
||||
{caseDetails.expected_outcome && (
|
||||
<>
|
||||
<span style={mutedStyle}>תוצאה צפויה:</span>
|
||||
<span>{caseDetails.expected_outcome}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>טיעונים משפטיים ({argsTotal})</h3>
|
||||
{args.loading && <p style={mutedStyle}>טוען טיעונים…</p>}
|
||||
{args.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {args.error.message}</p>
|
||||
)}
|
||||
{!args.loading && argsTotal === 0 && (
|
||||
<p style={mutedStyle}>לא חולצו עדיין טיעונים מהמסמכים.</p>
|
||||
)}
|
||||
{Object.entries(byParty).map(([party, list]) => (
|
||||
<PartySection key={party} party={party} args={list} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>פסיקה מצורפת ({precedentsList.length})</h3>
|
||||
{precedents.loading && <p style={mutedStyle}>טוען פסיקה…</p>}
|
||||
{precedents.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {precedents.error.message}</p>
|
||||
)}
|
||||
{!precedents.loading && precedentsList.length === 0 && (
|
||||
<p style={mutedStyle}>לא צורפה עדיין פסיקה לתיק.</p>
|
||||
)}
|
||||
{precedentsList.length > 0 && (
|
||||
<ul style={ulStyle}>
|
||||
{precedentsList.map((p) => (
|
||||
<li key={p.id} style={liStyle}>
|
||||
<strong>{p.citation}</strong>
|
||||
{p.practice_area && (
|
||||
<span style={mutedStyle}> — {p.practice_area}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={headingStyle}>פסיקה חסרה ({missingList.length})</h3>
|
||||
{missing.loading && <p style={mutedStyle}>טוען רשימת חסרים…</p>}
|
||||
{missing.error && (
|
||||
<p style={{ color: "crimson" }}>שגיאה: {missing.error.message}</p>
|
||||
)}
|
||||
{!missing.loading && missingList.length === 0 && (
|
||||
<p style={mutedStyle}>אין פסיקה חסרה פתוחה בתיק זה.</p>
|
||||
)}
|
||||
{missingList.length > 0 && (
|
||||
<ul style={ulStyle}>
|
||||
{missingList.map((m) => (
|
||||
<li key={m.id} style={liStyle}>
|
||||
<strong>{m.citation}</strong>
|
||||
<span style={mutedStyle}> — {m.status}</span>
|
||||
{m.legal_topic && (
|
||||
<span style={mutedStyle}> ({m.legal_topic})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user