feat: update app.db and BACKLOG.md with new decisions and adjustments
All checks were successful
Deploy / deploy (push) Successful in 7m45s

- Updated the app.db file to reflect recent changes in the application state.
- Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates.
- Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
This commit is contained in:
bilalgursen
2026-10-06 17:17:49 +03:00
parent c0696e2cbc
commit a3cb058a3b
74 changed files with 10334 additions and 445 deletions

View File

@@ -2,13 +2,13 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Armchair,
Briefcase,
Building2,
Calculator,
ChartLine,
ChevronRight,
GraduationCap,
Landmark,
LayoutList,
Target,
} from "lucide-react";
import {
@@ -34,7 +34,13 @@ import { BolumProgramListesi } from "./bolum-program-listesi";
import { VeriKaynagi } from "./veri-kaynagi";
import { NavBaslik } from "./nav-baslik";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { OzetKart } from "@/components/ozet-kart";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import {
KartBaglantisi,
kisaUniAdi,
ortaTabanAciklamasi,
} from "./taban-karti";
import { trBaslikDuzeni } from "@/lib/slug";
import {
BOLUM_ISTIHDAM_KAYNAK_URL,
BOLUM_ISTIHDAM_YIL,
@@ -56,24 +62,6 @@ import {
const sayi = (n: number) => n.toLocaleString("tr-TR");
/**
* Özet kartında iri sayı telefonda ~94 px'e sığmalı (DESIGN.md §4.8):
* 36 px Cal Sans'ta "1.618.988" 123 px ölçüldü, ikonun altına giriyor. 6 ve
* 7 haneli sıralar birimle kısalır: 245.300 → "245" + "bin",
* 1.618.988 → "1,62" + "milyon".
*/
function kartSayisi(n: number): { deger: string; birim?: string } {
// 999.500+ "1.000 bin" olmasın diye milyona geçer
if (n >= 999_500) {
return {
deger: (n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 }),
birim: "milyon",
};
}
if (n >= 100_000) return { deger: sayi(Math.round(n / 1000)), birim: "bin" };
return { deger: sayi(n) };
}
const ondalik = (n: number) =>
n.toLocaleString("tr-TR", { maximumFractionDigits: 1 });
@@ -420,19 +408,22 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
...(bolum.lisansProgramlari.length > 0 ? ["Lisans"] : []),
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
];
// Özet kartları: devlet ve vakıf ayrı sayılır; KKTC ve yurt dışı
// programları toplamda var ama iki karta da girmez (vakıf sayılmaz).
const devletUni = new Set<string>();
const vakifUni = new Set<string>();
let vakifProgram = 0;
for (const p of tumProgramlar) {
if (p.unitur === "DEVLET") devletUni.add(p.universite);
else if (p.unitur === "VAKIF" || p.unitur === "VAKIF MYO") {
vakifProgram += 1;
vakifUni.add(p.universite);
}
}
const { stats } = bolum;
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
// çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici
// program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve
// boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları
// özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8).
const { taban } = bolum;
const uniBaglantisi = (universite: string) => (
<KartBaglantisi
href={(() => {
const s = uniSlugFromAd(universite);
return s ? `/universite/${s}` : null;
})()}
>
{kisaUniAdi(trBaslikDuzeni(universite))}
</KartBaglantisi>
);
// TÜİK mezun istihdamı: lisansı olan bölümde lisans tablosu, yalnız ön
// lisansı olanda ön lisans tablosu. Tabloda yoksa satır hiç çizilmez.
const istihdamDuzeyi: IstihdamDuzeyi =
@@ -568,38 +559,62 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{ilkSayfa ? (
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(stats.toplam)}
alt={`${sayi(stats.ilSayisi)} ilde`}
ikon={Target}
etiket="En seçici"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
alt={
taban.enSecici ? (
<>
{uniBaglantisi(taban.enSecici.universite)} · {SON_YIL} taban
sırası
</>
) : (
"henüz taban oluşmadı"
)
}
/>
{/* Etiket kısa kalır ("Devlette en geniş" 131 px'te iki satıra
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
<OzetKart
ikon={Landmark}
etiket="Devlet"
deger={sayi(stats.devlet)}
alt={
devletUni.size > 0
? `${sayi(devletUni.size)} üniversitede`
: "bu bölümde yok"
}
/>
<OzetKart
ikon={Building2}
etiket="Vakıf"
deger={sayi(vakifProgram)}
alt={
vakifUni.size > 0
? `${sayi(vakifUni.size)} üniversitede`
: "bu bölümde yok"
}
/>
<OzetKart
ikon={Target}
etiket="En iyi sıralama"
{...(stats.minSira != null
? kartSayisi(stats.minSira)
etiket="En geniş kapı"
{...(taban.enGenis
? kartSayisi(taban.enGenis.sira)
: { deger: "—" })}
alt={`${SON_YIL} taban sırası`}
alt={
taban.enGenis ? (
<>
{taban.kume === "devlet" ? "Devlet · " : ""}
{uniBaglantisi(taban.enGenis.universite)} · {SON_YIL} taban
sırası
</>
) : (
"henüz taban oluşmadı"
)
}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban].reverse().find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
/>
<OzetKart
ikon={Armchair}
etiket="Boş koltuk"
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
alt={
taban.doluluk
? taban.doluluk.bosKoltuk > 0
? `${sayi(taban.doluluk.programSayisi)} programın ${sayi(taban.doluluk.dolmayanProgram)} tanesi dolmadı · ${SON_YIL}`
: `Her program doldu · ${SON_YIL}`
: "kontenjan verisi yok"
}
/>
</dl>
) : null}

View File

@@ -28,7 +28,7 @@ import {
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import { ProgramAyrintiButonu } from "./program-ayrinti";
import { UniLogo } from "@/components/uni-logo";
import {
Table,
@@ -208,7 +208,7 @@ export function BolumProgramListesi({
/>
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
<span className="sr-only">Ayrıntı ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -290,7 +290,7 @@ export function BolumProgramListesi({
</TableCell>
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
<ProgramAyrintiButonu
programId={p.id}
isim={p.isim.trim()}
universite={uniAd}

View File

@@ -0,0 +1,541 @@
"use client";
// Program satırındaki "ayrıntı" düğmesi: programın tüm açık verisini alttan
// açılan sayfada gösterir (DESIGN.md §4.10 çekmece, §4.19 program ayrıntı
// çekmecesi). Veri /api/program-ayrinti'den gelir: künye (dil, öğretim türü,
// burs/ücret, akreditasyon, baraj, kadro, kampüs, cinsiyet koşulu), yıllara
// göre sıra/puan/kontenjan/yerleşen, kontenjan türleri, son yerleşenin
// netleri, yerleşme koşulları, TÜİK mezun istihdamı, üniversite özeti.
// Kadın-erkek dağılımı program düzeyinde bilinçli olarak yok: YÖK Atlas 2026
// SPA'sına geçerken cinsiyet panelini yayından kaldırdı (bkz. scripts/detay.ts).
// Çekmece içerik ağırlıklı olduğu için liste çekmecesi gibi kaydırılır (§4.10
// tek ekran kuralının ikinci istisnası).
import { SATIR_BASLIK, SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { useState } from "react";
import {
BadgeCheck,
Clock,
Flag,
GraduationCap,
Info,
Languages,
MapPin,
Monitor,
Users,
Wallet,
X,
} from "lucide-react";
import type { ProgramAyrintisi } from "@/app/api/program-ayrinti/route";
import type { KontenjanTuruAnahtari, ProgramNetSatiri } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
// Yalnız saf modüller: bu bileşen istemcide çalışır, db'ye dokunan
// program-ozellik.ts import EDİLMEZ (kadro toplamı API'den gelir)
import { CINSIYET_KOSULU_METNI, KONTENJAN_TURU_ADI } from "@/lib/ozel-durum";
import { trBaslikDuzeni } from "@/lib/slug";
import { UNITUR_ETIKET } from "@/lib/unitur";
import { cn } from "@/lib/utils";
import { SON_YIL } from "@/lib/veri-yillari";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Ders sırası ÖSYM oturum sırasıyla; max = testteki soru sayısı.
const DERSLER: {
alan: keyof Omit<ProgramNetSatiri, "yil" | "puanTuru" | "tabanPuan" | "obp" | "katsayi">;
etiket: string;
max: number;
}[] = [
{ alan: "tytTrkNet", etiket: "TYT Türkçe", max: 40 },
{ alan: "tytSosNet", etiket: "TYT Sosyal Bilimler", max: 20 },
{ alan: "tytMatNet", etiket: "TYT Matematik", max: 40 },
{ alan: "tytFenNet", etiket: "TYT Fen Bilimleri", max: 20 },
{ alan: "aytMatNet", etiket: "AYT Matematik", max: 40 },
{ alan: "aytFizNet", etiket: "AYT Fizik", max: 14 },
{ alan: "aytKimNet", etiket: "AYT Kimya", max: 13 },
{ alan: "aytBioNet", etiket: "AYT Biyoloji", max: 13 },
{ alan: "aytTdeNet", etiket: "AYT Türk Dili ve Edebiyatı", max: 24 },
{ alan: "aytTrh1Net", etiket: "AYT Tarih-1", max: 10 },
{ alan: "aytCog1Net", etiket: "AYT Coğrafya-1", max: 6 },
{ alan: "aytTrh2Net", etiket: "AYT Tarih-2", max: 11 },
{ alan: "aytCog2Net", etiket: "AYT Coğrafya-2", max: 11 },
{ alan: "aytDinNet", etiket: "AYT Din Kültürü", max: 6 },
{ alan: "aytFelNet", etiket: "AYT Felsefe Grubu", max: 12 },
{ alan: "ydtYdilNet", etiket: "YDT Yabancı Dil", max: 80 },
];
/** Meslek lisesi alan içi ek puanla yerleşmeyi gösteren OBP katsayısı. */
const EK_PUAN_KATSAYISI = 0.18;
const KONTENJAN_TURU_SIRASI: KontenjanTuruAnahtari[] = [
"genel",
"okulBirincisi",
"sehitGaziYakini",
"depremzede",
"kadin34",
];
const sayi = (n: number) => n.toLocaleString("tr-TR");
const ondalik = (n: number) =>
n.toLocaleString("tr-TR", { maximumFractionDigits: 2 });
/** Satır kimlik hapı (DESIGN.md §4.12 ölçüsü, link değil). */
const HAP =
"inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-sm font-semibold text-slate-800";
const HAP_IKON = "size-4 shrink-0 text-primary";
const BOLUM_BASLIK = "mt-7 font-heading text-base font-bold text-slate-900";
const NOT = "mt-2 text-sm leading-relaxed text-slate-500";
type Durum = "bekliyor" | "yukleniyor" | "hazir" | "hata";
export function ProgramAyrintiButonu({
programId,
isim,
universite,
className = "",
varyant = "ikon",
}: {
programId: string;
isim: string;
universite?: string;
className?: string;
/** ikon: satır düğmesi (yuvarlak); metin: açık satır içindeki metin düğmesi ("Haritada gör" ile aynı kalıp) */
varyant?: "ikon" | "metin";
}) {
const [durum, setDurum] = useState<Durum>("bekliyor");
const [veri, setVeri] = useState<ProgramAyrintisi | null>(null);
async function yukle() {
setDurum("yukleniyor");
try {
const res = await fetch(`/api/program-ayrinti?id=${programId}`);
if (!res.ok) throw new Error(String(res.status));
setVeri((await res.json()) as ProgramAyrintisi);
setDurum("hazir");
} catch {
setDurum("hata");
}
}
function acildi(acik: boolean) {
if (!acik) return;
olay("program_ayrinti_goruntulendi");
if (durum === "bekliyor" || durum === "hata") yukle();
}
return (
<Drawer onOpenChange={acildi} handleOnly>
<DrawerTrigger asChild>
{varyant === "metin" ? (
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={`inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-sm font-semibold text-primary transition-colors duration-150 hover:text-orange-500 ${className}`}
>
<Info className="size-4" aria-hidden />
Program ayrıntısı
<span className="sr-only"> — {isim}</span>
</button>
) : (
<button
type="button"
title="Program ayrıntısı"
onClick={(e) => e.stopPropagation()}
className={`inline-flex ${SATIR_DUGME_BOYU} cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
>
<Info className="size-4" aria-hidden />
<span className="sr-only">{isim} — program ayrıntısı</span>
</button>
)}
</DrawerTrigger>
{/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
<DrawerContent
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{isim}
</DrawerTitle>
<DrawerDescription className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
{veri
? [
trBaslikDuzeni(veri.program.universite),
veri.program.il ? trBaslikDuzeni(veri.program.il) : null,
veri.program.fakulte,
]
.filter(Boolean)
.join(" · ")
: (universite ?? "Programın tüm açık verisi")}
</DrawerDescription>
<div className="mt-5">
<AyrintiIcerik durum={durum} veri={veri} />
</div>
</div>
</DrawerContent>
</Drawer>
);
}
function AyrintiIcerik({ durum, veri }: { durum: Durum; veri: ProgramAyrintisi | null }) {
if (durum === "yukleniyor" || durum === "bekliyor" || (durum === "hazir" && !veri)) {
return (
<div className="space-y-2" aria-hidden>
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-lg bg-slate-100" />
))}
</div>
);
}
if (durum === "hata" || !veri) {
return (
<p className="py-4 text-center text-base text-slate-500">
Ayrıntı yüklenemedi. Paneli kapatıp yeniden açmayı dene.
</p>
);
}
return (
<>
<Kunye veri={veri} />
<YillaraGore veri={veri} />
<KontenjanTurleriBolumu veri={veri} />
<h3 className={BOLUM_BASLIK}>Son yerleşenin netleri</h3>
<p className={NOT}>
Bölüme en düşük puanla yerleşen adayın sınav netleri (YÖK Atlas).
</p>
<div className="mt-3">
<NetlerIcerik netler={veri.netler} />
</div>
<Kosullar veri={veri} />
<Istihdam veri={veri} />
<UniversiteOzeti veri={veri} />
<p className="mt-7 border-t border-slate-100 pt-3 text-xs leading-5 text-slate-500">
Kaynaklar: YÖK Atlas (yerleştirme, kontenjan, netler, koşullar; {SON_YIL}{" "}
yerleştirmesi), TÜİK (mezun istihdamı), URAP ve YÖK İstatistik (üniversite).
</p>
</>
);
}
function Kunye({ veri }: { veri: ProgramAyrintisi }) {
const o = veri.ozellik;
const haplar: { ikon: typeof Languages; metin: string }[] = [];
const tur = veri.program.unitur ? UNITUR_ETIKET[veri.program.unitur]?.cip : null;
if (veri.program.sure) {
haplar.push({
ikon: Clock,
metin: `${veri.program.sure} yıl${tur ? ` · ${tur}` : ""}`,
});
}
if (o?.ogrenimDili) haplar.push({ ikon: Languages, metin: o.ogrenimDili });
if (o?.ogrenimTuru && o.ogrenimTuru !== "Örgün Öğretim") {
haplar.push({ ikon: Monitor, metin: o.ogrenimTuru });
}
if (o?.bursOrani) {
const ucret =
o.ucret != null && o.bursOrani !== "Burslu" ? ` · yıllık ${sayi(o.ucret)} TL` : "";
haplar.push({ ikon: Wallet, metin: `${o.bursOrani}${ucret}` });
}
if (o?.akreditasyon) haplar.push({ ikon: BadgeCheck, metin: `${o.akreditasyon} akreditasyonlu` });
if (o?.minBasariSirasi != null) {
haplar.push({ ikon: Flag, metin: `Baraj ${sayi(o.minBasariSirasi)}. sıra` });
}
const uye = veri.ogretimUyesiToplam;
if (uye != null) haplar.push({ ikon: GraduationCap, metin: `${sayi(uye)} öğretim üyesi` });
if (o?.cinsiyetKosulu) {
const m = CINSIYET_KOSULU_METNI[o.cinsiyetKosulu];
haplar.push({ ikon: Users, metin: m.charAt(0).toLocaleUpperCase("tr-TR") + m.slice(1) });
}
if (o?.ilce) haplar.push({ ikon: MapPin, metin: `${trBaslikDuzeni(o.ilce)} kampüsü` });
if (haplar.length === 0) return null;
return (
<ul className="flex flex-wrap justify-center gap-2">
{haplar.map((h) => (
<li key={h.metin} className={HAP}>
<h.ikon className={HAP_IKON} aria-hidden />
{h.metin}
</li>
))}
</ul>
);
}
function YillaraGore({ veri }: { veri: ProgramAyrintisi }) {
const satirlar = veri.gecmis.filter(
(g) => g.sira != null || g.puan != null || g.kontenjan != null || g.yerlesen != null,
);
if (satirlar.length === 0) return null;
const sonYil = satirlar[0].yil;
const hucre = (yil: number) =>
cn(
"py-2.5 text-right text-sm tabular-nums",
yil === sonYil ? "font-semibold text-slate-800" : "text-slate-500",
);
return (
<>
<h3 className={BOLUM_BASLIK}>Yıllara göre</h3>
<p className={NOT}>
Taban sırası (ve geniş ekranda puanı) o yıl yerleşen son adayınki;
kontenjan ve yerleşen o yılın yerleştirmesi. Sırası boş yıl: program o
yıl dolmadı ya da yoktu.
</p>
<Table className="mt-3">
<TableHeader>
<TableRow>
<TableHead className={cn(SATIR_BASLIK, "pl-0")}>Yıl</TableHead>
<TableHead className={cn(SATIR_BASLIK, "text-right")}>Taban sıra</TableHead>
{/* Telefonda beş sütun 375 px'e sığmıyor (yatay kaydırma yok, §5.2.3): puan sm'den itibaren */}
<TableHead className={cn(SATIR_BASLIK, "hidden text-right sm:table-cell")}>Puan</TableHead>
<TableHead className={cn(SATIR_BASLIK, "text-right")}>Kontenjan</TableHead>
<TableHead className={cn(SATIR_BASLIK, "text-right")}>Yerleşen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{satirlar.map((g) => (
<TableRow key={g.yil}>
<TableCell
className={cn(
"py-2.5 pl-0 text-sm tabular-nums",
g.yil === sonYil ? "font-semibold text-slate-900" : "text-slate-700",
)}
>
{g.yil}
</TableCell>
<TableCell className={hucre(g.yil)}>{g.sira != null ? sayi(g.sira) : "—"}</TableCell>
<TableCell className={cn(hucre(g.yil), "hidden sm:table-cell")}>{g.puan != null ? ondalik(g.puan) : "—"}</TableCell>
<TableCell className={hucre(g.yil)}>{g.kontenjan != null ? sayi(g.kontenjan) : "—"}</TableCell>
<TableCell className={hucre(g.yil)}>{g.yerlesen != null ? sayi(g.yerlesen) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
}
function KontenjanTurleriBolumu({ veri }: { veri: ProgramAyrintisi }) {
const k = veri.kontenjanTurleri;
if (!k) return null;
const satirlar = KONTENJAN_TURU_SIRASI.filter((t) => (k[t].kontenjan ?? 0) > 0);
if (satirlar.length === 0) return null;
const ozelVar = satirlar.some((t) => t !== "genel");
return (
<>
<h3 className={BOLUM_BASLIK}>Kontenjan türleri ({k.yil})</h3>
<p className={NOT}>
{ozelVar
? "ÖSYM okul birincileri, şehit ve gazi yakınları, depremzedeler ve 34 yaş üstü kadınlar için ayrı kontenjan ayırır; bu kontenjanlar yalnız o durumdaki adaylara açıktır ve her birinin taban sırası ayrı yayımlanır — o sıra burada yok."
: "Bu programda yalnız genel kontenjan var."}
</p>
<dl className="mt-3 grid gap-x-6 gap-y-2 sm:grid-cols-2">
{satirlar.map((t) => (
<div key={t} className="flex items-baseline justify-between gap-3 border-b border-slate-100 py-2">
<dt className="text-sm text-slate-700">{KONTENJAN_TURU_ADI[t]}</dt>
<dd className="text-right text-sm tabular-nums text-slate-900">
<span className="font-semibold">{sayi(k[t].kontenjan ?? 0)}</span>
<span className="text-slate-500">
{" "}
kontenjan · {k[t].yerlesen != null ? sayi(k[t].yerlesen) : "—"} yerleşen
</span>
</dd>
</div>
))}
</dl>
</>
);
}
function NetlerIcerik({ netler }: { netler: ProgramNetSatiri[] }) {
if (netler.length === 0) {
return (
<p className="py-2 text-base text-slate-500">
Bu program için YÖK Atlas&apos;ta net verisi yayınlanmamış.
</p>
);
}
const dersler = DERSLER.filter((d) => netler.some((y) => y[d.alan] != null));
// En yeni yıl vurgulu (§1.1 "sık bakılan önde"); eski yıllar bağlam.
const sonYil = Math.max(...netler.map((y) => y.yil));
const ekPuanli = netler.some((y) => y.katsayi != null && y.katsayi >= EK_PUAN_KATSAYISI);
const hucre = (yil: number, vurgu = false) =>
cn(
"py-2.5 text-right text-sm tabular-nums",
yil === sonYil
? vurgu
? "font-semibold text-slate-900"
: "font-semibold text-slate-800"
: "text-slate-500",
);
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className={cn(SATIR_BASLIK, "pl-0")}>Ders</TableHead>
{netler.map((y) => (
<TableHead
key={y.yil}
className={cn(
SATIR_BASLIK,
"text-right tabular-nums",
y.yil === sonYil && "text-slate-900",
)}
>
{y.yil}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{dersler.map((d) => (
<TableRow key={d.alan}>
{/* Ders adı sarılır: 375 px'te üç yıl sütunu + "AYT Türk Dili ve
Edebiyatı / 24" tek satırda 343 px'lik kaba sığmıyor, tablo
yatay kayıyordu (6 Eki 2026 QA). Sayı hücreleri nowrap kalır. */}
<TableCell className="py-2.5 pl-0 pr-3 text-sm leading-snug whitespace-normal text-slate-700">
{d.etiket}
<span className="text-slate-400"> / {d.max}</span>
</TableCell>
{netler.map((y) => {
const deger = y[d.alan];
return (
<TableCell key={y.yil} className={hucre(y.yil)}>
{deger != null ? ondalik(deger) : "—"}
</TableCell>
);
})}
</TableRow>
))}
<TableRow>
<TableCell className="py-2.5 pl-0 text-sm text-slate-500">OBP</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2.5 text-right text-sm tabular-nums text-slate-500"
>
{y.obp != null ? ondalik(y.obp) : "—"}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="py-2.5 pl-0 text-sm text-slate-500">OBP katsayısı</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2.5 text-right text-sm tabular-nums text-slate-500"
>
{y.katsayi != null ? ondalik(y.katsayi) : "—"}
</TableCell>
))}
</TableRow>
<TableRow className="border-t border-slate-200">
<TableCell className="py-3 pl-0 text-sm font-semibold text-slate-900">
Yerleşme puanı
</TableCell>
{netler.map((y) => (
<TableCell key={y.yil} className={hucre(y.yil, true)}>
{y.tabanPuan != null ? ondalik(y.tabanPuan) : "—"}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
{ekPuanli ? (
<p className={NOT}>
OBP katsayısı {ondalik(EK_PUAN_KATSAYISI)} olan yılda son yerleşen,
meslek lisesi alan içi ek puanıyla yerleşmiş; standart katsayı 0,12.
</p>
) : null}
</>
);
}
function Kosullar({ veri }: { veri: ProgramAyrintisi }) {
if (veri.kosullar.length === 0) return null;
return (
<details className="group mt-7">
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 font-heading text-base font-bold text-slate-900 transition-colors hover:text-orange-500 [&::-webkit-details-marker]:hidden">
Yerleşme koşulları ({veri.kosullar.length})
<span className="text-sm font-semibold text-slate-500 group-open:hidden">Göster</span>
<span className="hidden text-sm font-semibold text-slate-500 group-open:inline">Gizle</span>
</summary>
<ol className="mt-2 space-y-3 text-sm leading-relaxed text-slate-600">
{veri.kosullar.map((k) => (
<li key={k.kod} className="flex gap-3">
<span className="shrink-0 tabular-nums text-slate-400">{k.kod}</span>
<span>{k.metin}</span>
</li>
))}
</ol>
<p className={NOT}>
Koşul metinleri ÖSYM tercih kılavuzundan; tercih göndermeden önce güncel
kılavuzdan doğrula.
</p>
</details>
);
}
function Istihdam({ veri }: { veri: ProgramAyrintisi }) {
const v = veri.istihdam;
if (!v) return null;
const parcalar = [
v.istihdam != null ? `mezunların %${ondalik(v.istihdam)}'i kayıtlı çalışıyor` : null,
v.isBulmaAy != null ? `ilk iş ortalama ${ondalik(v.isBulmaAy)} ayda` : null,
v.kazanc ? `kazanç grubu ${v.kazanc.toLocaleLowerCase("tr-TR")}` : null,
].filter(Boolean);
if (parcalar.length === 0) return null;
const ort = v.ortalama.istihdam != null ? ` Türkiye ortalaması %${ondalik(v.ortalama.istihdam)}.` : "";
return (
<>
<h3 className={BOLUM_BASLIK}>Mezun istihdamı</h3>
<p className={NOT}>
{parcalar.join(", ")} (TÜİK {v.yil}, ülke geneli, {v.duzey === "lisans" ? "lisans" : "ön lisans"}{" "}
mezunları; üniversiteye göre değişir, kırılım yok).{ort}
</p>
</>
);
}
function UniversiteOzeti({ veri }: { veri: ProgramAyrintisi }) {
const u = veri.universite;
if (!u || (!u.urap && !u.istatistik)) return null;
const parcalar = [
u.urap ? `URAP araştırma sıralaması ${u.urap.sira}/${u.urap.toplam} (${u.urap.donem})` : null,
u.istatistik
? `öğretim üyesi başına ${sayi(u.istatistik.hocaBasinaOgrenci)} öğrenci (sayfası olan üniversitelerin medyanı ${sayi(u.istatistik.medyan)}; YÖK İstatistik ${u.istatistik.donem})`
: null,
].filter(Boolean);
return (
<>
<h3 className={BOLUM_BASLIK}>Üniversite</h3>
<p className={NOT}>
{parcalar.join(" · ")}.{" "}
<a
href={`/universite/${u.slug}`}
className="font-semibold text-primary transition-colors hover:text-orange-500 hover:underline"
>
Üniversite sayfası
</a>
</p>
</>
);
}

View File

@@ -0,0 +1,68 @@
import Link from "next/link";
import type { ReactNode } from "react";
import {
ortaTabanYonu,
YON_ETIKET,
type TabanKumesi,
type TabanDuzeyi,
type TabanOzeti,
} from "@/lib/taban-ozeti";
import { kisaSayi } from "@/components/ozet-kart";
/**
* Üniversite ve bölüm sayfasının özet kartlarının ortak metin ve link
* yardımcıları (DESIGN.md §4.8). Yıl yıl ayrıntı çekmecesi ("Yıllara göre
* taban") 6 Eki 2026'da Bilal'in isteğiyle şimdilik kaldırıldı; geri gelirse
* git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor.
*/
/** "2026'da" — yıl eki ünlü uyumuna göre (2023'te, 2026'da, 2027'de). */
export function yilEki(yil: number): string {
const son = yil % 10;
if (son === 3 || son === 4 || son === 5) return `${yil}'te`;
if (son === 0 || son === 6 || son === 9) return `${yil}'da`;
return `${yil}'de`;
}
/** Orta taban kümesinin adı: "Devlet" / "Lisans" / "Önlisans". */
export function kumeAdi(kume: TabanKumesi, duzey: TabanDuzeyi): string {
if (kume === "devlet") return "Devlet";
return duzey === "lisans" ? "Lisans" : "Önlisans";
}
/** Özet kartının açıklaması: "Devlet · 2021'de 105 bin · gevşedi". */
export function ortaTabanAciklamasi(taban: TabanOzeti): string {
const yon = ortaTabanYonu(taban.ortaTaban);
const kume = kumeAdi(taban.kume, taban.duzey);
if (!yon) {
return taban.ortaTabanAdet > 0
? `${kume} · ${taban.ortaTabanAdet.toLocaleString("tr-TR")} programın ortası`
: "henüz taban oluşmadı";
}
return `${kume} · ${yilEki(yon.ilkYil)} ${kisaSayi(yon.ilk)} · ${YON_ETIKET[yon.yon]}`;
}
/** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
export function KartBaglantisi({
href,
children,
}: {
href: string | null;
children: ReactNode;
}) {
if (!href) return <>{children}</>;
return (
<Link
href={href}
className="font-medium text-primary transition-colors hover:text-orange-500 hover:underline"
>
{children}
</Link>
);
}
/** "Koç Üniversitesi" → "Koç Üni.", "… Meslek Yüksekokulu" → "… MYO" — kart açıklaması kısa kalsın. */
export function kisaUniAdi(ad: string): string {
// \b kullanılmaz: JS'te ASCII sınırıdır, "Ü" önünde sözcük sınırı saymaz.
return ad.replace("Üniversitesi", "Üni.").replace("Meslek Yüksekokulu", "MYO");
}

View File

@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Armchair,
ArrowRight,
Building2,
ChartLine,
ChevronRight,
Landmark,
LayoutList,
@@ -54,7 +56,9 @@ import { UniLogo } from "@/components/uni-logo";
import { UrapLogo } from "./urap-logo";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { NavBaslik } from "./nav-baslik";
import { OzetKart } from "@/components/ozet-kart";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti";
import { bolumBazAdi } from "@/lib/slug";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
import type { Program } from "@/types/yokatlas";
@@ -468,6 +472,7 @@ export function UniversiteIcerik({
}
const ilkSayfa = sayfa === 1;
const { taban } = uni;
// Tür iki yerde geçer ve bu bilinçli: H1'in altındaki çip taranmak,
// özet paragrafın ilk virgülündeki öbek okunmak içindir. Izgarada "Tür"
// kartı yok (şablon 4 hücre, bkz. aşağıdaki ızgara yorumu). Haritada
@@ -671,39 +676,75 @@ export function UniversiteIcerik({
<div className="relative isolate mt-8 grid grid-cols-2 gap-4 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
{/* Şablon tam 4 hücre bırakıyor: masaüstünde harita 3. sütunu
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Fakülte
sayısı Program kartının alt satırında; boşalan hücrede
Türkiye sırası. Geri kalan akademik ayrıntı alttaki
"Akademik göstergeler ›" penceresinde. */}
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
program/kontenjan adedi ilgi çekmiyor): en seçici bölüm
adıyla, orta tabanın yıllar içindeki seyri, doluluk ve
Türkiye sırası. Program, birim ve kontenjan sayıları özet
paragrafta. Hesap lib/taban-ozeti.ts. */}
<dl className="contents">
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(uni.programSayisi)}
alt={`${sayi(uni.fakulteSayisi)} birimde`}
/>
<OzetKart
ikon={Users}
etiket="Kontenjan"
deger={sayi(uni.stats.toplamKontenjan)}
alt={`${SON_YIL} toplamı`}
/>
<OzetKart
ikon={Target}
etiket="En iyi sıralama"
deger={
uni.stats.minSira != null ? sayi(uni.stats.minSira) : "—"
etiket="En seçici bölüm"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
alt={
taban.enSecici ? (
<>
<KartBaglantisi
href={(() => {
const s = bolumSlugFromIsim(taban.enSecici.isim);
return s ? `/bolum/${s}` : null;
})()}
>
{bolumBazAdi(taban.enSecici.isim)}
</KartBaglantisi>{" "}
· {SON_YIL} taban sırası
</>
) : (
"henüz taban oluşmadı"
)
}
alt={`${SON_YIL} taban sırası`}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban]
.reverse()
.find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
/>
{taban.doluluk ? (
<OzetKart
ikon={Armchair}
etiket="Doluluk"
deger={`%${taban.doluluk.yuzde}`}
alt={
taban.doluluk.bosKoltuk > 0
? `${sayi(taban.doluluk.kontenjan)} kontenjandan ${sayi(taban.doluluk.bosKoltuk)} koltuk boş kaldı · ${SON_YIL}`
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
}
/>
) : (
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(uni.programSayisi)}
alt={`${sayi(uni.fakulteSayisi)} birimde`}
/>
)}
{uni.urap ? (
<TurkiyeSirasiKarti sira={uni.urap.sira} />
) : (
<OzetKart
ikon={Building2}
etiket="Fakülte / birim"
deger={sayi(uni.fakulteSayisi)}
alt="programların dağıldığı"
ikon={Users}
etiket="Kontenjan"
deger={sayi(uni.stats.toplamKontenjan)}
alt={`${SON_YIL} toplamı`}
/>
)}
</dl>

View File

@@ -22,7 +22,7 @@ import {
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import { ProgramAyrintiButonu } from "./program-ayrinti";
import {
Table,
TableBody,
@@ -79,7 +79,7 @@ export function UniversiteProgramTablosu({
Taban sıra
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
<span className="sr-only">Ayrıntı ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -141,7 +141,7 @@ export function UniversiteProgramTablosu({
</TableCell>
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
<ProgramAyrintiButonu
programId={p.id}
isim={p.isim.trim()}
/>