Add methodology settings page with golden ratios, discussion rules, and checklists
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s

New /methodology page with 3 tabs for viewing and editing decision
writing methodology. Uses DB override pattern: hardcoded Python
constants serve as defaults, edits saved to appeal_type_rules table,
delete restores default.

Backend: 3 generic endpoints (GET/PUT/DELETE /api/methodology/{category}/{key})
with validation per category type.

Frontend: methodology.ts hooks, GoldenRatiosPanel (number inputs per
outcome/section), DiscussionRulesPanel (accordion with textarea per
rule), ContentChecklistsPanel (markdown editor with preview toggle).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 16:30:39 +00:00
parent 5dd24729e2
commit 3288624349
7 changed files with 766 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
useMethodology,
useUpdateMethodology,
useResetMethodology,
type GoldenRatios,
} from "@/lib/api/methodology";
import { toast } from "sonner";
import { Save, RotateCcw, Loader2 } from "lucide-react";
const OUTCOME_LABELS: Record<string, string> = {
rejection: "דחייה",
full_acceptance: "קבלה מלאה",
partial_acceptance: "קבלה חלקית",
betterment_levy: "היטל השבחה",
};
const SECTION_LABELS: Record<string, string> = {
background: "רקע",
claims: "טענות",
discussion: "דיון",
summary: "סיכום",
};
type RatioCard = {
key: string;
label: string;
original: GoldenRatios;
draft: GoldenRatios;
isOverride: boolean;
dirty: boolean;
};
export function GoldenRatiosPanel() {
const { data, isLoading } = useMethodology<GoldenRatios>("golden_ratios");
const update = useUpdateMethodology("golden_ratios");
const reset = useResetMethodology("golden_ratios");
const [cards, setCards] = useState<RatioCard[]>([]);
// Sync from server
useEffect(() => {
if (!data?.items) return;
setCards(
Object.entries(data.items).map(([key, item]) => ({
key,
label: OUTCOME_LABELS[key] ?? key,
original: item.value,
draft: structuredClone(item.value),
isOverride: item.is_override,
dirty: false,
})),
);
}, [data]);
const setRange = (cardIdx: number, section: string, idx: 0 | 1, val: number) => {
setCards((prev) =>
prev.map((c, i) => {
if (i !== cardIdx) return c;
const next = { ...c, draft: { ...c.draft, [section]: [...c.draft[section]] as [number, number] } };
next.draft[section][idx] = val;
next.dirty = JSON.stringify(next.draft) !== JSON.stringify(next.original);
return next;
}),
);
};
const handleSave = (card: RatioCard) => {
update.mutate(
{ key: card.key, value: card.draft },
{
onSuccess: () => toast.success(`${card.label} נשמר`),
onError: () => toast.error("שגיאה בשמירה"),
},
);
};
const handleReset = (card: RatioCard) => {
reset.mutate(card.key, {
onSuccess: () => toast.success(`${card.label} אופס לברירת מחדל`),
onError: () => toast.error("שגיאה באיפוס"),
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin ml-2" />
טוען...
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
{cards.map((card, ci) => (
<Card key={card.key} className="border-rule">
<CardContent className="px-5 py-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-navy">{card.label}</h3>
<Badge variant={card.isOverride ? "default" : "secondary"} className="text-[10px]">
{card.isOverride ? "מותאם" : "ברירת מחדל"}
</Badge>
</div>
{/* Table */}
<table className="w-full text-sm">
<thead>
<tr className="text-ink-faint text-[11px]">
<th className="text-right py-1">Section</th>
<th className="text-center py-1">Min %</th>
<th className="text-center py-1">Max %</th>
</tr>
</thead>
<tbody>
{Object.entries(SECTION_LABELS).map(([sec, label]) => (
<tr key={sec} className="border-t border-rule/50">
<td className="py-1.5 text-ink">{label}</td>
<td className="py-1.5 px-1">
<Input
type="number"
min={0}
max={100}
value={card.draft[sec]?.[0] ?? 0}
onChange={(e) => setRange(ci, sec, 0, Number(e.target.value))}
className="h-7 w-16 text-center text-xs mx-auto"
/>
</td>
<td className="py-1.5 px-1">
<Input
type="number"
min={0}
max={100}
value={card.draft[sec]?.[1] ?? 0}
onChange={(e) => setRange(ci, sec, 1, Number(e.target.value))}
className="h-7 w-16 text-center text-xs mx-auto"
/>
</td>
</tr>
))}
</tbody>
</table>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
disabled={!card.dirty || update.isPending}
onClick={() => handleSave(card)}
>
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
שמור
</Button>
{card.isOverride && (
<Button
size="sm"
variant="outline"
disabled={reset.isPending}
onClick={() => handleReset(card)}
>
<RotateCcw className="w-3 h-3 ml-1" />
איפוס
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
);
}