feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Four parallel sub-agents closed the remaining critical gaps from the 26/05 Stage A/B sprint. Each block independently tested; aggregated here. ## #30/#31 finalizers (sub-agent A) * Auto-derive practice_area in case_create from case_number prefix (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197); default for CaseCreateRequest is now "" (the DB constraint catches any stray "appeals_committee"). * practice_area.py: derive_subtype now handles axis-B domain values (rishuy_uvniya/betterment_levy/compensation_197) without parsing the case number; new helper derive_domain_practice_area(). * Halacha re-extraction verified unnecessary — all 6 reclassified records already had is_binding=false and approved halachot. * Regression tests: 6 cases in tests/test_corpus_constraints.py covering practice_area enum, internal-committee chair/district, external-upload arar prefix, MCP guard. * UI: district input → Select dropdown (7 districts) in precedent-edit-sheet.tsx, preserving legacy free-text values. ## #37 בל"מ subtypes (sub-agent B) * 3 new appeal_subtypes: extension_request_{building_permit, betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended, SUBTYPES_BY_AREA mappings added. * New helpers: is_blam_subject(), is_blam_subtype(), derive_subtype_with_blam(case_number, subject, practice_area). case_create now uses it to auto-detect "בקשה להארכת מועד" subjects. * 3 methodology templates under docs/methodology/extension-request-*.md. * paperclip_client.py mapping updated for the 3 new subtypes (extension_request_building_permit→CMP, the other two→CMPA). * Frontend: bilingual "בל"מ" badge + filter dropdown on cases list + detail header; appeal-type-bars collapseBlam() merges בל"מ into its parent domain for aggregate bars. * Wizard auto-detects בל"מ from subject during case creation. * 3 Berlinger cases (1017/1018/1019-03-26) migrated to appeal_subtype=extension_request_building_permit via psql. ## #35 missing_precedents feature (sub-agent C) * Schema V13: missing_precedents table (citation, case_id, party, legal_topic, status, linked_case_law_id, claim_quote, ...) + FK constraints + 3 indexes. Applied via psql + idempotent migration. * 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix to ingest_internal_decision or ingest_precedent). * Next.js page /missing-precedents with 5 status tabs + filters + sidebar badge counter + detail drawer with metadata edit + smart upload form that switches fields per committee/court. * Bootstrap: 7 rows imported from the JSON file (3 citations × cases, all status=closed with linked_case_law_id). * legal-researcher.md: new §2ב.5 with missing_precedent_create usage + dedup semantics + tool grant. ## #36 legal_arguments aggregation (sub-agent D) * Schema V14: legal_arguments + legal_argument_propositions M:M. Applied via psql. * New service argument_aggregator.py with two functions — aggregate_claims_to_arguments() (Claude CLI / claude_session) and get_legal_arguments(). Graceful llm_unavailable handling when CLI is missing (containers). * 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as BackgroundTask, GET .../legal-arguments). * Frontend: shadcn Accordion + new legal-arguments-panel.tsx with hierarchical (party → priority badge → arguments) display, "טיעונים" tab on the case page, "חשב/חשב מחדש" buttons. * scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run found 8 candidate cases including 1017/1018/1019. ## Open follow-ups (intentionally deferred) * npm run api:types in web-ui (CLAUDE.md flow) — recommended before the next UI commit; not required for backend deployment. * Run backfill_legal_arguments.py --apply once the container picks up the new aggregator service. * webhook on missing-precedents upload-close to Paperclip (optional). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Upload, Save, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useMissingPrecedent,
|
||||
useUpdateMissingPrecedent,
|
||||
useUploadMissingPrecedent,
|
||||
CITED_BY_PARTY_LABELS,
|
||||
STATUS_LABELS,
|
||||
type CitedByParty,
|
||||
type MissingPrecedentStatus,
|
||||
type MissingPrecedentPatch,
|
||||
} from "@/lib/api/missing-precedents";
|
||||
import {
|
||||
PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS,
|
||||
} from "@/components/precedents/practice-area";
|
||||
|
||||
type Props = {
|
||||
id: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
|
||||
|
||||
function isCommitteeCitation(citation: string): boolean {
|
||||
const norm = citation.trim();
|
||||
return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm);
|
||||
}
|
||||
|
||||
export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
|
||||
const open = id !== null;
|
||||
const { data: mp, isPending } = useMissingPrecedent(id);
|
||||
const update = useUpdateMissingPrecedent();
|
||||
const upload = useUploadMissingPrecedent();
|
||||
|
||||
// Edit form for metadata.
|
||||
const [legalTopic, setLegalTopic] = useState("");
|
||||
const [legalIssue, setLegalIssue] = useState("");
|
||||
const [cityParty, setCitedByParty] = useState<CitedByParty>("unknown");
|
||||
const [citedByPartyName, setCitedByPartyName] = useState("");
|
||||
const [caseName, setCaseName] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [status, setStatus] = useState<MissingPrecedentStatus>("open");
|
||||
|
||||
// Upload form fields.
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [decisionDate, setDecisionDate] = useState("");
|
||||
const [court, setCourt] = useState("");
|
||||
const [practiceArea, setPracticeArea] = useState<string>("");
|
||||
const [appealSubtype, setAppealSubtype] = useState("");
|
||||
const [precedentLevel, setPrecedentLevel] = useState("");
|
||||
const [chairName, setChairName] = useState("");
|
||||
const [district, setDistrict] = useState("");
|
||||
const [committeeCaseNumber, setCommitteeCaseNumber] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
|
||||
// Sync form from record when it loads or id changes.
|
||||
const [syncedId, setSyncedId] = useState<string | null>(null);
|
||||
if (mp && mp.id !== syncedId) {
|
||||
setSyncedId(mp.id);
|
||||
setLegalTopic(mp.legal_topic ?? "");
|
||||
setLegalIssue(mp.legal_issue ?? "");
|
||||
setCitedByParty(mp.cited_by_party ?? "unknown");
|
||||
setCitedByPartyName(mp.cited_by_party_name ?? "");
|
||||
setCaseName(mp.case_name ?? "");
|
||||
setNotes(mp.notes ?? "");
|
||||
setStatus(mp.status);
|
||||
}
|
||||
|
||||
// Reset on close. The cascading-render warning is the intended side
|
||||
// effect here — wiping the form when the drawer closes.
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFile(null);
|
||||
setSyncedId(null);
|
||||
setDecisionDate(""); setCourt(""); setPracticeArea("");
|
||||
setAppealSubtype(""); setPrecedentLevel(""); setChairName("");
|
||||
setDistrict(""); setCommitteeCaseNumber(""); setSummary("");
|
||||
}, [open]);
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!mp) return;
|
||||
const patch: MissingPrecedentPatch = {
|
||||
legal_topic: legalTopic,
|
||||
legal_issue: legalIssue,
|
||||
cited_by_party: cityParty,
|
||||
cited_by_party_name: citedByPartyName,
|
||||
case_name: caseName,
|
||||
notes,
|
||||
status,
|
||||
};
|
||||
try {
|
||||
await update.mutateAsync({ id: mp.id, patch });
|
||||
toast.success("הרשומה עודכנה");
|
||||
} catch (e) {
|
||||
toast.error("העדכון נכשל");
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const isCommittee = mp ? isCommitteeCitation(mp.citation) : false;
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!mp || !file) {
|
||||
toast.error("בחר קובץ");
|
||||
return;
|
||||
}
|
||||
if (isCommittee && (!chairName.trim() || !district.trim())) {
|
||||
toast.error("החלטת ועדת ערר דורשת שם יו״ר ומחוז");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await upload.mutateAsync({
|
||||
id: mp.id,
|
||||
file,
|
||||
case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
|
||||
chair_name: isCommittee ? chairName : undefined,
|
||||
district: isCommittee ? district : undefined,
|
||||
case_name: caseName || undefined,
|
||||
court: court || undefined,
|
||||
decision_date: decisionDate || undefined,
|
||||
practice_area: practiceArea || undefined,
|
||||
appeal_subtype: appealSubtype || undefined,
|
||||
precedent_level: precedentLevel || undefined,
|
||||
source_type: isCommittee ? "appeals_committee" : "court_ruling",
|
||||
summary: summary || undefined,
|
||||
});
|
||||
toast.success(
|
||||
`הפסיקה נכנסה לקורפוס (${result.route === "internal_committee" ? "ועדת ערר" : "פסק דין"}) והרשומה נסגרה.`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: typeof e === "string"
|
||||
? e
|
||||
: "כשל העלאה";
|
||||
toast.error(msg);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-full sm:max-w-2xl overflow-y-auto"
|
||||
>
|
||||
<SheetHeader className="space-y-1">
|
||||
<SheetTitle className="text-navy">
|
||||
פסיקה חסרה
|
||||
{mp ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ms-2 align-middle"
|
||||
>
|
||||
{STATUS_LABELS[mp.status]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
פרטים מלאים והעלאת הפסיקה לקורפוס.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isPending || !mp ? (
|
||||
<div className="space-y-3 px-6 py-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 px-6 py-4">
|
||||
{/* ── Citation block (read-only) ── */}
|
||||
<section className="space-y-2">
|
||||
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
|
||||
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
|
||||
{mp.citation}
|
||||
</div>
|
||||
{mp.claim_quote ? (
|
||||
<>
|
||||
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
|
||||
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
|
||||
{mp.claim_quote}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Linked record (if closed) ── */}
|
||||
{mp.linked_case_law_id ? (
|
||||
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
מקושר ל
|
||||
</div>
|
||||
<div className="text-sm text-emerald-900 truncate">
|
||||
{mp.linked_case_law_name || "—"}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-emerald-700 truncate">
|
||||
{mp.linked_case_law_number}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ── Editable metadata ── */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-navy">מטא־דאטה</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="legal_topic">נושא משפטי</Label>
|
||||
<Input
|
||||
id="legal_topic"
|
||||
value={legalTopic}
|
||||
onChange={(e) => setLegalTopic(e.target.value)}
|
||||
placeholder="זכות עמידה"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="case_name">שם פסיקה</Label>
|
||||
<Input
|
||||
id="case_name"
|
||||
value={caseName}
|
||||
onChange={(e) => setCaseName(e.target.value)}
|
||||
placeholder="אנטרים"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="legal_issue">שאלה משפטית</Label>
|
||||
<Textarea
|
||||
id="legal_issue"
|
||||
value={legalIssue}
|
||||
onChange={(e) => setLegalIssue(e.target.value)}
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="cited_by_party">צד מצטט</Label>
|
||||
<Select
|
||||
value={cityParty}
|
||||
onValueChange={(v) => setCitedByParty(v as CitedByParty)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(CITED_BY_PARTY_LABELS) as [CitedByParty, string][]).map(
|
||||
([v, label]) => (
|
||||
<SelectItem key={v} value={v}>{label}</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cited_by_party_name">שם צד</Label>
|
||||
<Input
|
||||
id="cited_by_party_name"
|
||||
value={citedByPartyName}
|
||||
onChange={(e) => setCitedByPartyName(e.target.value)}
|
||||
placeholder="לינדאב בע״מ"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="status">סטטוס</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as MissingPrecedentStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(STATUS_LABELS) as [
|
||||
MissingPrecedentStatus,
|
||||
string,
|
||||
][]).map(([v, label]) => (
|
||||
<SelectItem key={v} value={v}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notes">הערות</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSaveMetadata}
|
||||
disabled={update.isPending}
|
||||
variant="outline"
|
||||
className="border-rule"
|
||||
>
|
||||
{update.isPending ? (
|
||||
<Loader2 className="w-4 h-4 me-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שמור פרטים
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* ── Upload section ── */}
|
||||
{!mp.linked_case_law_id ? (
|
||||
<section className="space-y-3 border-t border-rule pt-5">
|
||||
<h3 className="text-sm font-semibold text-navy">
|
||||
העלאת הפסיקה לקורפוס
|
||||
</h3>
|
||||
<div className="text-[0.78rem] text-ink-muted">
|
||||
ניתוב אוטומטי לפי הציטוט:
|
||||
<strong className="text-navy">
|
||||
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpload} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="court">ערכאה</Label>
|
||||
<Input
|
||||
id="court"
|
||||
value={court}
|
||||
onChange={(e) => setCourt(e.target.value)}
|
||||
placeholder="בית המשפט העליון"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="decision_date">תאריך</Label>
|
||||
<Input
|
||||
id="decision_date"
|
||||
type="date"
|
||||
value={decisionDate}
|
||||
onChange={(e) => setDecisionDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="practice_area">תחום</Label>
|
||||
<Select value={practiceArea} onValueChange={setPracticeArea}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="ללא" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRACTICE_AREAS.map((a) => (
|
||||
<SelectItem key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="appeal_subtype">תת־סוג</Label>
|
||||
<Input
|
||||
id="appeal_subtype"
|
||||
value={appealSubtype}
|
||||
onChange={(e) => setAppealSubtype(e.target.value)}
|
||||
placeholder="זכות עמידה"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCommittee ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="chair_name">
|
||||
יו״ר <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="chair_name"
|
||||
value={chairName}
|
||||
onChange={(e) => setChairName(e.target.value)}
|
||||
placeholder="דפנה תמיר"
|
||||
dir="rtl"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="district">
|
||||
מחוז <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Select value={district} onValueChange={setDistrict}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="בחר" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISTRICTS.map((d) => (
|
||||
<SelectItem key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="committee_case_number">
|
||||
מספר ערר (לציטוט הקטן)
|
||||
</Label>
|
||||
<Input
|
||||
id="committee_case_number"
|
||||
value={committeeCaseNumber}
|
||||
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
|
||||
placeholder="ערר 1112/22 ..."
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="precedent_level">רמת תקדים</Label>
|
||||
<Select
|
||||
value={precedentLevel}
|
||||
onValueChange={setPrecedentLevel}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="ללא" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRECEDENT_LEVELS.map((l) => (
|
||||
<SelectItem key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="summary">תקציר</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
rows={2}
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!file || upload.isPending}
|
||||
className="bg-navy text-parchment hover:bg-navy-soft"
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="w-4 h-4 me-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 me-1" />
|
||||
)}
|
||||
העלאה וסגירה
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user