refactor: rapor feature'ı — listem, yazdır, sohbet ve actions taşındı (Faz 8)

- app/sonuc/actions.ts → features/rapor/rapor-actions.ts birebir taşındı
  (kredi harcama/iade idempotency yolları bayt bayt aynı, yalnızca import
  yolları değişti); liste-uretici/revizyon-kutusu action'ı doğrudan import
  ediyor
- lib/rapor-kaydi.ts rapor-queries.ts'e emildi: kullanicininRaporu aynı tek
  okuma kapısı; listem sayfasındaki inline drizzle sorgusu sohbetGecmisiGetir
  oldu; tadimlikSec re-export'u eklendi
- listem-govde, sohbet-client, revizyon-kutusu, liste-uretici, listem-bos-cta,
  rapor-listesi, tadimlik-satiri, yazdir-butonu features/rapor/components/'a
- Yeni async ListemIcerik (verifySession + maskeleme + bosDurum dalı içeride;
  çift await searchParams kalktı) ve RaporYazdir (3 redirect + yazdırılabilir
  belge); skeleton'lar eski loading.tsx geometrileriyle aynı dosyada
- /listem ve /rapor/yazdir senkron: searchParams.then() + sayfada Suspense;
  maxDuration=300 ve metadata sayfada kaldı; iki loading.tsx silindi
- Davranış değişikliği yok; /api/soru el değmedi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 01:39:33 +03:00
parent 7d2b2ddd71
commit fbd184e2be
21 changed files with 357 additions and 319 deletions

View File

@@ -1,392 +0,0 @@
"use client";
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
// sunucuda zaten maskelidir).
import { Fragment, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
import { UniLogo } from "@/components/uni-logo";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
raporSiraSerisi,
} from "@/components/program-liste-verileri";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import {
RISK_ETIKET,
dilimdenRisk,
riskHesapla,
type RiskSeviyesi,
} from "@/lib/risk";
export const DILIM_ETIKET: Record<string, string> = {
hayal: "Hayal",
dengeli: "Dengeli",
garanti: "Garanti",
};
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket dar ekranda sr-only
// olarak kalır (renk tek başına anlam taşımaz).
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
riskli: { nokta: "bg-red-500", icon: Rocket },
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
};
// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
const SUTUN_SAYISI = 6;
/**
* Açık satırın detay bloğu: dilim/risk başlığı + gerekçe ve risk/trend
* kartları. /sonuc'taki girişsiz tadımlık satırı da (tadimlik-satiri.tsx)
* aynı bileşeni kullanır — "demo değil ürünün kendisi" hissi kopyayla değil
* tek kaynakla sağlanır.
*/
export function RaporSatirDetayi({
dilim,
risk,
unitur,
gerekce,
riskNotu,
trendOzeti,
}: {
dilim: string;
risk: RiskSeviyesi;
unitur?: string | null;
gerekce: string;
riskNotu: string;
trendOzeti: string;
}) {
const stil = RISK_STIL[risk];
return (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[dilim]} · {RISK_ETIKET[risk]}
</span>
{unitur ? <span>· {unitur}</span> : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{gerekce}</dd>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve Yapay Zeka yorumu
</dt>
<dd className="mt-1 text-slate-700">
{riskNotu} {trendOzeti}
</dd>
</div>
</dl>
</>
);
}
export function RaporListesi({
rapor,
adaySira,
kilitliBaslangic,
kilitOverlay,
acikSira,
onHaritadaGor,
}: {
rapor: RaporSonuc;
adaySira?: number;
kilitliBaslangic?: number;
kilitOverlay?: React.ReactNode;
/** Dışarıdan (haritadan) açtırılan satır — değişince satır açılıp kaydırılır */
acikSira?: number | null;
/** Detaydaki "Haritada gör" — il DB biçiminde */
onHaritadaGor?: (il: string | null, sira: number) => void;
}) {
const [acik, setAcik] = useState<Set<number>>(new Set());
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
useEffect(() => {
if (acikSira == null) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAcik((mevcut) => new Set(mevcut).add(acikSira));
satirRefs.current
.get(acikSira)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [acikSira]);
function tiklandi(sira: number) {
setAcik((mevcut) => {
const yeni = new Set(mevcut);
if (yeni.has(sira)) yeni.delete(sira);
else yeni.add(sira);
return yeni;
});
}
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const iller = new Set(
rapor.tercihler
.map((t) => rapor.programlar[t.programId]?.il)
.filter((il): il is string => Boolean(il)),
);
const tekIl = iller.size === 1 ? [...iller][0] : null;
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const seri = raporSiraSerisi(p?.siraGecmisi);
const taban = p?.efektifSira ?? programEtkinSira(seri);
const acikMi = acik.has(t.sira);
const devlet = p?.unitur === "DEVLET";
return (
<Fragment key={t.sira}>
<TableRow
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
onClick={() => tiklandi(t.sira)}
className="cursor-pointer"
>
<TableCell className="pl-4 pr-0">
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
<span className="min-w-0 flex-1">
{p ? (
<Link
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`${p.isim} bölüm sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.isim}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{t.programId}
</span>
)}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
{p?.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
) : null}
<span className="truncate">
{p?.universite ? (
<Link
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
title={`${p.universite} taban puanları sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.universite}
</Link>
) : null}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={seri} />
</TableCell>
<TableCell className="text-xs font-medium">
{analizKilitli ? (
<span className="inline-flex items-center gap-1.5 text-slate-500">
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
<span className="sr-only sm:not-sr-only">Pakette</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-hidden
/>
<span className="sr-only sm:not-sr-only">
{RISK_ETIKET[risk]}
</span>
</span>
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
aria-expanded={acikMi}
onClick={(e) => {
e.stopPropagation();
tiklandi(t.sira);
}}
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
<span className="sr-only">
{p?.isim ?? t.programId} — Yapay Zeka değerlendirmesi
</span>
</button>
</TableCell>
</TableRow>
{acikMi ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal bg-slate-50/60 px-4 py-4"
>
{analizKilitli ? (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
<span className="font-medium">Kişisel analiz pakette</span>
{p?.unitur ? <span>· {p.unitur}</span> : null}
</div>
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Lock className="size-3.5" aria-hidden />
Yapay Zeka karar desteği
</p>
<p className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel
yerleşme riskini paketle gör.
</p>
</div>
</>
) : (
<RaporSatirDetayi
dilim={t.dilim}
risk={risk}
unitur={p?.unitur}
gerekce={t.gerekce}
riskNotu={t.riskNotu}
trendOzeti={t.trendOzeti}
/>
)}
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<MapPin className="size-3.5" aria-hidden />
Haritada gör
</button>
) : null}
</TableCell>
</TableRow>
) : null}
</Fragment>
);
};
return (
<div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-right text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
{rapor.kapsam?.uyarlandi ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
dengeli · {rapor.kapsam.dagilim.garanti} garanti olarak kuruldu. Daha
fazla seçenek için alan veya il filtreni genişletebilirsin.
</p>
) : null}
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Listenin {rapor.tercihler.length} tercihi de tek şehirde (
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead>
<span className="hidden sm:inline">Yapay Zeka </span>Risk
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-12 pr-4 text-right">
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal border-y border-orange-200 bg-orange-50/70 p-5"
>
{kilitOverlay}
</TableCell>
</TableRow>
) : null}
{satir(t, index)}
</Fragment>
))}
</TableBody>
</Table>
</div>
</div>
);
}