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>
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
useMethodology,
|
||
useUpdateMethodology,
|
||
useResetMethodology,
|
||
} from "@/lib/api/methodology";
|
||
import { toast } from "sonner";
|
||
import { Save, RotateCcw, Plus, Trash2, Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||
|
||
const OUTCOME_LABELS: Record<string, string> = {
|
||
universal: "כללי (לכל סוגי התוצאות)",
|
||
rejection: "דחייה",
|
||
full_acceptance: "קבלה מלאה",
|
||
partial_acceptance: "קבלה חלקית",
|
||
betterment_levy: "היטל השבחה",
|
||
};
|
||
|
||
type RulesSection = {
|
||
key: string;
|
||
label: string;
|
||
original: string[];
|
||
draft: string[];
|
||
isOverride: boolean;
|
||
dirty: boolean;
|
||
expanded: boolean;
|
||
};
|
||
|
||
export function DiscussionRulesPanel() {
|
||
const { data, isLoading } = useMethodology<string[]>("discussion_rules");
|
||
const update = useUpdateMethodology("discussion_rules");
|
||
const reset = useResetMethodology("discussion_rules");
|
||
const [sections, setSections] = useState<RulesSection[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!data?.items) return;
|
||
const order = ["universal", "rejection", "partial_acceptance", "full_acceptance", "betterment_levy"];
|
||
setSections(
|
||
order
|
||
.filter((k) => k in data.items)
|
||
.map((key) => ({
|
||
key,
|
||
label: OUTCOME_LABELS[key] ?? key,
|
||
original: data.items[key].value,
|
||
draft: [...data.items[key].value],
|
||
isOverride: data.items[key].is_override,
|
||
dirty: false,
|
||
expanded: key === "universal",
|
||
})),
|
||
);
|
||
}, [data]);
|
||
|
||
const toggle = (idx: number) => {
|
||
setSections((prev) =>
|
||
prev.map((s, i) => (i === idx ? { ...s, expanded: !s.expanded } : s)),
|
||
);
|
||
};
|
||
|
||
const updateRule = (sIdx: number, rIdx: number, text: string) => {
|
||
setSections((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s;
|
||
const draft = [...s.draft];
|
||
draft[rIdx] = text;
|
||
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
|
||
}),
|
||
);
|
||
};
|
||
|
||
const addRule = (sIdx: number) => {
|
||
setSections((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s;
|
||
const draft = [...s.draft, ""];
|
||
return { ...s, draft, dirty: true };
|
||
}),
|
||
);
|
||
};
|
||
|
||
const removeRule = (sIdx: number, rIdx: number) => {
|
||
setSections((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s;
|
||
const draft = s.draft.filter((_, j) => j !== rIdx);
|
||
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
|
||
}),
|
||
);
|
||
};
|
||
|
||
const handleSave = (sec: RulesSection) => {
|
||
const cleaned = sec.draft.filter((s) => s.trim());
|
||
update.mutate(
|
||
{ key: sec.key, value: cleaned },
|
||
{
|
||
onSuccess: () => toast.success(`${sec.label} נשמר`),
|
||
onError: () => toast.error("שגיאה בשמירה"),
|
||
},
|
||
);
|
||
};
|
||
|
||
const handleReset = (sec: RulesSection) => {
|
||
reset.mutate(sec.key, {
|
||
onSuccess: () => toast.success(`${sec.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="space-y-3">
|
||
{sections.map((sec, si) => (
|
||
<Card key={sec.key} className="border-rule">
|
||
<CardContent className="px-5 py-0">
|
||
{/* Accordion header */}
|
||
<button
|
||
className="flex items-center justify-between w-full py-3 text-right"
|
||
onClick={() => toggle(si)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-navy">{sec.label}</h3>
|
||
<Badge variant={sec.isOverride ? "default" : "secondary"} className="text-[10px]">
|
||
{sec.isOverride ? "מותאם" : "ברירת מחדל"}
|
||
</Badge>
|
||
<span className="text-[11px] text-ink-faint">{sec.draft.length} כללים</span>
|
||
</div>
|
||
{sec.expanded ? <ChevronUp className="w-4 h-4 text-ink-faint" /> : <ChevronDown className="w-4 h-4 text-ink-faint" />}
|
||
</button>
|
||
|
||
{/* Expanded content */}
|
||
{sec.expanded && (
|
||
<div className="pb-4 space-y-2">
|
||
{sec.draft.map((rule, ri) => (
|
||
<div key={ri} className="flex gap-2">
|
||
<Textarea
|
||
value={rule}
|
||
onChange={(e) => updateRule(si, ri, e.target.value)}
|
||
className="min-h-[60px] text-sm flex-1"
|
||
dir="rtl"
|
||
/>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8 text-red-400 hover:text-red-600 flex-shrink-0 mt-1"
|
||
onClick={() => removeRule(si, ri)}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
|
||
<Button size="sm" variant="outline" onClick={() => addRule(si)}>
|
||
<Plus className="w-3 h-3 ml-1" />
|
||
הוסף כלל
|
||
</Button>
|
||
|
||
<div className="flex items-center gap-2 pt-2 border-t border-rule/50">
|
||
<Button size="sm" disabled={!sec.dirty || update.isPending} onClick={() => handleSave(sec)}>
|
||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||
שמור
|
||
</Button>
|
||
{sec.isOverride && (
|
||
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={() => handleReset(sec)}>
|
||
<RotateCcw className="w-3 h-3 ml-1" />
|
||
איפוס
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|