Files
legal-ai/web-ui/src/app/feedback/page.tsx
Chaim 67c2c43777
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:18:09 +00:00

388 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { Check, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
useFeedbackList,
useResolveFeedback,
useCreateFeedback,
CATEGORY_LABELS,
BLOCK_LABELS,
type ChairFeedback,
type FeedbackCategory,
} from "@/lib/api/feedback";
import { formatDateLong } from "@/lib/format-date";
/**
* מרכז הערות יו"ר — הדף המרכזי לטיפול בכל הערות דפנה שנרשמו על טיוטות
* (chair_feedback). מאגד הערות מכל התיקים במקום אחד, מאפשר סינון לפי
* "טרם יושמו" וקטגוריה, וסימון כל הערה כיושמה. מוזן מ-/api/feedback.
*/
// category chip styling per mockup 06 (.c-missing / .c-tone / .c-struct / .c-fact / .c-style)
const CAT_CHIP: Record<FeedbackCategory, string> = {
missing_content: "bg-warn-bg text-warn",
wrong_tone: "bg-info-bg text-info",
wrong_structure: "bg-gold-wash text-gold-deep border border-rule",
factual_error: "bg-danger-bg text-danger",
style: "bg-rule-soft text-ink-soft",
other: "bg-rule-soft text-ink-soft",
};
const formatDate = (iso?: string | null) => formatDateLong(iso);
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
const resolve = useResolveFeedback();
const onResolve = () => {
resolve.mutate(
{ feedbackId: fb.id, applied_to: [], fold: true },
{
onSuccess: (res) =>
toast.success(
res.fold_queued
? "סומנה כיושמה — הלקח נשלח ל-CEO לקיפול לקובץ הידע"
: "ההערה סומנה כיושמה",
),
onError: (e) =>
toast.error(e instanceof Error ? e.message : "שגיאה בסימון"),
},
);
};
return (
<Card className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-[0.78]" : ""}`}>
<CardContent className="px-[18px] py-4 space-y-2.5">
{/* meta row — where · category chip · when (mockup 06 .meta) */}
<div className="flex items-center gap-2.5 flex-wrap">
<span className="font-semibold text-navy text-[0.84rem]">
{fb.case_number ? (
<Link
href={`/cases/${encodeURIComponent(fb.case_number)}`}
className="hover:text-gold-deep"
>
ערר {fb.case_number}
</Link>
) : (
"ללא תיק"
)}
<span className="text-ink-muted font-normal"> · {BLOCK_LABELS[fb.block_id] ?? fb.block_id}</span>
</span>
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${CAT_CHIP[fb.category]}`}
>
{CATEGORY_LABELS[fb.category]}
</span>
<span className="ms-auto text-[0.72rem] text-ink-muted whitespace-nowrap">
{formatDate(fb.created_at)}
</span>
</div>
<p className="text-ink-soft text-sm leading-relaxed m-0 whitespace-pre-wrap" dir="rtl">
{fb.feedback_text}
</p>
{fb.lesson_extracted ? (
<p
className="text-[0.82rem] text-ink-muted italic leading-relaxed m-0 whitespace-pre-wrap border-s-[3px] border-gold ps-2.5"
dir="rtl"
>
לקח שחולץ: {fb.lesson_extracted}
</p>
) : null}
{/* action row — gold CTA / applied pill (mockup 06 .actrow) */}
<div className="flex items-center justify-start pt-1">
{fb.resolved ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-bg text-success text-[0.76rem] font-semibold px-3 py-1">
<CheckCircle2 className="w-3.5 h-3.5" /> יושם
</span>
) : (
<Button
size="sm"
onClick={onResolve}
disabled={resolve.isPending}
className="bg-gold text-white hover:bg-gold-deep border-transparent"
>
<Check className="w-3.5 h-3.5 me-1" />
סמן כיושם
</Button>
)}
</div>
</CardContent>
</Card>
);
}
type CatFilter = "all" | FeedbackCategory;
const BLOCK_OPTIONS = [
"block-vav",
"block-zayin",
"block-chet",
"block-tet",
"block-yod",
"block-yod-alef",
];
function AddFeedbackForm() {
const create = useCreateFeedback();
const [caseNumber, setCaseNumber] = useState("");
const [blockId, setBlockId] = useState("block-yod");
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
const [text, setText] = useState("");
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) {
toast.error("יש להזין את תוכן ההערה");
return;
}
create.mutate(
{
case_number: caseNumber.trim() || undefined,
block_id: blockId,
category,
feedback_text: text.trim(),
},
{
onSuccess: () => {
toast.success("ההערה נשמרה");
setCaseNumber("");
setText("");
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בשמירה"),
},
);
};
const fieldCls =
"w-full text-[0.84rem] text-ink bg-parchment border border-rule rounded-md px-3 py-2";
const labelCls = "block text-[0.76rem] text-ink-muted font-medium mt-2.5 mb-1";
return (
<Card className="bg-surface border-rule shadow-sm lg:sticky lg:top-6">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-base font-semibold mb-3.5">הוסף הערה</h3>
<form onSubmit={onSubmit}>
<label className={labelCls} htmlFor="fb-case">מספר ערר</label>
<input
id="fb-case"
type="text"
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="לדוגמה: 1126-08-25"
className={fieldCls}
dir="rtl"
/>
<label className={labelCls} htmlFor="fb-block">בלוק</label>
<select
id="fb-block"
value={blockId}
onChange={(e) => setBlockId(e.target.value)}
className={`${fieldCls} cursor-pointer`}
>
{BLOCK_OPTIONS.map((b) => (
<option key={b} value={b}>
{BLOCK_LABELS[b]}
</option>
))}
</select>
<label className={labelCls} htmlFor="fb-cat">קטגוריה</label>
<select
id="fb-cat"
value={category}
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
className={`${fieldCls} cursor-pointer`}
>
{(Object.keys(CATEGORY_LABELS) as FeedbackCategory[]).map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c]}
</option>
))}
</select>
<label className={labelCls} htmlFor="fb-text">תוכן ההערה</label>
<textarea
id="fb-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="מה צריך לתקן ולמה…"
className={`${fieldCls} min-h-[90px] resize-y leading-relaxed`}
dir="rtl"
/>
<Button
type="submit"
disabled={create.isPending}
className="w-full mt-4 bg-gold text-white hover:bg-gold-deep border-transparent"
>
{create.isPending ? "שומר…" : "שמור הערה"}
</Button>
</form>
</CardContent>
</Card>
);
}
export default function FeedbackPage() {
const [unresolvedOnly, setUnresolvedOnly] = useState(true);
const [category, setCategory] = useState<CatFilter>("all");
const { data, isPending, error } = useFeedbackList({
unresolved_only: unresolvedOnly,
category: category === "all" ? undefined : category,
});
const items = useMemo(() => data ?? [], [data]);
const unresolvedCount = useMemo(
() => items.filter((f) => !f.resolved).length,
[items],
);
const categories: { key: CatFilter; label: string }[] = [
{ key: "all", label: "הכל" },
{ key: "missing_content", label: CATEGORY_LABELS.missing_content },
{ key: "wrong_tone", label: CATEGORY_LABELS.wrong_tone },
{ key: "wrong_structure", label: CATEGORY_LABELS.wrong_structure },
{ key: "factual_error", label: CATEGORY_LABELS.factual_error },
{ key: "style", label: CATEGORY_LABELS.style },
{ key: "other", label: CATEGORY_LABELS.other },
];
return (
<AppShell>
<section className="space-y-6">
<header className="space-y-1.5">
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<Link href="/approvals" className="hover:text-gold-deep">מרכז אישורים</Link>
<span aria-hidden> · </span>
<span className="text-navy">הערות יו״ר</span>
</nav>
<div className="flex items-end justify-between gap-4 flex-wrap">
<div className="space-y-1">
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
שערים אנושיים · יו״ר הוועדה
</div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-navy mb-0">הערות יו״ר</h1>
<span className="inline-block rounded-full bg-gold-wash border border-rule text-ink-muted text-[0.76rem] px-3 py-0.5">
נגיש גם מתוך מרכז האישורים
</span>
</div>
<p className="text-ink-muted text-sm mt-1 max-w-2xl leading-relaxed">
כל ההערות שנרשמו על טיוטות מכל התיקים. סמן כל הערה כיושמה
לאחר שהלקח הוטמע.
</p>
</div>
<div className="text-end">
<div className="text-3xl font-semibold text-navy leading-none tabular-nums">
{unresolvedCount}
</div>
<div className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted mt-1">
טרם יושמו
</div>
</div>
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* category filter chips (mockup 06 .chips) + resolved-state toggle */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
{categories.map((c) => {
const on = category === c.key;
return (
<button
key={c.key}
type="button"
onClick={() => setCategory(c.key)}
className={`text-[0.8rem] font-medium px-3.5 py-1.5 rounded-full border transition-colors ${
on
? "bg-navy text-white border-navy"
: "bg-surface text-ink-soft border-rule hover:bg-rule-soft"
}`}
>
{c.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 ms-auto">
<button
type="button"
onClick={() => setUnresolvedOnly(true)}
className={`text-[0.78rem] px-3 py-1.5 rounded border transition-colors ${
unresolvedOnly
? "bg-navy text-white border-navy"
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
}`}
>
טרם יושמו
</button>
<button
type="button"
onClick={() => setUnresolvedOnly(false)}
className={`text-[0.78rem] px-3 py-1.5 rounded border transition-colors ${
!unresolvedOnly
? "bg-navy text-white border-navy"
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
}`}
>
הכל
</button>
</div>
</div>
{/* two-column body — feedback list + sticky add-form rail (mockup 06 .wrap grid) */}
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start">
<div>
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-ink-muted text-sm">
שגיאה בטעינת ההערות. נסה לרענן.
</CardContent>
</Card>
) : isPending ? (
<div className="space-y-3.5">
{[0, 1, 2].map((i) => (
<Card key={i} className="bg-surface border-rule shadow-sm">
<CardContent className="px-[18px] py-4 h-28 animate-pulse" />
</Card>
))}
</div>
) : items.length === 0 ? (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הערות בקטגוריה זו.</p>
{unresolvedOnly && (
<p className="text-sm mt-2">כל ההערות יושמו </p>
)}
</div>
) : (
<div className="space-y-3.5">
{items.map((fb) => (
<FeedbackCard key={fb.id} fb={fb} />
))}
</div>
)}
</div>
<AddFeedbackForm />
</div>
</section>
</AppShell>
);
}