feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural
Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle, iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir. - chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu kayıttan geri kurulur; form cevabı kredi harcamaz. - Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar, "yeniden kurdum" beyanı araçsızsa düzeltme notu. - /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir. - /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
144
src/features/rapor/components/sohbet-anketi.tsx
Normal file
144
src/features/rapor/components/sohbet-anketi.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
// Danışmanın soru formu (DESIGN.md §4.20, §4.33). `soru_sor` aracı
|
||||
// (lib/ai/araclar.ts) liste revizyonu isteği belirsizken 1-3 kısa soruyu bu
|
||||
// formla sorar; sorular tek tek gösterilir (shadcn Questionnaire,
|
||||
// components/ui/questionnaire.tsx). Gönderilince cevaplar hem adayın balonuna
|
||||
// okunur metin olarak (lib/ai/sohbet-meta.ts anketCevabiMetni) hem sunucuya
|
||||
// yapısal olarak gider; bu mesaj kredi harcamaz. Form yalnız sohbetin SON
|
||||
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey
|
||||
// yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
|
||||
// aynı hâlde döner).
|
||||
|
||||
import { useId } from "react";
|
||||
import { Check, MessageSquareText } from "lucide-react";
|
||||
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
|
||||
import {
|
||||
Questionnaire,
|
||||
QuestionnaireActions,
|
||||
QuestionnaireChoice,
|
||||
QuestionnaireChoiceDescription,
|
||||
QuestionnaireChoices,
|
||||
QuestionnaireDescription,
|
||||
QuestionnaireError,
|
||||
QuestionnaireInput,
|
||||
QuestionnaireItem,
|
||||
QuestionnaireNext,
|
||||
QuestionnairePrevious,
|
||||
QuestionnaireProgress,
|
||||
QuestionnaireSkip,
|
||||
QuestionnaireSubmit,
|
||||
QuestionnaireTitle,
|
||||
} from "@/components/ui/questionnaire";
|
||||
|
||||
export type AnketDurumu = "acik" | "gonderiliyor" | "cevaplandi" | "kapandi";
|
||||
|
||||
export function SohbetAnketi({
|
||||
anket,
|
||||
durum,
|
||||
onGonder,
|
||||
}: {
|
||||
anket: Anket;
|
||||
durum: AnketDurumu;
|
||||
onGonder: (cevaplar: AnketCevaplari) => void;
|
||||
}) {
|
||||
const kimlik = useId();
|
||||
|
||||
if (durum === "cevaplandi" || durum === "kapandi") {
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 px-0.5 text-xs font-medium text-slate-500">
|
||||
{durum === "cevaplandi" ? (
|
||||
<Check className="size-3.5 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<MessageSquareText className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{durum === "cevaplandi" ? "Formu cevapladın." : "Form kapandı; isteğini yazarak söyleyebilirsin."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const items = anket.sorular.map((s) => ({
|
||||
name: s.ad,
|
||||
required: s.zorunlu !== false,
|
||||
choices: s.secenekler.map((o) => ({ value: o.deger })),
|
||||
}));
|
||||
|
||||
function gonder(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (durum === "gonderiliyor") return;
|
||||
const veri = new FormData(e.currentTarget);
|
||||
const cevaplar: AnketCevaplari = {};
|
||||
for (const s of anket.sorular) {
|
||||
const hepsi = veri
|
||||
.getAll(s.ad)
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (hepsi.length === 0) continue;
|
||||
cevaplar[s.ad] = s.coklu ? [...new Set(hepsi)] : hepsi[hepsi.length - 1];
|
||||
}
|
||||
if (Object.keys(cevaplar).length === 0) return;
|
||||
onGonder(cevaplar);
|
||||
}
|
||||
|
||||
return (
|
||||
// Sade kutu (§4.4); balonun altında, aynı sütunda durur
|
||||
<Questionnaire
|
||||
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
|
||||
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
|
||||
className="rounded-2xl border border-slate-200 bg-card p-4"
|
||||
defaultItem={anket.sorular[0]?.ad}
|
||||
items={items}
|
||||
shortcuts="letters"
|
||||
onSubmit={gonder}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p id={`${kimlik}-baslik`} className="text-xs font-semibold text-slate-500">
|
||||
{anket.baslik ?? "Danışmanın sorusu"}
|
||||
</p>
|
||||
<QuestionnaireProgress
|
||||
render={(props, state) => (
|
||||
<div {...props}>
|
||||
{state.current} / {state.total}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{anket.sorular.map((s) => (
|
||||
<QuestionnaireItem
|
||||
key={s.ad}
|
||||
name={s.ad}
|
||||
multiple={s.coklu === true}
|
||||
required={s.zorunlu !== false}
|
||||
disabled={durum === "gonderiliyor"}
|
||||
>
|
||||
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
|
||||
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
|
||||
<QuestionnaireChoices>
|
||||
{s.secenekler.map((o) => (
|
||||
<QuestionnaireChoice key={o.deger} value={o.deger}>
|
||||
<span>{o.etiket}</span>
|
||||
{o.aciklama ? (
|
||||
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
|
||||
) : null}
|
||||
</QuestionnaireChoice>
|
||||
))}
|
||||
{s.serbest ? (
|
||||
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
|
||||
) : null}
|
||||
</QuestionnaireChoices>
|
||||
<QuestionnaireError>
|
||||
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
|
||||
</QuestionnaireError>
|
||||
</QuestionnaireItem>
|
||||
))}
|
||||
<QuestionnaireActions>
|
||||
<QuestionnairePrevious size="sm" />
|
||||
<QuestionnaireSkip size="sm" />
|
||||
<QuestionnaireNext size="sm" />
|
||||
<QuestionnaireSubmit size="sm" disabled={durum === "gonderiliyor"}>
|
||||
{durum === "gonderiliyor" ? "Gönderiliyor…" : "Cevapları gönder"}
|
||||
</QuestionnaireSubmit>
|
||||
</QuestionnaireActions>
|
||||
</Questionnaire>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user