Add methodology settings page with golden ratios, discussion rules, and checklists
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
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:
177
web-ui/src/components/methodology/golden-ratios-panel.tsx
Normal file
177
web-ui/src/components/methodology/golden-ratios-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user