Admin Müşteri Detay Sayfası & Global Arama — Implementation Plan
DerinFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Admin panelindeki müşteri yönetimini 10 sekmeli detay sayfasına çevir (Özet/Servis/Fatura/E-posta/Ticket/Aktivite/Oturum/Dosya/Abonelik/Portal Kurulum), liste sayfasına debounced URL arama ekle, admin layout’una Cmd+K global command palette mount et.
Architecture: Next.js App Router nested layout.tsx + child page.tsx+loading.tsx+error.tsx. SSR ile Prisma’dan veri çekilir. Layout’ta tek findUnique + _count aggregat sorgu N+1’i önler. Arama URL param + ILIKE OR ile SSR re-fetch tetikler. Cmd+K için cmdk paketi headless palette, dedicated /api/admin/search endpoint.
Tech Stack: Next.js 15 App Router · Prisma · PostgreSQL · TypeScript · Tailwind + Industrial Design System primitives · cmdk package · node --test + tsx test runner.
Spec: docs/superpowers/specs/2026-05-11-admin-customer-detail-tabs-design.md (commit 0b6214c)
Çalışma dizini: /var/www/vhosts/ecutuningportal.com/httpdocs/
Faz Özeti
Bölüm başlığı “Faz Özeti”| Faz | Kapsam | Task Sayısı |
|---|---|---|
| 1 | Foundation: ortak template’ler + test helper | 3 |
| 2 | [id]/layout.tsx + müşteri kartı + tab bar | 4 |
| 3 | Overview tab + Services refactor | 3 |
| 4 | Yüksek değerli tab’lar (Invoices, Emails, Tickets, Activity) | 4 |
| 5 | Kalan tab’lar (Sessions, Files, Subscriptions, Installations) | 4 |
| 6 | Liste sayfası arama (?q=) | 3 |
| 7 | Cmd+K palette + /api/admin/search | 4 |
| 8 | Session revoke endpoint + UI | 2 |
| 9 | i18n çevirileri (24 dil, 4+ paralel agent) | 1 (dispatch) |
Faz 1: Foundation
Bölüm başlığı “Faz 1: Foundation”Task 1: Ortak loading & error template’leri
Bölüm başlığı “Task 1: Ortak loading & error template’leri”Files:
- Create:
components/admin/CustomerLoadingState.tsx - Create:
components/admin/CustomerErrorState.tsx
Amaç: Tüm customer route’larda kullanılacak DRY loading & error bileşenleri. Her loading.tsx ve error.tsx bu component’leri çağıracak.
- Step 1: Loading bileşenini yaz
Create components/admin/CustomerLoadingState.tsx:
import { Loader2 } from 'lucide-react';
export default function CustomerLoadingState({ label }: { label?: string }) { return ( <div className="p-6 flex flex-col items-center justify-center min-h-[400px] gap-3"> <Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--brand-red)' }} /> <p className="font-mono t-4" style={{ fontSize: '11px', letterSpacing: '0.08em' }}> {label ?? 'YÜKLENİYOR...'} </p> </div> );}- Step 2: Error bileşenini yaz
Create components/admin/CustomerErrorState.tsx:
'use client';
import { AlertCircle } from 'lucide-react';import { TechCard } from '@/components/admin/design-system';
interface Props { error: Error; reset: () => void; label?: string;}
export default function CustomerErrorState({ error, reset, label }: Props) { return ( <div className="p-6"> <TechCard className="p-12 text-center"> <AlertCircle className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--brand-red)' }} /> <p className="font-tech t-1 mb-2" style={{ fontSize: '14px' }}> {label ?? 'YÜKLENEMEDİ'} </p> <p className="t-3 text-[12px] mb-4 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> {error.message} </p> <button onClick={reset} className="btn-wrap btn--primary btn--sm"> <span className="btn-inner">Tekrar Dene</span> </button> </TechCard> </div> );}- Step 3: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep -E "CustomerLoadingState|CustomerErrorState" | head -5
Expected: hiç çıktı yok (hata yok).
- Step 4: Commit
git add components/admin/CustomerLoadingState.tsx components/admin/CustomerErrorState.tsxgit commit -m "feat(admin): müşteri yönetimi için ortak loading/error bileşenleri"Task 2: Liste sayfası loading.tsx + error.tsx
Bölüm başlığı “Task 2: Liste sayfası loading.tsx + error.tsx”Files:
-
Create:
app/[locale]/admin/(panel)/customers/loading.tsx -
Create:
app/[locale]/admin/(panel)/customers/error.tsx -
Step 1: Loading dosyası
Create app/[locale]/admin/(panel)/customers/loading.tsx:
import CustomerLoadingState from '@/components/admin/CustomerLoadingState';
export default function Loading() { return <CustomerLoadingState label="MÜŞTERİLER YÜKLENİYOR..." />;}- Step 2: Error dosyası
Create app/[locale]/admin/(panel)/customers/error.tsx:
'use client';import CustomerErrorState from '@/components/admin/CustomerErrorState';
export default function Error({ error, reset }: { error: Error; reset: () => void }) { return <CustomerErrorState error={error} reset={reset} label="MÜŞTERİ LİSTESİ YÜKLENEMEDİ" />;}- Step 3: Browser doğrulama
Tarayıcıda http://49.12.188.137:3077/tr/admin/customers aç. Sayfa açılırken ya da hata oluştuğunda loading/error state’ler çalışıyor olmalı. (Manuel: throw atan bir test sayfası yerine geliştirme sırasında verify.)
- Step 4: Commit
git add app/[locale]/admin/(panel)/customers/loading.tsx app/[locale]/admin/(panel)/customers/error.tsxgit commit -m "feat(admin): müşteri listesi loading/error sayfaları"Task 3: Customer search WHERE builder helper + test
Bölüm başlığı “Task 3: Customer search WHERE builder helper + test”Files:
- Create:
lib/admin-customer-search.ts - Create:
lib/admin-customer-search.test.ts
Amaç: Listede arama + Cmd+K palette’in paylaşacağı saf fonksiyon. TDD ile yaz.
- Step 1: Failing test yaz
Create lib/admin-customer-search.test.ts:
import { test } from 'node:test';import assert from 'node:assert/strict';import { buildCustomerSearchWhere } from './admin-customer-search';
test('boş sorgu için boş object döner', () => { const where = buildCustomerSearchWhere(''); assert.deepEqual(where, {});});
test('whitespace-only sorgu boş object döner', () => { const where = buildCustomerSearchWhere(' '); assert.deepEqual(where, {});});
test('tek kelime sorgu için 8 alanlı OR clause döner', () => { const where = buildCustomerSearchWhere('acme'); assert.ok(Array.isArray(where.OR)); assert.equal(where.OR.length, 8); const fields = where.OR.map((c: any) => Object.keys(c)[0]); assert.deepEqual(fields.sort(), [ 'billingCity', 'company', 'countryIso', 'email', 'name', 'phone', 'stripeCustomerId', 'vatId', ]);});
test('her OR clause case-insensitive contains kullanır', () => { const where = buildCustomerSearchWhere('foo'); for (const clause of where.OR) { const field = Object.keys(clause)[0]; assert.deepEqual(clause[field], { contains: 'foo', mode: 'insensitive' }); }});
test('sorgu trim edilir', () => { const where = buildCustomerSearchWhere(' acme '); assert.equal(where.OR[0][Object.keys(where.OR[0])[0]].contains, 'acme');});- Step 2: Run test, FAIL bekle
Run: npm test -- lib/admin-customer-search.test.ts 2>&1 | tail -20
Expected: FAIL — Cannot find module './admin-customer-search'
- Step 3: Minimal implementation
Create lib/admin-customer-search.ts:
import type { Prisma } from '@prisma/client';
const SEARCH_FIELDS = [ 'name', 'email', 'company', 'phone', 'vatId', 'stripeCustomerId', 'billingCity', 'countryIso',] as const;
export function buildCustomerSearchWhere(q: string): Prisma.CustomerWhereInput { const trimmed = q.trim(); if (!trimmed) return {}; return { OR: SEARCH_FIELDS.map((field) => ({ [field]: { contains: trimmed, mode: 'insensitive' as const }, })), };}- Step 4: Run test, PASS bekle
Run: npm test -- lib/admin-customer-search.test.ts 2>&1 | tail -20
Expected: 5 PASS, 0 FAIL.
- Step 5: Commit
git add lib/admin-customer-search.ts lib/admin-customer-search.test.tsgit commit -m "feat(admin): müşteri arama WHERE clause builder (8 alan OR)"Faz 2: [id]/layout.tsx + Müşteri Kartı + Tab Bar
Bölüm başlığı “Faz 2: [id]/layout.tsx + Müşteri Kartı + Tab Bar”Task 4: CustomerDetailTabs client component
Bölüm başlığı “Task 4: CustomerDetailTabs client component”Files:
- Create:
components/admin/CustomerDetailTabs.tsx
Amaç: 10 sekmeli tab bar — usePathname() ile aktif tab tespit, count badge’leri ile birlikte.
- Step 1: Component’i yaz
Create components/admin/CustomerDetailTabs.tsx:
'use client';
import { usePathname } from 'next/navigation';import { Link } from '@/i18n/navigation';
export interface CustomerTabCounts { services: number; invoices: number; emails: number; tickets: number; activity: number; sessions: number; files: number; subscriptions: number; installations: number;}
interface TabDef { slug: string; label: string; countKey?: keyof CustomerTabCounts;}
const TABS: TabDef[] = [ { slug: '', label: 'ÖZET' }, { slug: 'services', label: 'SERVİSLER', countKey: 'services' }, { slug: 'invoices', label: 'FATURALAR', countKey: 'invoices' }, { slug: 'emails', label: 'E-POSTA', countKey: 'emails' }, { slug: 'tickets', label: 'TICKET', countKey: 'tickets' }, { slug: 'activity', label: 'AKTİVİTE', countKey: 'activity' }, { slug: 'sessions', label: 'OTURUM', countKey: 'sessions' }, { slug: 'files', label: 'DOSYA', countKey: 'files' }, { slug: 'subscriptions', label: 'ABONELİK', countKey: 'subscriptions' }, { slug: 'installations', label: 'KURULUM', countKey: 'installations' },];
interface Props { customerId: number; counts: CustomerTabCounts;}
export default function CustomerDetailTabs({ customerId, counts }: Props) { const pathname = usePathname() ?? ''; const base = `/admin/customers/${customerId}`;
// pathname'den locale prefix'i sıyır, son segmenti al const pathAfterBase = pathname.split(`/customers/${customerId}`)[1] ?? ''; const activeSlug = pathAfterBase.replace(/^\//, '').split('/')[0] ?? '';
return ( <div className="flex items-center gap-1 overflow-x-auto" style={{ borderBottom: '1px solid var(--border-hairline)' }} > {TABS.map((tab) => { const isActive = activeSlug === tab.slug; const href = tab.slug ? `${base}/${tab.slug}` : base; const count = tab.countKey ? counts[tab.countKey] : null;
return ( <Link key={tab.slug || 'overview'} href={href as any} className="font-tech px-3 py-2 transition-colors whitespace-nowrap" style={{ fontSize: '11px', letterSpacing: '0.08em', color: isActive ? 'var(--brand-red)' : 'var(--fg-3)', borderBottom: isActive ? '2px solid var(--brand-red)' : '2px solid transparent', marginBottom: '-1px', }} > {tab.label} {count !== null && ( <span className="font-mono ml-1.5" style={{ fontSize: '10px', color: 'var(--fg-4)' }} > {count} </span> )} </Link> ); })} </div> );}- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "CustomerDetailTabs" | head -5
Expected: çıktı yok.
- Step 3: Commit
git add components/admin/CustomerDetailTabs.tsxgit commit -m "feat(admin): müşteri detay 10-sekmeli tab bar bileşeni"Task 5: CustomerSummaryCard component
Bölüm başlığı “Task 5: CustomerSummaryCard component”Files:
- Create:
components/admin/CustomerSummaryCard.tsx
Amaç: Layout’un başında müşteri özet kartı — avatar, kimlik, status badge’ler, aksiyon butonları.
- Step 1: Component’i yaz
Create components/admin/CustomerSummaryCard.tsx:
import { Link } from '@/i18n/navigation';import { Pencil, Trash2, Mail, Power } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';import DeleteCustomerButton from '@/components/admin/DeleteCustomerButton';
interface Customer { id: number; name: string; email: string; phone: string | null; company: string | null; countryIso: string | null; locale: string; isActive: boolean; emailVerifiedAt: Date | null; totpEnabled: boolean; createdAt: Date; updatedAt: Date;}
interface Props { customer: Customer; lastLoginAt: Date | null;}
function initials(name: string): string { return name.split(' ').map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase();}
function flag(iso: string | null): string { if (!iso || iso.length !== 2) return ''; const A = 0x1f1e6; return String.fromCodePoint(A + iso.toUpperCase().charCodeAt(0) - 65, A + iso.toUpperCase().charCodeAt(1) - 65);}
export default function CustomerSummaryCard({ customer, lastLoginAt }: Props) { const grad = 'linear-gradient(135deg,var(--brand-red),var(--brand-red-dark))';
return ( <TechCard className="p-5 mb-4"> <div className="flex items-start justify-between gap-4"> <div className="flex items-start gap-4 min-w-0 flex-1"> {/* Avatar */} <div className="w-14 h-14 rounded-full flex items-center justify-center text-white font-tech shrink-0" style={{ background: grad, fontSize: '16px', fontWeight: 700 }} > {initials(customer.name)} </div>
{/* Identity column */} <div className="min-w-0 flex-1"> <div className="flex items-center gap-2 flex-wrap mb-1"> <p className="font-semibold t-1" style={{ fontSize: '16px' }}> {customer.name} </p> {customer.isActive ? ( <IndustrialBadge tone="teal" dot>AKTİF</IndustrialBadge> ) : ( <IndustrialBadge tone="neutral">PASİF</IndustrialBadge> )} {customer.emailVerifiedAt && ( <IndustrialBadge tone="teal">EMAIL ✓</IndustrialBadge> )} {customer.totpEnabled && ( <IndustrialBadge tone="blue">2FA</IndustrialBadge> )} {customer.countryIso && ( <span style={{ fontSize: '18px' }}>{flag(customer.countryIso)}</span> )} <IndustrialBadge tone="neutral">{customer.locale.toUpperCase()}</IndustrialBadge> </div>
<div className="flex items-center gap-3 flex-wrap mb-2"> <span className="font-mono t-3" style={{ fontSize: '11px' }}> {customer.email} </span> {customer.phone && ( <span className="font-mono t-4" style={{ fontSize: '11px' }}> · {customer.phone} </span> )} {customer.company && ( <span className="t-3" style={{ fontSize: '12px' }}> · {customer.company} </span> )} </div>
<div className="flex items-center gap-3 flex-wrap"> <span className="font-mono t-4" style={{ fontSize: '10px', letterSpacing: '0.08em' }}> ID #{String(customer.id).padStart(5, '0')} </span> <span className="font-mono t-4" style={{ fontSize: '10px' }}> KAYIT: {customer.createdAt.toISOString().slice(0, 10)} </span> {lastLoginAt && ( <span className="font-mono t-4" style={{ fontSize: '10px' }}> SON LOGIN: {lastLoginAt.toISOString().slice(0, 16).replace('T', ' ')} </span> )} </div> </div> </div>
{/* Actions */} <div className="flex items-center gap-2 shrink-0"> <Link href={`/admin/customers/${customer.id}/edit` as any} className="btn-wrap btn--ghost btn--sm" title="Düzenle" > <span className="btn-inner"> <Pencil className="w-3.5 h-3.5" /> DÜZENLE </span> </Link> <DeleteCustomerButton id={customer.id} name={customer.name} /> </div> </div> </TechCard> );}- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "CustomerSummaryCard" | head -5
Expected: çıktı yok.
- Step 3: Commit
git add components/admin/CustomerSummaryCard.tsxgit commit -m "feat(admin): müşteri detay üst özet kartı (kimlik+rozetler+aksiyon)"Task 6: [id]/layout.tsx + [id]/loading.tsx + [id]/error.tsx
Bölüm başlığı “Task 6: [id]/layout.tsx + [id]/loading.tsx + [id]/error.tsx”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/layout.tsx -
Create:
app/[locale]/admin/(panel)/customers/[id]/loading.tsx -
Create:
app/[locale]/admin/(panel)/customers/[id]/error.tsx -
Step 1: Layout dosyası
Create app/[locale]/admin/(panel)/customers/[id]/layout.tsx:
import { validateAdminSession } from '@/lib/admin-session';import prisma from '@/lib/prisma';import { redirect, notFound } from 'next/navigation';import { Link } from '@/i18n/navigation';import { ArrowLeft } from 'lucide-react';import CustomerSummaryCard from '@/components/admin/CustomerSummaryCard';import CustomerDetailTabs, { type CustomerTabCounts } from '@/components/admin/CustomerDetailTabs';
interface LayoutProps { params: Promise<{ id: string }>; children: React.ReactNode;}
export default async function CustomerDetailLayout({ params, children }: LayoutProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id); if (!Number.isFinite(customerId)) notFound();
// Tek sorguda 8 relation count + EmailLog ayrı parallel + son session parallel const [customer, emailCount, lastSession] = await Promise.all([ prisma.customer.findUnique({ where: { id: customerId }, include: { _count: { select: { services: true, invoices: true, tickets: true, sessions: true, files: true, subscriptions: true, portalInstallations: true, auditLogs: true, }, }, }, }), prisma.emailLog.count({ where: { customerId } }), prisma.customerSession.findFirst({ where: { customerId }, orderBy: { createdAt: 'desc' }, select: { createdAt: true }, }), ]);
if (!customer) notFound();
const counts: CustomerTabCounts = { services: customer._count.services, invoices: customer._count.invoices, emails: emailCount, tickets: customer._count.tickets, activity: customer._count.auditLogs, sessions: customer._count.sessions, files: customer._count.files, subscriptions: customer._count.subscriptions, installations: customer._count.portalInstallations, };
return ( <div className="p-6"> {/* Back link */} <Link href="/admin/customers" className="inline-flex items-center gap-1 font-mono mb-4 transition-colors" style={{ fontSize: '11px', letterSpacing: '0.08em', color: 'var(--fg-4)', textTransform: 'uppercase', }} > <ArrowLeft className="w-3.5 h-3.5" /> Müşterilere Dön </Link>
<CustomerSummaryCard customer={customer} lastLoginAt={lastSession?.createdAt ?? null} />
<CustomerDetailTabs customerId={customerId} counts={counts} />
<div className="mt-6">{children}</div> </div> );}- Step 2: Loading + Error
Create app/[locale]/admin/(panel)/customers/[id]/loading.tsx:
import CustomerLoadingState from '@/components/admin/CustomerLoadingState';
export default function Loading() { return <CustomerLoadingState label="MÜŞTERİ YÜKLENİYOR..." />;}Create app/[locale]/admin/(panel)/customers/[id]/error.tsx:
'use client';import CustomerErrorState from '@/components/admin/CustomerErrorState';
export default function Error({ error, reset }: { error: Error; reset: () => void }) { return <CustomerErrorState error={error} reset={reset} label="MÜŞTERİ YÜKLENEMEDİ" />;}- Step 3: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep -E "customers/\[id\]/layout|customers/\[id\]/loading|customers/\[id\]/error" | head -5
Expected: çıktı yok.
- Step 4: Browser doğrulama
Henüz [id]/page.tsx yok, sadece /services var. Layout’un services sayfasını sarmaladığını test et:
Tarayıcıda http://49.12.188.137:3077/tr/admin/customers/<bir-id>/services aç. Üstte müşteri kartı + 10 tab görünmeli, alt kısımda mevcut servisler tablosu olmalı.
NOT: Mevcut services sayfası kendi başlık ve “müşterilere dön” link’i bastığı için duplicate görünecek — Task 8’de o sayfa refactor edilirken duplicate kaldırılır.
- Step 5: Commit
git add app/[locale]/admin/(panel)/customers/[id]/layout.tsx app/[locale]/admin/(panel)/customers/[id]/loading.tsx app/[locale]/admin/(panel)/customers/[id]/error.tsxgit commit -m "feat(admin): müşteri detay layout + müşteri kartı + 10-tab bar"Task 7: 9 yeni tab klasörü için loading.tsx + error.tsx şablonu
Bölüm başlığı “Task 7: 9 yeni tab klasörü için loading.tsx + error.tsx şablonu”Files: (her tab için 2 dosya, 9 tab = 18 dosya. Liste:)
- Create:
app/[locale]/admin/(panel)/customers/[id]/invoices/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/invoices/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/emails/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/emails/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/tickets/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/tickets/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/activity/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/activity/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/sessions/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/sessions/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/files/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/files/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/subscriptions/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/subscriptions/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/installations/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/installations/error.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/services/loading.tsx - Create:
app/[locale]/admin/(panel)/customers/[id]/services/error.tsx
(Edit/Create sayfaları ayrı, Task 11/sonrasında.)
Her loading.tsx içeriği:
import CustomerLoadingState from '@/components/admin/CustomerLoadingState';export default function Loading() { return <CustomerLoadingState />;}Her error.tsx içeriği:
'use client';import CustomerErrorState from '@/components/admin/CustomerErrorState';export default function Error({ error, reset }: { error: Error; reset: () => void }) { return <CustomerErrorState error={error} reset={reset} />;}- Step 1: 18 dosyayı yaz
Her dosyada yukarıdaki şablonu kullan — label override etmeye gerek yok, default (“YÜKLENİYOR…” / “YÜKLENEMEDİ”) yeterli.
- Step 2: Dosya sayısı doğrula
Run: find app/\[locale\]/admin/\(panel\)/customers/\[id\] -name "loading.tsx" -o -name "error.tsx" | wc -l
Expected: 22 (9 yeni tab × 2 + services × 2 + [id] kök × 2 = 22). [id]/loading.tsx ve [id]/error.tsx Task 6’da yazılmıştı, find komutu onları da sayar.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/git commit -m "feat(admin): tüm müşteri detay tab'ları için loading/error sayfaları"Faz 3: Overview Tab + Services Refactor
Bölüm başlığı “Faz 3: Overview Tab + Services Refactor”Task 8: Services tab refactor — duplicate header’ı kaldır
Bölüm başlığı “Task 8: Services tab refactor — duplicate header’ı kaldır”Files:
- Modify:
app/[locale]/admin/(panel)/customers/[id]/services/page.tsx
Layout artık müşteri kartı ve “müşterilere dön” link’i bastığı için bu sayfadan onları sil.
- Step 1: Mevcut dosyayı yeniden yaz
Replace app/[locale]/admin/(panel)/customers/[id]/services/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect, notFound } from 'next/navigation';import prisma from '@/lib/prisma';import { Link } from '@/i18n/navigation';import { Pencil, Wrench } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
const statusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { active: 'teal', expired: 'amber', suspended: 'red', cancelled: 'neutral',};
const statusLabel: Record<string, string> = { active: 'AKTİF', expired: 'SÜRESİ DOLDU', suspended: 'ASKIYA ALINDI', cancelled: 'İPTAL',};
export default async function CustomerServicesPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const services = await prisma.service.findMany({ where: { customerId }, include: { serviceType: true }, orderBy: { createdAt: 'desc' }, });
// Customer existence check artık layout'ta — burada services boş olabilir, OK. if (services.length === 0) { return ( <TechCard className="p-16 text-center"> <Wrench className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">SERVİS BULUNAMADI</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Servis eklemek için{' '} <Link href="/admin/services/create" style={{ color: 'var(--brand-red)' }}> Servisler → Yeni </Link>{' '}sayfasını kullanın. </p> </TechCard> ); }
return ( <TechCard className="overflow-hidden"> <table className="admin-table"> <thead> <tr> <th>Servis Adı</th> <th>Tip</th> <th>Durum</th> <th>Fiyat</th> <th>Başlangıç</th> <th style={{ width: '80px' }}></th> </tr> </thead> <tbody> {services.map((service) => { const tone = statusTone[service.status] ?? 'neutral'; const label = statusLabel[service.status] ?? service.status.toUpperCase(); const dateStr = service.createdAt.toISOString().slice(0, 10);
return ( <tr key={service.id}> <td> <span className="font-semibold t-1" style={{ fontSize: '12px' }}> {service.name} </span> </td> <td className="t-2">{service.serviceType.name}</td> <td> <IndustrialBadge tone={tone} dot={tone === 'teal'}>{label}</IndustrialBadge> </td> <td> {service.price !== null ? ( <span className="font-mono font-semibold t-red" style={{ fontSize: '12px' }}> {service.price.toString()} {service.currency} </span> ) : ( <span className="font-mono t-4" style={{ fontSize: '11px' }}>—</span> )} </td> <td> <span className="font-mono t-4" style={{ fontSize: '11px' }}>{dateStr}</span> </td> <td> <Link href={`/admin/services/${service.id}/edit` as any} title="Düzenle" className="btn-wrap btn--ghost btn--xs btn--icon" > <span className="btn-inner"> <Pencil className="w-3.5 h-3.5" /> </span> </Link> </td> </tr> ); })} </tbody> </table> </TechCard> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/services aç. Layout’tan gelen müşteri kartı + tab bar üstte, alt kısımda sadece tablo (artık duplicate başlık yok).
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/services/page.tsxgit commit -m "refactor(admin): müşteri servisler sayfasını layout'a uyumlu yap (duplicate header kaldırıldı)"Task 9: Overview tab ([id]/page.tsx)
Bölüm başlığı “Task 9: Overview tab ([id]/page.tsx)”Files:
- Create:
app/[locale]/admin/(panel)/customers/[id]/page.tsx
Amaç: Müşteri detay sayfasının default sekmesi — StatCard’lar + son aktivite/fatura/ticket özetleri.
- Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { Link } from '@/i18n/navigation';import { TechCard, StatCard, IndustrialBadge, SectionHeading } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
export default async function CustomerOverviewPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [ totalInvoiceAggregate, openTicketCount, recentEmailCount, recentInvoices, recentTickets, recentActivity, ] = await Promise.all([ prisma.invoice.aggregate({ where: { customerId, status: 'paid' }, _sum: { amount: true }, }), prisma.ticket.count({ where: { customerId, status: 'open' } }), prisma.emailLog.count({ where: { customerId, createdAt: { gte: thirtyDaysAgo } }, }), prisma.invoice.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, take: 5, select: { id: true, invoiceNumber: true, amount: true, currency: true, status: true, createdAt: true, }, }), prisma.ticket.findMany({ where: { customerId }, orderBy: { lastMessageAt: 'desc' }, take: 3, select: { id: true, ticketNumber: true, subject: true, status: true, lastMessageAt: true }, }), prisma.customerAuditLog.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, take: 5, select: { id: true, action: true, ipAddress: true, status: true, createdAt: true }, }), ]);
const totalRevenue = totalInvoiceAggregate._sum.amount?.toString() ?? '0';
const invoiceStatusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { paid: 'teal', pending: 'amber', failed: 'red', refunded: 'neutral', }; const ticketStatusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { open: 'amber', pending: 'amber', resolved: 'teal', closed: 'neutral', };
return ( <div className="space-y-6"> {/* Top stats */} <div className="grid grid-cols-4 gap-4"> <StatCard label="Ödenen Ciro" value={<span className="t-teal">{totalRevenue} €</span>} delta={{ text: 'TOPLAM PAID', tone: 'neutral' }} accent="teal" tick /> <StatCard label="Açık Ticket" value={openTicketCount} delta={{ text: 'ŞU AN AÇIK', tone: 'amber' }} accent={openTicketCount > 0 ? 'red' : 'neutral'} tick /> <StatCard label="E-Posta (30G)" value={recentEmailCount} delta={{ text: 'SON 30 GÜN', tone: 'neutral' }} accent="blue" tick /> <StatCard label="Aktivite" value={recentActivity.length} delta={{ text: 'SON 5 KAYIT', tone: 'neutral' }} accent="neutral" tick /> </div>
{/* Recent lists — 2 column grid */} <div className="grid grid-cols-2 gap-4"> {/* Recent invoices */} <TechCard className="overflow-hidden"> <SectionHeading prefix="Recent" title="Son Faturalar" /> {recentInvoices.length === 0 ? ( <p className="p-4 text-center t-4 font-mono" style={{ fontSize: '11px' }}> KAYIT YOK </p> ) : ( <table className="admin-table"> <tbody> {recentInvoices.map((inv) => ( <tr key={inv.id}> <td> <Link href={`/admin/invoices/${inv.id}/edit` as any} className="font-mono t-1" style={{ fontSize: '11px' }} > {inv.invoiceNumber} </Link> </td> <td> <IndustrialBadge tone={invoiceStatusTone[inv.status] ?? 'neutral'}> {inv.status.toUpperCase()} </IndustrialBadge> </td> <td className="text-right"> <span className="font-mono font-semibold" style={{ fontSize: '12px' }}> {inv.amount.toString()} {inv.currency} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {inv.createdAt.toISOString().slice(0, 10)} </span> </td> </tr> ))} </tbody> </table> )} </TechCard>
{/* Recent tickets */} <TechCard className="overflow-hidden"> <SectionHeading prefix="Recent" title="Son Ticket'lar" /> {recentTickets.length === 0 ? ( <p className="p-4 text-center t-4 font-mono" style={{ fontSize: '11px' }}> KAYIT YOK </p> ) : ( <table className="admin-table"> <tbody> {recentTickets.map((t) => ( <tr key={t.id}> <td> <Link href={`/admin/tickets/${t.id}` as any} className="t-1" style={{ fontSize: '12px' }}> {t.subject} </Link> <div className="font-mono t-4" style={{ fontSize: '10px' }}>{t.ticketNumber}</div> </td> <td> <IndustrialBadge tone={ticketStatusTone[t.status] ?? 'neutral'}> {t.status.toUpperCase()} </IndustrialBadge> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {t.lastMessageAt.toISOString().slice(0, 10)} </span> </td> </tr> ))} </tbody> </table> )} </TechCard> </div>
{/* Recent activity timeline */} <TechCard className="overflow-hidden"> <SectionHeading prefix="Activity" title="Son Aktivite" /> {recentActivity.length === 0 ? ( <p className="p-4 text-center t-4 font-mono" style={{ fontSize: '11px' }}> AKTİVİTE YOK </p> ) : ( <table className="admin-table"> <tbody> {recentActivity.map((a) => ( <tr key={a.id}> <td> <span className="font-tech t-1" style={{ fontSize: '11px' }}> {a.action.toUpperCase()} </span> </td> <td> <IndustrialBadge tone={a.status === 'success' ? 'teal' : a.status === 'failed' ? 'red' : 'neutral'}> {a.status.toUpperCase()} </IndustrialBadge> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {a.ipAddress ?? '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {a.createdAt.toISOString().slice(0, 16).replace('T', ' ')} </span> </td> </tr> ))} </tbody> </table> )} </TechCard> </div> );}- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "customers/\[id\]/page" | head -5
Expected: çıktı yok.
- Step 3: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id> aç (slug yok = ÖZET tab aktif). 4 StatCard + son fatura/ticket/aktivite tabloları görünmeli.
- Step 4: Commit
git add app/[locale]/admin/(panel)/customers/[id]/page.tsxgit commit -m "feat(admin): müşteri detay özet sekmesi (StatCard+son fatura/ticket/aktivite)"Task 10: Edit sayfası loading.tsx + error.tsx
Bölüm başlığı “Task 10: Edit sayfası loading.tsx + error.tsx”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/edit/loading.tsx -
Create:
app/[locale]/admin/(panel)/customers/[id]/edit/error.tsx -
Step 1: Dosyaları yaz
Create app/[locale]/admin/(panel)/customers/[id]/edit/loading.tsx:
import CustomerLoadingState from '@/components/admin/CustomerLoadingState';export default function Loading() { return <CustomerLoadingState />; }Create app/[locale]/admin/(panel)/customers/[id]/edit/error.tsx:
'use client';import CustomerErrorState from '@/components/admin/CustomerErrorState';export default function Error({ error, reset }: { error: Error; reset: () => void }) { return <CustomerErrorState error={error} reset={reset} />;}- Step 2: Create sayfası loading/error (aynı pattern)
Create app/[locale]/admin/(panel)/customers/create/loading.tsx:
import CustomerLoadingState from '@/components/admin/CustomerLoadingState';export default function Loading() { return <CustomerLoadingState />; }Create app/[locale]/admin/(panel)/customers/create/error.tsx:
'use client';import CustomerErrorState from '@/components/admin/CustomerErrorState';export default function Error({ error, reset }: { error: Error; reset: () => void }) { return <CustomerErrorState error={error} reset={reset} />;}- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/edit/loading.tsx app/[locale]/admin/(panel)/customers/[id]/edit/error.tsx app/[locale]/admin/(panel)/customers/create/loading.tsx app/[locale]/admin/(panel)/customers/create/error.tsxgit commit -m "feat(admin): müşteri edit/create sayfaları için loading/error"Faz 4: Yüksek Değerli Tab’lar
Bölüm başlığı “Faz 4: Yüksek Değerli Tab’lar”Task 11: Invoices tab ([id]/invoices/page.tsx)
Bölüm başlığı “Task 11: Invoices tab ([id]/invoices/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/invoices/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/invoices/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { Link } from '@/i18n/navigation';import { Pencil, FileText, Download } from 'lucide-react';import { TechCard, StatCard, IndustrialBadge } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
const statusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { paid: 'teal', pending: 'amber', failed: 'red', refunded: 'neutral', cancelled: 'neutral',};
export default async function CustomerInvoicesPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const [invoices, paidSum, pendingSum] = await Promise.all([ prisma.invoice.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, }), prisma.invoice.aggregate({ where: { customerId, status: 'paid' }, _sum: { amount: true }, }), prisma.invoice.aggregate({ where: { customerId, status: 'pending' }, _sum: { amount: true }, }), ]);
const paidTotal = paidSum._sum.amount?.toString() ?? '0'; const pendingTotal = pendingSum._sum.amount?.toString() ?? '0';
return ( <div className="space-y-4"> <div className="grid grid-cols-3 gap-4"> <StatCard label="Ödenen" value={<span className="t-teal">{paidTotal} €</span>} accent="teal" tick /> <StatCard label="Ödenmemiş" value={<span className="t-red">{pendingTotal} €</span>} accent="red" tick /> <StatCard label="Toplam Fatura" value={invoices.length} accent="neutral" tick /> </div>
{invoices.length === 0 ? ( <TechCard className="p-16 text-center"> <FileText className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">FATURA YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteri için henüz fatura kesilmemiş. </p> </TechCard> ) : ( <TechCard className="overflow-hidden"> <table className="admin-table"> <thead> <tr> <th>No</th> <th>Başlık</th> <th>Tutar</th> <th>Durum</th> <th>Vade</th> <th>Ödeme</th> <th>Tarih</th> <th style={{ width: '120px' }}></th> </tr> </thead> <tbody> {invoices.map((inv) => { const tone = statusTone[inv.status] ?? 'neutral'; return ( <tr key={inv.id}> <td> <span className="font-mono t-1" style={{ fontSize: '11px' }}> {inv.invoiceNumber} </span> </td> <td> <span className="t-1" style={{ fontSize: '12px' }}>{inv.title}</span> </td> <td> <span className="font-mono font-semibold" style={{ fontSize: '12px' }}> {inv.amount.toString()} {inv.currency} </span> </td> <td> <IndustrialBadge tone={tone} dot={tone === 'teal'}> {inv.status.toUpperCase()} </IndustrialBadge> </td> <td> <span className="font-mono t-4" style={{ fontSize: '11px' }}> {inv.dueDate ? inv.dueDate.toISOString().slice(0, 10) : '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '11px' }}> {inv.paidAt ? inv.paidAt.toISOString().slice(0, 10) : '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '11px' }}> {inv.createdAt.toISOString().slice(0, 10)} </span> </td> <td> <div className="flex items-center gap-1"> <a href={`/api/admin/invoices/${inv.id}/pdf`} target="_blank" rel="noopener" title="PDF" className="btn-wrap btn--ghost btn--xs btn--icon" > <span className="btn-inner"> <Download className="w-3.5 h-3.5" /> </span> </a> <Link href={`/admin/invoices/${inv.id}/edit` as any} title="Düzenle" className="btn-wrap btn--ghost btn--xs btn--icon" > <span className="btn-inner"> <Pencil className="w-3.5 h-3.5" /> </span> </Link> </div> </td> </tr> ); })} </tbody> </table> </TechCard> )} </div> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/invoices aç. 3 StatCard (paid/pending/total) + tablo görünmeli. PDF butonu yeni sekmede /api/admin/invoices/<id>/pdf açmalı.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/invoices/page.tsxgit commit -m "feat(admin): müşteri faturalar sekmesi (paid/pending stat + tablo + PDF)"Task 12: Emails tab ([id]/emails/page.tsx)
Bölüm başlığı “Task 12: Emails tab ([id]/emails/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/emails/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/emails/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { Link } from '@/i18n/navigation';import { Mail, Eye } from 'lucide-react';import { TechCard, IndustrialBadge, SectionHeading } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>; searchParams: Promise<{ page?: string }>;}
const statusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { sent: 'teal', pending: 'amber', failed: 'red', bounced: 'red',};
const PER_PAGE = 50;
export default async function CustomerEmailsPage({ params, searchParams }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const { page: pageStr = '1' } = await searchParams; const page = Math.max(1, parseInt(pageStr) || 1); const customerId = parseInt(id);
const [total, emails] = await Promise.all([ prisma.emailLog.count({ where: { customerId } }), prisma.emailLog.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * PER_PAGE, take: PER_PAGE, select: { id: true, template: true, subject: true, status: true, locale: true, sentAt: true, createdAt: true, attemptCount: true, }, }), ]);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
if (emails.length === 0 && total === 0) { return ( <TechCard className="p-16 text-center"> <Mail className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">E-POSTA YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteriye henüz e-posta gönderilmemiş. </p> </TechCard> ); }
return ( <TechCard className="overflow-hidden"> <table className="admin-table"> <thead> <tr> <th>Şablon</th> <th>Konu</th> <th>Durum</th> <th>Dil</th> <th>Deneme</th> <th>Gönderim</th> <th>Oluşturulma</th> <th style={{ width: '80px' }}></th> </tr> </thead> <tbody> {emails.map((m) => { const tone = statusTone[m.status] ?? 'neutral'; return ( <tr key={m.id}> <td> <span className="font-mono t-2" style={{ fontSize: '11px' }}>{m.template}</span> </td> <td> <span className="t-1" style={{ fontSize: '12px' }}>{m.subject}</span> </td> <td> <IndustrialBadge tone={tone}>{m.status.toUpperCase()}</IndustrialBadge> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {m.locale ?? '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '11px' }}> {m.attemptCount} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {m.sentAt ? m.sentAt.toISOString().slice(0, 16).replace('T', ' ') : '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {m.createdAt.toISOString().slice(0, 16).replace('T', ' ')} </span> </td> <td> <Link href={`/admin/communications/emails/${m.id}` as any} title="Detay" className="btn-wrap btn--ghost btn--xs btn--icon" > <span className="btn-inner"> <Eye className="w-3.5 h-3.5" /> </span> </Link> </td> </tr> ); })} </tbody> </table>
{totalPages > 1 && ( <div className="flex items-center justify-between px-4 py-3" style={{ borderTop: '1px solid var(--border-hairline)', background: 'var(--brand-panel)' }} > <p className="font-mono t-4" style={{ fontSize: '10px', letterSpacing: '0.08em' }}> {emails.length}/{total} KAYIT · SAYFA {page}/{totalPages} </p> <div className="flex items-center gap-1"> {page > 1 && ( <Link href={`/admin/customers/${customerId}/emails?page=${page - 1}` as any} className="btn-wrap btn--ghost btn--sm" > <span className="btn-inner" style={{ padding: '4px 8px', fontSize: '11px' }}>←</span> </Link> )} {page < totalPages && ( <Link href={`/admin/customers/${customerId}/emails?page=${page + 1}` as any} className="btn-wrap btn--ghost btn--sm" > <span className="btn-inner" style={{ padding: '4px 8px', fontSize: '11px' }}>→</span> </Link> )} </div> </div> )} </TechCard> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/emails aç. EmailLog kayıtları tablo halinde, pagination 50 satır/sayfa. “Detay” butonu mevcut /admin/communications/emails/<id> sayfasına gitmeli.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/emails/page.tsxgit commit -m "feat(admin): müşteri e-posta logları sekmesi (template/status/pagination)"Task 13: Tickets tab ([id]/tickets/page.tsx)
Bölüm başlığı “Task 13: Tickets tab ([id]/tickets/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/tickets/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/tickets/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { Link } from '@/i18n/navigation';import { Ticket as TicketIcon } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
const statusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { open: 'amber', pending: 'amber', resolved: 'teal', closed: 'neutral',};
const priorityTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { low: 'neutral', normal: 'neutral', high: 'amber', urgent: 'red',};
export default async function CustomerTicketsPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const tickets = await prisma.ticket.findMany({ where: { customerId }, orderBy: { lastMessageAt: 'desc' }, include: { category: { select: { slug: true, name: true } }, _count: { select: { messages: true } }, }, });
if (tickets.length === 0) { return ( <TechCard className="p-16 text-center"> <TicketIcon className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">TICKET YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteri henüz destek talebi açmamış. </p> </TechCard> ); }
return ( <TechCard className="overflow-hidden"> <table className="admin-table"> <thead> <tr> <th>No</th> <th>Konu</th> <th>Kategori</th> <th>Durum</th> <th>Öncelik</th> <th>Mesaj</th> <th>Son Mesaj</th> <th>Açılış</th> </tr> </thead> <tbody> {tickets.map((t) => { const sTone = statusTone[t.status] ?? 'neutral'; const pTone = priorityTone[t.priority] ?? 'neutral';
// category.name is Json (locale map); fallback to slug const categoryLabel = t.category?.slug?.toUpperCase() ?? '—';
return ( <tr key={t.id}> <td> <Link href={`/admin/tickets/${t.id}` as any} className="font-mono t-1" style={{ fontSize: '11px' }} > {t.ticketNumber} </Link> </td> <td> <Link href={`/admin/tickets/${t.id}` as any} className="t-1" style={{ fontSize: '12px' }}> {t.subject} </Link> </td> <td> <span className="font-mono t-3" style={{ fontSize: '11px' }}>{categoryLabel}</span> </td> <td> <IndustrialBadge tone={sTone}>{t.status.toUpperCase()}</IndustrialBadge> </td> <td> <IndustrialBadge tone={pTone}>{t.priority.toUpperCase()}</IndustrialBadge> </td> <td> <span className="font-mono t-4" style={{ fontSize: '11px' }}>{t._count.messages}</span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {t.lastMessageAt.toISOString().slice(0, 16).replace('T', ' ')} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {t.createdAt.toISOString().slice(0, 10)} </span> </td> </tr> ); })} </tbody> </table> </TechCard> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/tickets aç. Ticket’lar tablo + status/priority badge’leri. Satıra tıklayınca /admin/tickets/<id> açılmalı.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/tickets/page.tsxgit commit -m "feat(admin): müşteri ticket'lar sekmesi (status/priority/mesaj sayısı)"Task 14: Activity tab ([id]/activity/page.tsx)
Bölüm başlığı “Task 14: Activity tab ([id]/activity/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/activity/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/activity/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { Link } from '@/i18n/navigation';import { Activity } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>; searchParams: Promise<{ page?: string; action?: string }>;}
const PER_PAGE = 50;
export default async function CustomerActivityPage({ params, searchParams }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const { page: pageStr = '1', action } = await searchParams; const page = Math.max(1, parseInt(pageStr) || 1); const customerId = parseInt(id);
const where = { customerId, ...(action ? { action } : {}), };
const [total, logs, actionGroups] = await Promise.all([ prisma.customerAuditLog.count({ where }), prisma.customerAuditLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * PER_PAGE, take: PER_PAGE, }), // Distinct actions for filter chips prisma.customerAuditLog.groupBy({ by: ['action'], where: { customerId }, _count: true, }), ]);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return ( <div className="space-y-4"> {/* Action filter chips */} {actionGroups.length > 0 && ( <div className="flex items-center gap-2 flex-wrap"> <Link href={`/admin/customers/${customerId}/activity` as any} className={`btn-wrap btn--xs ${!action ? 'btn--primary' : 'btn--ghost'}`} > <span className="btn-inner">TÜMÜ ({total})</span> </Link> {actionGroups.map((g) => ( <Link key={g.action} href={`/admin/customers/${customerId}/activity?action=${g.action}` as any} className={`btn-wrap btn--xs ${action === g.action ? 'btn--primary' : 'btn--ghost'}`} > <span className="btn-inner"> {g.action.toUpperCase()} ({g._count}) </span> </Link> ))} </div> )}
{logs.length === 0 ? ( <TechCard className="p-16 text-center"> <Activity className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">AKTİVİTE YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Seçili filtre için kayıt yok. </p> </TechCard> ) : ( <TechCard className="overflow-hidden"> <table className="admin-table"> <thead> <tr> <th>Eylem</th> <th>Hedef</th> <th>Durum</th> <th>IP</th> <th>User-Agent</th> <th>Tarih</th> </tr> </thead> <tbody> {logs.map((l) => ( <tr key={l.id}> <td> <span className="font-tech t-1" style={{ fontSize: '11px' }}> {l.action.toUpperCase()} </span> </td> <td> <span className="font-mono t-3" style={{ fontSize: '11px' }}> {l.target ?? '—'} </span> </td> <td> <IndustrialBadge tone={l.status === 'success' ? 'teal' : l.status === 'failed' ? 'red' : 'neutral'} > {l.status.toUpperCase()} </IndustrialBadge> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {l.ipAddress ?? '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px', maxWidth: '200px', display: 'inline-block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={l.userAgent ?? ''} > {l.userAgent ?? '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {l.createdAt.toISOString().slice(0, 19).replace('T', ' ')} </span> </td> </tr> ))} </tbody> </table>
{totalPages > 1 && ( <div className="flex items-center justify-between px-4 py-3" style={{ borderTop: '1px solid var(--border-hairline)', background: 'var(--brand-panel)' }} > <p className="font-mono t-4" style={{ fontSize: '10px', letterSpacing: '0.08em' }}> {logs.length}/{total} KAYIT · SAYFA {page}/{totalPages} </p> <div className="flex items-center gap-1"> {page > 1 && ( <Link href={`/admin/customers/${customerId}/activity?page=${page - 1}${action ? `&action=${action}` : ''}` as any} className="btn-wrap btn--ghost btn--sm" > <span className="btn-inner" style={{ padding: '4px 8px', fontSize: '11px' }}>←</span> </Link> )} {page < totalPages && ( <Link href={`/admin/customers/${customerId}/activity?page=${page + 1}${action ? `&action=${action}` : ''}` as any} className="btn-wrap btn--ghost btn--sm" > <span className="btn-inner" style={{ padding: '4px 8px', fontSize: '11px' }}>→</span> </Link> )} </div> </div> )} </TechCard> )} </div> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/activity aç. Üstte action filter chips, alt kısımda timeline tablosu. Filter chip’e tıklayınca URL’e ?action=... eklenmeli.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/activity/page.tsxgit commit -m "feat(admin): müşteri aktivite sekmesi (audit log + action filter + pagination)"Faz 5: Kalan Tab’lar
Bölüm başlığı “Faz 5: Kalan Tab’lar”Task 15: Sessions tab ([id]/sessions/page.tsx)
Bölüm başlığı “Task 15: Sessions tab ([id]/sessions/page.tsx)”Files:
- Create:
app/[locale]/admin/(panel)/customers/[id]/sessions/page.tsx - Create:
components/admin/RevokeSessionButton.tsx
NOT: Bu task’ta UI buton bağlı ama endpoint Faz 8’de yazılacak — buton şimdilik disabled veya “yakında” olabilir. Aşağıdaki yaklaşımda buton hazır + endpoint Faz 8’de fonksiyonel olunca otomatik çalışır.
- Step 1: Revoke buton component’i (placeholder)
Create components/admin/RevokeSessionButton.tsx:
'use client';
import { useState, useTransition } from 'react';import { useRouter } from 'next/navigation';import { X } from 'lucide-react';
interface Props { customerId: number; sessionId: number;}
export default function RevokeSessionButton({ customerId, sessionId }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [confirm, setConfirm] = useState(false);
async function doRevoke() { const res = await fetch( `/api/admin/customers/${customerId}/sessions/${sessionId}/revoke`, { method: 'POST' } ); if (res.ok) { startTransition(() => router.refresh()); } else { alert('Oturum sonlandırılamadı'); } setConfirm(false); }
if (confirm) { return ( <div className="flex items-center gap-1"> <button onClick={doRevoke} disabled={pending} className="btn-wrap btn--xs" style={{ background: 'var(--brand-red)' }} > <span className="btn-inner" style={{ color: '#fff' }}>EVET</span> </button> <button onClick={() => setConfirm(false)} className="btn-wrap btn--ghost btn--xs"> <span className="btn-inner">İPTAL</span> </button> </div> ); }
return ( <button onClick={() => setConfirm(true)} title="Oturumu Sonlandır" className="btn-wrap btn--ghost btn--xs btn--icon" > <span className="btn-inner"> <X className="w-3.5 h-3.5" /> </span> </button> );}- Step 2: Sessions page
Create app/[locale]/admin/(panel)/customers/[id]/sessions/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { KeyRound } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';import RevokeSessionButton from '@/components/admin/RevokeSessionButton';
interface PageProps { params: Promise<{ id: string }>;}
export default async function CustomerSessionsPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id); const now = new Date();
const sessions = await prisma.customerSession.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, });
const active = sessions.filter((s) => s.expiresAt > now); const expired = sessions.filter((s) => s.expiresAt <= now);
if (sessions.length === 0) { return ( <TechCard className="p-16 text-center"> <KeyRound className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">OTURUM YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteri henüz portala giriş yapmamış. </p> </TechCard> ); }
function row(s: typeof sessions[number], isExpired: boolean) { return ( <tr key={s.id} style={{ opacity: isExpired ? 0.5 : 1 }}> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {s.ipAddress ?? '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px', maxWidth: '300px', display: 'inline-block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={s.userAgent ?? ''} > {s.userAgent ?? '—'} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {s.createdAt.toISOString().slice(0, 16).replace('T', ' ')} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {s.expiresAt.toISOString().slice(0, 16).replace('T', ' ')} </span> </td> <td> {isExpired ? ( <IndustrialBadge tone="neutral">SÜRESİ DOLDU</IndustrialBadge> ) : ( <RevokeSessionButton customerId={customerId} sessionId={s.id} /> )} </td> </tr> ); }
return ( <div className="space-y-4"> {active.length > 0 && ( <TechCard className="overflow-hidden"> <div className="px-4 py-2 font-tech" style={{ fontSize: '11px', letterSpacing: '0.08em', borderBottom: '1px solid var(--border-hairline)' }}> AKTİF OTURUMLAR ({active.length}) </div> <table className="admin-table"> <thead> <tr> <th>IP</th> <th>User-Agent</th> <th>Başlangıç</th> <th>Süre Sonu</th> <th style={{ width: '120px' }}></th> </tr> </thead> <tbody>{active.map((s) => row(s, false))}</tbody> </table> </TechCard> )}
{expired.length > 0 && ( <TechCard className="overflow-hidden"> <div className="px-4 py-2 font-tech t-4" style={{ fontSize: '11px', letterSpacing: '0.08em', borderBottom: '1px solid var(--border-hairline)' }}> GEÇMİŞ OTURUMLAR ({expired.length}) </div> <table className="admin-table"> <thead> <tr> <th>IP</th> <th>User-Agent</th> <th>Başlangıç</th> <th>Süre Sonu</th> <th style={{ width: '120px' }}></th> </tr> </thead> <tbody>{expired.slice(0, 20).map((s) => row(s, true))}</tbody> </table> </TechCard> )} </div> );}- Step 3: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/sessions aç. Aktif ve geçmiş oturumlar ayrı tablolarda. Revoke butonu confirm akışını gösterse de endpoint olmadığı için fetch hata verir (Faz 8’de bağlanacak).
- Step 4: Commit
git add app/[locale]/admin/(panel)/customers/[id]/sessions/page.tsx components/admin/RevokeSessionButton.tsxgit commit -m "feat(admin): müşteri oturumlar sekmesi + revoke buton (UI)"Task 16: Files tab ([id]/files/page.tsx)
Bölüm başlığı “Task 16: Files tab ([id]/files/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/files/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/files/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { FileBox, Download } from 'lucide-react';import { TechCard } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
function humanSize(bytes: bigint): string { const n = Number(bytes); if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;}
export default async function CustomerFilesPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const files = await prisma.file.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, include: { service: { select: { id: true, name: true } } }, });
if (files.length === 0) { return ( <TechCard className="p-16 text-center"> <FileBox className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">DOSYA YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteri için henüz dosya yüklenmemiş. </p> </TechCard> ); }
return ( <TechCard className="overflow-hidden"> <table className="admin-table"> <thead> <tr> <th>Dosya Adı</th> <th>Servis</th> <th>Sürüm</th> <th>Tip</th> <th>Boyut</th> <th>Yüklenme</th> <th style={{ width: '80px' }}></th> </tr> </thead> <tbody> {files.map((f) => ( <tr key={f.id}> <td> <span className="t-1" style={{ fontSize: '12px' }}>{f.originalName}</span> <div className="font-mono t-4" style={{ fontSize: '10px' }}>{f.fileName}</div> </td> <td> <span className="t-3" style={{ fontSize: '11px' }}> {f.service?.name ?? '—'} </span> </td> <td> <span className="font-mono t-2" style={{ fontSize: '11px' }}>{f.version}</span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}>{f.mimeType}</span> </td> <td> <span className="font-mono t-2" style={{ fontSize: '11px' }}> {humanSize(f.fileSize)} </span> </td> <td> <span className="font-mono t-4" style={{ fontSize: '10px' }}> {f.createdAt.toISOString().slice(0, 10)} </span> </td> <td> {/* filePath direkt indirilebilir varsayımı; gerçek endpoint mevcut admin dosya API'sine bağlanacak */} <a href={f.filePath.startsWith('/') ? f.filePath : `/${f.filePath}`} title="İndir" className="btn-wrap btn--ghost btn--xs btn--icon" target="_blank" rel="noopener" > <span className="btn-inner"> <Download className="w-3.5 h-3.5" /> </span> </a> </td> </tr> ))} </tbody> </table> </TechCard> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/files aç. Dosya listesi tablo halinde, boyut human-readable.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/files/page.tsxgit commit -m "feat(admin): müşteri dosyalar sekmesi (versiyon/boyut/servis ilişkisi)"Task 17: Subscriptions tab ([id]/subscriptions/page.tsx)
Bölüm başlığı “Task 17: Subscriptions tab ([id]/subscriptions/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/subscriptions/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/subscriptions/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { CreditCard, ExternalLink } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
const statusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { active: 'teal', trialing: 'teal', past_due: 'amber', unpaid: 'amber', incomplete: 'amber', canceled: 'neutral',};
export default async function CustomerSubscriptionsPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const subs = await prisma.subscription.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, });
if (subs.length === 0) { return ( <TechCard className="p-16 text-center"> <CreditCard className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">ABONELİK YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteri için aktif veya geçmiş Stripe aboneliği yok. </p> </TechCard> ); }
return ( <div className="grid grid-cols-2 gap-4"> {subs.map((s) => { const tone = statusTone[s.status] ?? 'neutral'; const amount = (s.amount / 100).toFixed(2);
return ( <TechCard key={s.id} className="p-4"> <div className="flex items-start justify-between mb-3"> <div> <p className="font-tech t-1" style={{ fontSize: '13px' }}> {s.productId.toUpperCase()} </p> <p className="font-mono t-4" style={{ fontSize: '10px' }}> {s.stripeSubscriptionId} </p> </div> <IndustrialBadge tone={tone} dot={tone === 'teal'}> {s.status.toUpperCase()} </IndustrialBadge> </div>
<div className="space-y-1.5 mb-3"> <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>TUTAR</span> <span className="font-mono font-semibold" style={{ fontSize: '13px' }}> {amount} {s.currency.toUpperCase()} / {s.interval.toUpperCase()} </span> </div> <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>DÖNEM</span> <span className="font-mono t-2" style={{ fontSize: '11px' }}> {s.currentPeriodStart.toISOString().slice(0, 10)} → {s.currentPeriodEnd.toISOString().slice(0, 10)} </span> </div> {s.cancelAtPeriodEnd && ( <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>İPTAL</span> <IndustrialBadge tone="amber">DÖNEM SONUNDA İPTAL</IndustrialBadge> </div> )} {s.canceledAt && ( <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>İPTAL TARİHİ</span> <span className="font-mono t-2" style={{ fontSize: '11px' }}> {s.canceledAt.toISOString().slice(0, 10)} </span> </div> )} </div>
<a href={`https://dashboard.stripe.com/subscriptions/${s.stripeSubscriptionId}`} target="_blank" rel="noopener" className="btn-wrap btn--ghost btn--xs" > <span className="btn-inner"> <ExternalLink className="w-3 h-3" /> STRIPE'DA AÇ </span> </a> </TechCard> ); })} </div> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/subscriptions aç. Abonelik card’ları grid halinde, Stripe link’i yeni sekme açmalı.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/subscriptions/page.tsxgit commit -m "feat(admin): müşteri abonelikler sekmesi (Stripe sub card grid)"Task 18: Installations tab ([id]/installations/page.tsx)
Bölüm başlığı “Task 18: Installations tab ([id]/installations/page.tsx)”Files:
-
Create:
app/[locale]/admin/(panel)/customers/[id]/installations/page.tsx -
Step 1: Page dosyasını yaz
Create app/[locale]/admin/(panel)/customers/[id]/installations/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { Globe, ExternalLink } from 'lucide-react';import { TechCard, IndustrialBadge } from '@/components/admin/design-system';
interface PageProps { params: Promise<{ id: string }>;}
const statusTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { active: 'teal', suspended: 'amber', deleted: 'red',};
const typeTone: Record<string, 'teal' | 'amber' | 'red' | 'neutral'> = { buy: 'teal', trial: 'amber',};
export default async function CustomerInstallationsPage({ params }: PageProps) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params; const customerId = parseInt(id);
const installations = await prisma.portalInstallation.findMany({ where: { customerId }, orderBy: { provisionedAt: 'desc' }, });
if (installations.length === 0) { return ( <TechCard className="p-16 text-center"> <Globe className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">PORTAL KURULUMU YOK</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Bu müşteri için provision edilmiş bir portal yok. </p> </TechCard> ); }
return ( <div className="grid grid-cols-2 gap-4"> {installations.map((i) => ( <TechCard key={i.id} className="p-4"> <div className="flex items-start justify-between mb-3"> <div className="min-w-0"> <p className="font-tech t-1" style={{ fontSize: '13px' }}> {i.subdomain} </p> <p className="font-mono t-4" style={{ fontSize: '10px', wordBreak: 'break-all' }}> {i.portalUrl} </p> </div> <div className="flex flex-col gap-1 items-end shrink-0"> <IndustrialBadge tone={statusTone[i.status] ?? 'neutral'} dot={i.status === 'active'}> {i.status.toUpperCase()} </IndustrialBadge> <IndustrialBadge tone={typeTone[i.type] ?? 'neutral'}> {i.type.toUpperCase()} </IndustrialBadge> </div> </div>
<div className="space-y-1.5 mb-3"> <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>EMAIL</span> <span className="font-mono t-2" style={{ fontSize: '11px' }}>{i.customerEmail}</span> </div> <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>KURULUM</span> <span className="font-mono t-2" style={{ fontSize: '11px' }}> {i.provisionedAt.toISOString().slice(0, 10)} </span> </div> {i.trialEndsAt && ( <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>TRIAL BİTİŞ</span> <span className="font-mono t-2" style={{ fontSize: '11px' }}> {i.trialEndsAt.toISOString().slice(0, 10)} </span> </div> )} {i.smtpUser && ( <div className="flex items-center justify-between"> <span className="font-mono t-4" style={{ fontSize: '10px' }}>SMTP USER</span> <span className="font-mono t-2" style={{ fontSize: '11px' }}>{i.smtpUser}</span> </div> )} </div>
<a href={i.portalUrl} target="_blank" rel="noopener" className="btn-wrap btn--ghost btn--xs" > <span className="btn-inner"> <ExternalLink className="w-3 h-3" /> PORTALI AÇ </span> </a> </TechCard> ))} </div> );}- Step 2: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customers/<id>/installations aç. Portal card’ları grid halinde, “PORTALI AÇ” link’i yeni sekme açmalı.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/[id]/installations/page.tsxgit commit -m "feat(admin): müşteri portal kurulumları sekmesi (card grid)"Faz 6: Liste Sayfası Arama (?q=)
Bölüm başlığı “Faz 6: Liste Sayfası Arama (?q=)”Task 19: CustomerSearchInput client component
Bölüm başlığı “Task 19: CustomerSearchInput client component”Files:
-
Create:
components/admin/CustomerSearchInput.tsx -
Step 1: Component’i yaz
Create components/admin/CustomerSearchInput.tsx:
'use client';
import { useState, useTransition, useEffect, useRef } from 'react';import { useRouter, useSearchParams } from 'next/navigation';import { Search, X } from 'lucide-react';
export default function CustomerSearchInput() { const router = useRouter(); const searchParams = useSearchParams(); const [pending, startTransition] = useTransition(); const initialQ = searchParams.get('q') ?? ''; const [value, setValue] = useState(initialQ); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { const params = new URLSearchParams(searchParams.toString()); const trimmed = value.trim(); if (trimmed) params.set('q', trimmed); else params.delete('q'); params.delete('page'); // arama değiştiğinde 1. sayfaya startTransition(() => { router.replace(`?${params.toString()}`); }); }, 300); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]);
function clear() { setValue(''); }
return ( <div className="flex items-center gap-2 px-3 h-9" style={{ background: 'var(--brand-panel)', border: '1px solid var(--border-hairline)', minWidth: '280px', }} > <Search className="w-3.5 h-3.5" style={{ color: 'var(--fg-4)' }} /> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} placeholder="Müşteri ara (ad, e-posta, şirket, VAT, şehir)..." className="flex-1 bg-transparent outline-none font-mono" style={{ fontSize: '11px', letterSpacing: '0.04em', color: 'var(--fg-1)', }} /> {value && ( <button onClick={clear} className="p-0.5" title="Temizle" > <X className="w-3 h-3" style={{ color: 'var(--fg-4)' }} /> </button> )} {pending && ( <span className="font-mono t-4" style={{ fontSize: '9px' }}>...</span> )} </div> );}- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "CustomerSearchInput" | head -5
Expected: çıktı yok.
- Step 3: Commit
git add components/admin/CustomerSearchInput.tsxgit commit -m "feat(admin): müşteri arama input bileşeni (debounced URL update)"Task 20: Liste sayfasını arama ile entegre et
Bölüm başlığı “Task 20: Liste sayfasını arama ile entegre et”Files:
-
Modify:
app/[locale]/admin/(panel)/customers/page.tsx -
Step 1: Mevcut page.tsx’i güncelle
app/[locale]/admin/(panel)/customers/page.tsx içinde 3 değişiklik:
- Import ekle (en üstte):
import CustomerSearchInput from '@/components/admin/CustomerSearchInput';import { buildCustomerSearchWhere } from '@/lib/admin-customer-search';- searchParams type’ına
q?: stringekle ve parse et:
Mevcut:
interface CustomersPageProps { searchParams: Promise<{ filter?: string; page?: string }>;}Yeni:
interface CustomersPageProps { searchParams: Promise<{ filter?: string; page?: string; q?: string }>;}Mevcut destructure satırını:
const { filter = 'all', page: pageStr = '1' } = await searchParams;Yeni:
const { filter = 'all', page: pageStr = '1', q = '' } = await searchParams;- filterWhere’i değiştir: Mevcut:
const filterWhere = filter === 'active' ? { isActive: true } : filter === 'passive' ? { isActive: false } : {};Yeni:
const statusWhere = filter === 'active' ? { isActive: true } : filter === 'passive' ? { isActive: false } : {};
const searchWhere = buildCustomerSearchWhere(q);
// AND birleşimi: hem status hem arama eşleşmeliconst filterWhere = q ? { AND: [statusWhere, searchWhere] } : statusWhere;-
Pagination link’lerine
qparametresini koru. Mevcut pagination’dahref={/admin/customers?filter=${filter}&page=…}olan 3 yerde sona${q ?&q=${encodeURIComponent(q)}: ''}ekle. -
Filter tabs row’una arama input’unu ekle:
Mevcut:
{/* Filter tabs row */}<div className="flex items-center gap-3 mb-4"> <CustomerFilterTabs totalCount={totalCount} activeCount={activeCount} passiveCount={passiveCount} activeFilter={filter} /></div>Yeni:
{/* Filter tabs row + search */}<div className="flex items-center justify-between gap-3 mb-4 flex-wrap"> <CustomerFilterTabs totalCount={totalCount} activeCount={activeCount} passiveCount={passiveCount} activeFilter={filter} /> <CustomerSearchInput /></div>
{q && ( <div className="mb-3 flex items-center gap-3"> <span className="font-mono t-3" style={{ fontSize: '11px' }}> "{q}" için {filteredTotal} sonuç </span> <a href={`/admin/customers${filter !== 'all' ? `?filter=${filter}` : ''}`} className="font-mono" style={{ fontSize: '10px', color: 'var(--brand-red)' }} > ARAMAYI TEMİZLE </a> </div>)}- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "customers/page" | head -5
Expected: çıktı yok.
- Step 3: Browser doğrulama
http://49.12.188.137:3077/tr/admin/customersaç. Sağ üstte arama kutusu görünmeli.- Bir şey yaz (örn. mevcut bir müşteri ismi). 300ms sonra URL’e
?q=...eklenmeli ve liste filtrelensin. - “ARAMAYI TEMİZLE” linki tıklayınca arama temizlensin.
- Aktif filter (active/passive) ile arama birlikte çalışmalı.
- Step 4: Commit
git add app/[locale]/admin/(panel)/customers/page.tsxgit commit -m "feat(admin): müşteri listesi inline arama (8 alanlı ILIKE + URL ?q=)"Task 21: Arama boş sonuç state’i
Bölüm başlığı “Task 21: Arama boş sonuç state’i”Files:
-
Modify:
app/[locale]/admin/(panel)/customers/page.tsx -
Step 1: Mevcut “Müşteri Bulunamadı” boş state’ini güncelle
customers/page.tsx içindeki şu blok:
{customers.length === 0 ? ( <TechCard className="p-16 text-center"> <Users className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]">Müşteri Bulunamadı</p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> Seçili filtre için kayıt yok. </p> </TechCard>) : (Şu hale getirilir (arama varken farklı mesaj):
{customers.length === 0 ? ( <TechCard className="p-16 text-center"> <Users className="w-14 h-14 mx-auto mb-4" style={{ color: 'var(--fg-4)' }} /> <p className="font-tech t-1 text-[14px]"> {q ? 'EŞLEŞEN MÜŞTERİ YOK' : 'MÜŞTERİ BULUNAMADI'} </p> <p className="t-3 text-[12px] mt-1 normal-case" style={{ fontFamily: 'inherit', fontWeight: 400 }}> {q ? <>Aramayı değiştirin veya <a href={`/admin/customers${filter !== 'all' ? `?filter=${filter}` : ''}`} style={{ color: 'var(--brand-red)' }}>aramayı temizleyin</a>.</> : 'Seçili filtre için kayıt yok.'} </p> </TechCard>) : (- Step 2: Browser doğrulama
Arama kutusuna gerçekten hiçbir müşteride olmayan bir kelime yaz (örn. xyzqwe123). “EŞLEŞEN MÜŞTERİ YOK” + temizle linki görünmeli.
- Step 3: Commit
git add app/[locale]/admin/(panel)/customers/page.tsxgit commit -m "feat(admin): müşteri listesi 0-sonuç durumunda farklı mesaj + temizle linki"Faz 7: Cmd+K Global Palette + /api/admin/search
Bölüm başlığı “Faz 7: Cmd+K Global Palette + /api/admin/search”Task 22: cmdk paketini kur
Bölüm başlığı “Task 22: cmdk paketini kur”Files:
-
Modify:
package.json -
Modify:
package-lock.json -
Step 1: Paketi yükle
Run: npm install cmdk@^1.0.0 2>&1 | tail -10
Expected: added 1 package veya benzer çıktı, hata yok.
- Step 2: Versiyonu doğrula
Run: node -e "console.log(require('cmdk/package.json').version)"
Expected: 1.x.x sürüm numarası.
- Step 3: Commit
git add package.json package-lock.jsongit commit -m "chore(deps): cmdk paketi eklendi (admin Cmd+K palette için)"Task 23: /api/admin/search endpoint + test
Bölüm başlığı “Task 23: /api/admin/search endpoint + test”Files:
-
Create:
lib/admin-global-search.ts -
Create:
lib/admin-global-search.test.ts -
Create:
app/api/admin/search/route.ts -
Step 1: Failing test yaz
Create lib/admin-global-search.test.ts:
import { test } from 'node:test';import assert from 'node:assert/strict';import { buildGlobalSearchInputs } from './admin-global-search';
test('boş sorgu için null döner (no-search)', () => { assert.equal(buildGlobalSearchInputs(''), null); assert.equal(buildGlobalSearchInputs(' '), null); assert.equal(buildGlobalSearchInputs('a'), null); // 2 karakterden az});
test('2+ karakter sorgu için trimmed q + default limit döner', () => { const r = buildGlobalSearchInputs(' acme '); assert.deepEqual(r, { q: 'acme', limit: 8 });});
test('limit override edilebilir, max 20', () => { assert.deepEqual(buildGlobalSearchInputs('acme', 5), { q: 'acme', limit: 5 }); assert.deepEqual(buildGlobalSearchInputs('acme', 100), { q: 'acme', limit: 20 }); assert.deepEqual(buildGlobalSearchInputs('acme', -1), { q: 'acme', limit: 8 });});- Step 2: Run test, FAIL bekle
Run: npm test -- lib/admin-global-search.test.ts 2>&1 | tail -10
Expected: FAIL — modül bulunamadı.
- Step 3: Implementation
Create lib/admin-global-search.ts:
export interface SearchInputs { q: string; limit: number;}
const MIN_QUERY_LENGTH = 2;const DEFAULT_LIMIT = 8;const MAX_LIMIT = 20;
export function buildGlobalSearchInputs(rawQ: string, rawLimit?: number): SearchInputs | null { const q = rawQ.trim(); if (q.length < MIN_QUERY_LENGTH) return null;
let limit = rawLimit ?? DEFAULT_LIMIT; if (!Number.isFinite(limit) || limit <= 0) limit = DEFAULT_LIMIT; if (limit > MAX_LIMIT) limit = MAX_LIMIT;
return { q, limit };}- Step 4: Run test, PASS bekle
Run: npm test -- lib/admin-global-search.test.ts 2>&1 | tail -10
Expected: 3 PASS.
- Step 5: Route handler
Create app/api/admin/search/route.ts:
import prisma from '@/lib/prisma';import { validateAdminSession } from '@/lib/admin-session';import { buildGlobalSearchInputs } from '@/lib/admin-global-search';import { NextResponse } from 'next/server';
export async function GET(req: Request) { const admin = await validateAdminSession(); if (!admin) return new NextResponse('Unauthorized', { status: 401 });
const url = new URL(req.url); const inputs = buildGlobalSearchInputs( url.searchParams.get('q') ?? '', url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined );
if (!inputs) { return NextResponse.json({ customers: [], services: [], invoices: [], tickets: [] }); }
const { q, limit } = inputs; const ci = { contains: q, mode: 'insensitive' as const };
const [customers, services, invoices, tickets] = await Promise.all([ prisma.customer.findMany({ where: { OR: [{ name: ci }, { email: ci }, { company: ci }] }, select: { id: true, name: true, email: true, company: true }, take: limit, }), prisma.service.findMany({ where: { name: ci }, select: { id: true, name: true, customerId: true }, take: limit, }), prisma.invoice.findMany({ where: { OR: [{ invoiceNumber: ci }, { title: ci }] }, select: { id: true, invoiceNumber: true, title: true, customerId: true }, take: limit, }), prisma.ticket.findMany({ where: { OR: [{ ticketNumber: ci }, { subject: ci }] }, select: { id: true, ticketNumber: true, subject: true, customerId: true }, take: limit, }), ]);
return NextResponse.json({ customers, services, invoices, tickets });}- Step 6: curl ile doğrulama
NOT: Endpoint admin session gerektirir. Önce browser’dan admin’e login ol, sonra cookie ile curl at:
Run (browser admin login sonrası): curl -s -b "your_session_cookie" "http://49.12.188.137:3077/api/admin/search?q=test&limit=3" | head -20
Expected: JSON yanıt, 4 anahtar (customers/services/invoices/tickets).
- Step 7: Commit
git add lib/admin-global-search.ts lib/admin-global-search.test.ts app/api/admin/search/route.tsgit commit -m "feat(admin): global arama endpoint (Cmd+K palette için, 4 kaynak parallel)"Task 24: CommandPalette component
Bölüm başlığı “Task 24: CommandPalette component”Files:
-
Create:
components/admin/CommandPalette.tsx -
Step 1: Component’i yaz
Create components/admin/CommandPalette.tsx:
'use client';
import { useEffect, useState, useRef } from 'react';import { Command } from 'cmdk';import { useRouter } from 'next/navigation';import { User, Wrench, FileText, Ticket as TicketIcon, Search } from 'lucide-react';
interface CustomerHit { id: number; name: string; email: string; company: string | null }interface ServiceHit { id: number; name: string; customerId: number }interface InvoiceHit { id: number; invoiceNumber: string; title: string; customerId: number }interface TicketHit { id: string; ticketNumber: string; subject: string; customerId: number }
interface SearchResults { customers: CustomerHit[]; services: ServiceHit[]; invoices: InvoiceHit[]; tickets: TicketHit[];}
export default function CommandPalette() { const router = useRouter(); const [open, setOpen] = useState(false); const [q, setQ] = useState(''); const [results, setResults] = useState<SearchResults>({ customers: [], services: [], invoices: [], tickets: [] }); const [loading, setLoading] = useState(false); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const abortRef = useRef<AbortController | null>(null);
// Global Cmd+K / Ctrl+K useEffect(() => { function onKey(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setOpen((o) => !o); } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []);
// Debounced fetch useEffect(() => { if (!open) return; if (debounceRef.current) clearTimeout(debounceRef.current); if (abortRef.current) abortRef.current.abort();
if (q.trim().length < 2) { setResults({ customers: [], services: [], invoices: [], tickets: [] }); setLoading(false); return; }
debounceRef.current = setTimeout(async () => { setLoading(true); const ctrl = new AbortController(); abortRef.current = ctrl; try { const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}&limit=8`, { signal: ctrl.signal, }); if (res.ok) setResults(await res.json()); } catch (err: any) { if (err.name !== 'AbortError') console.error('palette search failed', err); } finally { setLoading(false); } }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [q, open]);
function go(href: string) { setOpen(false); setQ(''); router.push(href as any); }
if (!open) return null;
return ( <div className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]" style={{ background: 'rgba(0,0,0,0.55)' }} onClick={() => setOpen(false)} > <Command className="w-full max-w-2xl" style={{ background: 'var(--brand-panel)', border: '1px solid var(--border-hairline)', boxShadow: '0 24px 60px rgba(0,0,0,0.5)', }} onClick={(e) => e.stopPropagation()} > <div className="flex items-center gap-2 px-4 h-12" style={{ borderBottom: '1px solid var(--border-hairline)' }}> <Search className="w-4 h-4" style={{ color: 'var(--fg-3)' }} /> <Command.Input value={q} onValueChange={setQ} placeholder="Müşteri, servis, fatura, ticket ara..." className="flex-1 bg-transparent outline-none font-mono" style={{ fontSize: '12px', letterSpacing: '0.04em', color: 'var(--fg-1)' }} autoFocus /> {loading && <span className="font-mono t-4" style={{ fontSize: '10px' }}>...</span>} <span className="font-mono t-4" style={{ fontSize: '10px' }}>ESC</span> </div>
<Command.List style={{ maxHeight: '420px', overflow: 'auto' }}> {!loading && q.trim().length >= 2 && results.customers.length === 0 && results.services.length === 0 && results.invoices.length === 0 && results.tickets.length === 0 && ( <Command.Empty> <div className="px-4 py-6 text-center font-mono t-4" style={{ fontSize: '11px' }}> SONUÇ YOK </div> </Command.Empty> )}
{q.trim().length < 2 && ( <div className="px-4 py-6 text-center font-mono t-4" style={{ fontSize: '11px' }}> EN AZ 2 KARAKTER GİRİN </div> )}
{results.customers.length > 0 && ( <Command.Group heading="MÜŞTERİLER" className="px-2 py-2"> {results.customers.map((c) => ( <Command.Item key={`c-${c.id}`} value={`customer ${c.name} ${c.email} ${c.company ?? ''}`} onSelect={() => go(`/admin/customers/${c.id}`)} className="flex items-center gap-3 px-3 py-2 cursor-pointer" style={{ fontSize: '12px' }} > <User className="w-3.5 h-3.5" style={{ color: 'var(--brand-teal)' }} /> <div className="flex-1 min-w-0"> <div className="t-1">{c.name}</div> <div className="font-mono t-4" style={{ fontSize: '10px' }}>{c.email}{c.company ? ` · ${c.company}` : ''}</div> </div> </Command.Item> ))} </Command.Group> )}
{results.services.length > 0 && ( <Command.Group heading="SERVİSLER" className="px-2 py-2"> {results.services.map((s) => ( <Command.Item key={`s-${s.id}`} value={`service ${s.name}`} onSelect={() => go(`/admin/services/${s.id}/edit`)} className="flex items-center gap-3 px-3 py-2 cursor-pointer" style={{ fontSize: '12px' }} > <Wrench className="w-3.5 h-3.5" style={{ color: 'var(--brand-blue)' }} /> <span className="t-1">{s.name}</span> </Command.Item> ))} </Command.Group> )}
{results.invoices.length > 0 && ( <Command.Group heading="FATURALAR" className="px-2 py-2"> {results.invoices.map((inv) => ( <Command.Item key={`i-${inv.id}`} value={`invoice ${inv.invoiceNumber} ${inv.title}`} onSelect={() => go(`/admin/invoices/${inv.id}/edit`)} className="flex items-center gap-3 px-3 py-2 cursor-pointer" style={{ fontSize: '12px' }} > <FileText className="w-3.5 h-3.5" style={{ color: 'var(--brand-red)' }} /> <div className="flex-1 min-w-0"> <div className="t-1">{inv.title}</div> <div className="font-mono t-4" style={{ fontSize: '10px' }}>{inv.invoiceNumber}</div> </div> </Command.Item> ))} </Command.Group> )}
{results.tickets.length > 0 && ( <Command.Group heading="TICKET'LAR" className="px-2 py-2"> {results.tickets.map((t) => ( <Command.Item key={`t-${t.id}`} value={`ticket ${t.ticketNumber} ${t.subject}`} onSelect={() => go(`/admin/tickets/${t.id}`)} className="flex items-center gap-3 px-3 py-2 cursor-pointer" style={{ fontSize: '12px' }} > <TicketIcon className="w-3.5 h-3.5" style={{ color: 'var(--brand-amber)' }} /> <div className="flex-1 min-w-0"> <div className="t-1">{t.subject}</div> <div className="font-mono t-4" style={{ fontSize: '10px' }}>{t.ticketNumber}</div> </div> </Command.Item> ))} </Command.Group> )} </Command.List> </Command> </div> );}- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "CommandPalette" | head -5
Expected: çıktı yok.
- Step 3: Commit
git add components/admin/CommandPalette.tsxgit commit -m "feat(admin): Cmd+K command palette bileşeni (cmdk + 4 grup gruplu sonuç)"Task 25: Palette’i admin layout’a mount et
Bölüm başlığı “Task 25: Palette’i admin layout’a mount et”Files:
-
Modify: admin panel layout (path doğrulanmalı)
-
Step 1: Admin panel layout dosyasını bul
Run: find app/\[locale\]/admin -maxdepth 3 -name "layout.tsx" | head -5
Expected: 1-2 path. (panel)/layout.tsx veya benzeri.
- Step 2: En dış admin panel layout’una
<CommandPalette />ekle
Bulunan layout dosyasının en üstte ('use client' direktifi yoksa server component’tir — CommandPalette client component olduğu için doğrudan import edilebilir). Layout JSX’inde {children} yanına <CommandPalette /> mount edilir. Tipik şablon:
import CommandPalette from '@/components/admin/CommandPalette';
// ... mevcut layout fonksiyonu içinde, return'ün sonunda:return ( <div className="..."> {/* mevcut sidebar/topbar/header */} <main>{children}</main> <CommandPalette /> </div>);NOT: Eğer admin layout zaten birçok client component içeriyorsa import sıralaması önemli değil. CommandPalette kendi 'use client' direktifine sahip.
- Step 3: Browser doğrulama
- Admin paneline login ol.
- Herhangi bir admin sayfasında
Cmd+K(Mac) veyaCtrl+K(Windows/Linux) bas. - Palette modal açılmalı, input auto-focus olmalı.
- 2+ karakter yaz — sonuçlar gelir, gruplara ayrılmış.
- Enter / tıklama → ilgili sayfaya gider, palette kapanır.
- Esc / dış tıklama → palette kapanır.
- Step 4: Commit
git add app/[locale]/admin/<bulunan-layout-path>.tsxgit commit -m "feat(admin): Cmd+K palette admin layout'a mount edildi"Faz 8: Session Revoke Endpoint
Bölüm başlığı “Faz 8: Session Revoke Endpoint”Task 26: Session revoke endpoint
Bölüm başlığı “Task 26: Session revoke endpoint”Files:
-
Create:
app/api/admin/customers/[id]/sessions/[sid]/revoke/route.ts -
Step 1: Endpoint’i yaz
Create app/api/admin/customers/[id]/sessions/[sid]/revoke/route.ts:
import prisma from '@/lib/prisma';import { validateAdminSession } from '@/lib/admin-session';import { NextResponse } from 'next/server';
interface RouteParams { params: Promise<{ id: string; sid: string }>;}
export async function POST(req: Request, { params }: RouteParams) { const admin = await validateAdminSession(); if (!admin) return new NextResponse('Unauthorized', { status: 401 });
const { id, sid } = await params; const customerId = parseInt(id); const sessionId = parseInt(sid);
if (!Number.isFinite(customerId) || !Number.isFinite(sessionId)) { return new NextResponse('Bad Request', { status: 400 }); }
// Session'ın gerçekten bu customer'a ait olduğunu doğrula (IDOR koruması) const session = await prisma.customerSession.findUnique({ where: { id: sessionId }, select: { customerId: true }, });
if (!session || session.customerId !== customerId) { return new NextResponse('Not Found', { status: 404 }); }
await prisma.customerSession.delete({ where: { id: sessionId } });
// Audit trail await prisma.customerAuditLog.create({ data: { customerId, action: 'session_revoked_by_admin', target: `session:${sessionId}`, details: `Admin #${admin.id} (${admin.email}) müşteri oturumunu sonlandırdı`, status: 'success', }, });
return NextResponse.json({ ok: true });}NOT: admin objesi validateAdminSession()’tan id ve email döndürmüyorsa, mevcut lib/admin-session.ts imzasına bakıp uyumlu hale getir. Eğer farklıysa details satırı sadeleştirilebilir.
- Step 2: Type-check
Run: npx tsc --noEmit --pretty false 2>&1 | grep "sessions/\[sid\]/revoke" | head -5
Expected: çıktı yok. (Eğer admin objesi farklıysa hata burada görülür.)
- Step 3: Browser doğrulama
- Bir müşteri sessions sekmesine git.
- Aktif oturum varsa “X” butonuna tıkla.
- “EVET” tıkla.
- Network tab’da
POST /api/admin/customers/<id>/sessions/<sid>/revoke200 OK görünmeli. - Sayfa refresh olunca oturum listeden kaybolmalı.
- Aynı müşterinin activity sekmesine git — yeni “SESSION_REVOKED_BY_ADMIN” satırı görünmeli.
- Step 4: Commit
git add app/api/admin/customers/[id]/sessions/[sid]/revoke/route.tsgit commit -m "feat(admin): müşteri oturumu sonlandırma endpoint'i (IDOR korumalı + audit log)"Task 27: validateAdminSession imzası doğrulama
Bölüm başlığı “Task 27: validateAdminSession imzası doğrulama”Files:
- Read:
lib/admin-session.ts
Bu task küçük ama Task 26’da endpoint’in admin id/email’e erişim varsayımını kullandığı için doğrulanmalı. Eğer mevcut imza farklıysa Task 26’daki kod düzeltilir.
- Step 1: lib/admin-session.ts’i oku
Run: Read lib/admin-session.ts. validateAdminSession()’ın return tipi nedir? Şunlar var mı: id, email?
- Step 2: Uyumsuzluk varsa fix
Eğer Task 26’da admin.id veya admin.email mevcut değilse, audit log details satırını basit bir mesaja indirgenir:
details: `Admin tarafından sonlandırıldı`,- Step 3: Commit (gerekiyorsa)
Eğer değişiklik yapıldıysa commit. Yoksa atla.
git add app/api/admin/customers/[id]/sessions/[sid]/revoke/route.tsgit commit -m "fix(admin): session revoke endpoint admin-session imzasıyla uyum"Faz 9: i18n (24 Dil Çeviri)
Bölüm başlığı “Faz 9: i18n (24 Dil Çeviri)”Task 28: i18n çevirilerini dispatch et
Bölüm başlığı “Task 28: i18n çevirilerini dispatch et”Files:
- Modify:
messages/admin/admin_{locale}.json(24 dosya)
NOT: CLAUDE.md kuralı gereği:
- Script YASAK (python/bash/node).
- Minimum 4 paralel agent zorunlu — her agent 6 dil alır (24/4).
- Manuel Edit/Write tool ile satır satır JSON yazımı.
- Profesyonel B2B SaaS terminolojisi.
Eklenecek namespace yapısı (her admin_{locale}.json dosyasında AdminCustomer namespace’i altına):
{ "AdminCustomer": { "tabs": { "overview": "{dil için 'Overview' karşılığı}", "services": "...", "invoices": "...", "emails": "...", "tickets": "...", "activity": "...", "sessions": "...", "files": "...", "subscriptions": "...", "installations": "..." }, "search": { "placeholder": "...", "noResults": "...", "clear": "...", "resultsCount": "{count} sonuç bulundu — '{q}'" }, "commandPalette": { "placeholder": "...", "minChars": "...", "noResults": "...", "groups": { "customers": "...", "services": "...", "invoices": "...", "tickets": "..." } }, "actions": { "edit": "...", "delete": "...", "revokeSession": "...", "confirmRevoke": "...", "downloadPdf": "...", "openInStripe": "...", "openPortal": "...", "retry": "..." }, "emptyStates": { "noServices": "...", "noInvoices": "...", "noEmails": "...", "noTickets": "...", "noActivity": "...", "noSessions": "...", "noFiles": "...", "noSubscriptions": "...", "noInstallations": "..." }, "columns": { "name": "...", "amount": "...", "status": "...", "createdAt": "...", "lastMessage": "...", "ipAddress": "...", "userAgent": "...", "expiresAt": "...", "priority": "...", "template": "...", "subject": "..." } }}NOT: Bu plan’daki tab’lar şu an Türkçe hardcoded etiketler kullanıyor (Task 4: CustomerDetailTabs.tsx içinde label: 'SERVİSLER' gibi). Bu task’tan ÖNCE veya BİRLİKTE tab bileşenleri useTranslations('AdminCustomer.tabs') ile değiştirilmeli. Bu refactor da i18n dispatch sırasında yapılır.
- Step 1: 4 subagent dispatch et (paralel)
Mesajda dört Agent tool çağrısı aynı anda gönderilir — her agent 6 dil alır:
- Agent 1: en, tr, de, fr, es, it
- Agent 2: nl, pl, ru, ar, pt, ro
- Agent 3: cs, sk, hu, el, he, hr
- Agent 4: sr, bs, sq, mk, lt, sv
Her agent’a verilecek prompt iskeleti:
Aşağıdaki 6 dil için /var/www/vhosts/ecutuningportal.com/httpdocs/messages/admin/admin_{locale}.jsondosyalarına AdminCustomer namespace'i ekle:
Diller: <ilgili 6 dil>
Eklenecek tam JSON yapısı:<yukarıdaki AdminCustomer bloğunun TR örnek değerleri ile dolu hali>
KURALLAR:1. Script YASAK — Edit veya Write tool ile manuel yaz.2. Her dilin profesyonel B2B SaaS terminolojisini kullan.3. Mevcut admin_{locale}.json dosyasının başka namespace'lerine dokunma — sadece AdminCustomer ekle.4. Placeholder İngilizce bırakma — her key gerçekten o dilde çevrilmeli.5. {count} ve {q} gibi placeholder'lar her dilde aynı isimde kalır.6. Her dosya işlemi sonrası JSON validity'sini doğrula: `node -e "JSON.parse(require('fs').readFileSync('path'))"`.- Step 2: Doğrulama agent’ı dispatch et
Tüm 4 agent bittikten sonra 5. agent:
-
24 dosyada
AdminCustomernamespace varlığını kontrol et -
JSON validity (her dosya için
JSON.parse) -
Placeholder İngilizce taraması (örn. tüm dosyalarda “Overview” geçerse Türkçe çevirisi unutulmuş demektir)
-
Eksik key tespiti (template ile karşılaştır)
-
Step 3: CustomerDetailTabs ve diğer UI’larda hardcoded etiketleri i18n key’lerine çevir
Bu adım dispatch sonrası ana session’da yapılır:
components/admin/CustomerDetailTabs.tsx içinde:
import { useTranslations } from 'next-intl';// ...export default function CustomerDetailTabs({ customerId, counts }: Props) { const t = useTranslations('AdminCustomer.tabs'); const TABS: TabDef[] = [ { slug: '', label: t('overview') }, { slug: 'services', label: t('services'), countKey: 'services' }, // ... vs. ]; // ...}Aynı şekilde CustomerSearchInput, CommandPalette, tab page’lerindeki boş state mesajları, kolon başlıkları, aksiyon butonları i18n key’lerine bağlanır.
- Step 4: Type-check + browser doğrulama
Run: npx tsc --noEmit --pretty false 2>&1 | head -20
Expected: hata yok.
Browser’da 3 dilde test et (tr, en, ar — RTL kontrolü için ar):
http://49.12.188.137:3077/tr/admin/customershttp://49.12.188.137:3077/en/admin/customershttp://49.12.188.137:3077/ar/admin/customers
Her sayfada tab adları, arama placeholder’ı, palette grup başlıkları o dilde görünmeli.
- Step 5: Commit
git add messages/admin/ components/admin/CustomerDetailTabs.tsx components/admin/CustomerSearchInput.tsx components/admin/CommandPalette.tsx app/[locale]/admin/(panel)/customers/git commit -m "feat(admin/i18n): müşteri detay tab'ları + arama + palette için 24 dil çevirisi"Final Verification
Bölüm başlığı “Final Verification”Task 29: Build + smoke test
Bölüm başlığı “Task 29: Build + smoke test”- Step 1: Production build
Run: npx next build 2>&1 | tail -30
Expected: build success, customer route’larında compile hatası yok.
- Step 2: Build sonrası ownership fix + restart
Run: chown -R yigit:yigit .next/ .env && pm2 restart ecutuningportal 2>&1 | tail -5
Expected: PM2 process online status.
- Step 3: Tüm test’leri çalıştır
Run: npm test 2>&1 | tail -20
Expected: tüm test’ler PASS, özellikle yeni eklenen admin-customer-search.test.ts ve admin-global-search.test.ts.
- Step 4: Production smoke test
Browser’da https://ecutuningportal.com/tr/admin/customers/43/services (spec’teki örnek URL):
-
10 sekmeli tab bar görünmeli
-
Müşteri özet kartı doğru
-
Tab’lar arası geçiş çalışmalı
-
Cmd+K palette açılmalı
-
Liste sayfasında arama çalışmalı
-
Step 5: Final commit (varsa)
Build artifact’leri commit edilmez. Bu adım sadece her şeyin commit edilip edilmediğini kontrol için:
Run: git status --short
Expected: çıktı boş veya sadece unrelated dosyalar.
Spec Coverage Self-Check
Bölüm başlığı “Spec Coverage Self-Check”Spec sayfa sayfa kontrol:
| Spec maddesi | Karşılayan task |
|---|---|
| §1 Route yapısı | Task 6, 7, 10 |
| §2 Layout + ortak header | Task 4, 5, 6 |
| §3 Liste arama | Task 3, 19, 20, 21 |
| §4 Cmd+K palette | Task 22, 23, 24, 25 |
| §5 Overview tab | Task 9 |
| §5 Services tab refactor | Task 8 |
| §5 Invoices/Emails/Tickets/Activity | Task 11, 12, 13, 14 |
| §5 Sessions/Files/Subs/Installations | Task 15, 16, 17, 18 |
| §6 Loading/error standardı | Task 1, 2, 7, 10 |
| §7 i18n | Task 28 |
| §8 Yeni API endpoints | Task 23, 26 |
| §10 Güvenlik (auth + IDOR + audit) | Task 23, 26 |
| §11 Edge cases (boş, not found, 0 sonuç) | Task 6, 9, 11–18, 21 |
| §13 Açık sorular | Plan başında schema okuması ile kapatıldı |
Notlar
Bölüm başlığı “Notlar”Customer.ticketsrelation çakışması: Customer modelindetickets Ticket[]vardı; layout’taki_count.ticketsdoğrudan bunu kullanır. EmailLog’da relation tanımı olmadığı için ayrı sorgu (Task 6’dakiPromise.all).- BigInt:
File.fileSizeBigInt — Task 16’dakihumanSize()helper’ıNumber()cast eder. 2GB üstü dosyalar için precision kaybı yok (sadece formatlama). - Ticket.id String (cuid): Service/Invoice/File int, ama Ticket cuid. CommandPalette’te
id: stringolarak doğru tip. - Subscription.amount Int (cents): Task 17’de
(amount / 100).toFixed(2)ile EUR formatına çevriliyor. - EmailLog filtering by email vs customerId: Schema’da
customerId Int?direkt FK var. Yine de bazı eski kayıtlar email-only olabilir. Faz sonrası iyileştirme:OR: [{ customerId }, { recipient: customer.email }]ile genişletilebilir (bu plan kapsamı dışında, opsiyonel). - i18n etiket refactor (Task 28 step 3): Tüm hardcoded TR string’ler bu adımda key’lere bağlanır. Daha önce yazılan task’lardaki örnek string’ler (
'SERVİSLER'vs.) Task 28’de translation key’leri ile değiştirilir.