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>
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|