Admin Müşteri Detay Sayfası & Global Arama — Tasarım
DerinTarih: 2026-05-11
Durum: Onaylandı, implementation plan bekliyor
Kapsam: app/[locale]/admin/(panel)/customers/** + global Cmd+K palette
Bağlam: httpdocs/ (Next.js, ecutuningportal.com ana portal)
Admin panelindeki müşteri yönetimini “düz liste + edit + servisler” üçlüsünden çıkarıp tek müşteri için her şeyin (faturalar, e-postalar, ticket’lar, oturumlar, dosyalar, abonelikler, portal kurulumları, audit log) tek yerden görüldüğü tab’lı bir detay sayfasına dönüştürmek. Aynı zamanda liste sayfasına inline arama ve admin layout’una global Cmd+K command palette eklemek.
Mevcut Durum (baseline)
Bölüm başlığı “Mevcut Durum (baseline)”app/[locale]/admin/(panel)/customers/├── page.tsx ← liste, filter tabs (all/active/passive), pagination├── create/page.tsx└── [id]/ ├── edit/page.tsx └── services/page.tsx ← şu an "müşteri detayı" sayılabilecek tek alt sayfaloading.tsx ve error.tsx dosyaları hiçbir customers route’unda yok — eklenecek.
Customer modelinde mevcut ilişkiler (Prisma):
sessions→CustomerSession[]services→Service[]files→File[]invoices→Invoice[]developmentRequests→DevelopmentRequest[]tickets→Ticket[]trialCredentials→TrialCredential[]portalInstallations→PortalInstallation[]subscriptions→Subscription[]auditLogs→CustomerAuditLog[]
EmailLog.customerId Int? direkt FK olarak mevcut (@@index([customerId]) var), ancak Customer modelinde reverse relation tanımlı değil. Sorgu doğrudan prisma.emailLog.findMany({ where: { customerId } }) ile yapılır.
Karar Özeti
Bölüm başlığı “Karar Özeti”| Karar | Tercih | Neden |
|---|---|---|
| Tab kapsamı | 10 tab (Özet, Servis, Fatura, E-posta, Ticket, Aktivite, Oturum, Dosya, Abonelik, Portal Kurulum) | “Müşteriyle ilgili her şey tek yerde” gereksinimi |
| Route mimarisi | Nested layout.tsx + her tab kendi page.tsx+loading.tsx+error.tsx | Next.js best practice; layout re-render olmaz; lazy SSR; deep-link |
| Liste araması | Debounced URL ?q= + SSR ILIKE OR | SEO/share friendly; mevcut SSR yapısıyla uyumlu |
| Global arama | Cmd+K command palette (cmdk paketi) | Admin’in nerede olduğundan bağımsız hızlı erişim |
| Arama alanları | name, email, company, phone, vatId, stripeCustomerId, billingCity, countryIso | Geniş kapsam; mevcut dataset büyüklüğü için ILIKE yeterli |
1. Route Yapısı
Bölüm başlığı “1. Route Yapısı”app/[locale]/admin/(panel)/customers/├── page.tsx ← MODIFIED: ?q= arama parametresi├── loading.tsx ← NEW├── error.tsx ← NEW├── create/│ ├── page.tsx ← unchanged│ ├── loading.tsx ← NEW│ └── error.tsx ← NEW└── [id]/ ├── layout.tsx ← NEW: müşteri kartı + 10 sekmeli tab bar ├── loading.tsx ← NEW ├── error.tsx ← NEW ├── page.tsx ← NEW: Overview (default tab) ├── edit/ │ ├── page.tsx ← unchanged │ ├── loading.tsx ← NEW │ └── error.tsx ← NEW ├── services/ │ ├── page.tsx ← REFACTORED: header/back link kaldırılır (layout'a taşınır) │ ├── loading.tsx ← NEW │ └── error.tsx ← NEW ├── invoices/{page,loading,error}.tsx ← NEW ├── emails/{page,loading,error}.tsx ← NEW ├── tickets/{page,loading,error}.tsx ← NEW ├── activity/{page,loading,error}.tsx ← NEW ├── sessions/{page,loading,error}.tsx ← NEW ├── files/{page,loading,error}.tsx ← NEW ├── subscriptions/{page,loading,error}.tsx ← NEW └── installations/{page,loading,error}.tsx ← NEW2. [id]/layout.tsx — Ortak Header + Tab Bar
Bölüm başlığı “2. [id]/layout.tsx — Ortak Header + Tab Bar”Sorumluluğu:
- Müşterinin temel verisini ve tüm tab count’larını tek sorguda çek.
- Müşteri özet kartını render et.
- Aktif tab’ı
usePathname()ile belirleyip tab bar göster. childrenüzerinden aktif tab’ınpage.tsx’ini mount et.
Tek sorgu (N+1’i önle):
const customer = await 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, }, }, },});
// EmailLog Customer'da relation olmadığı için ayrı count:const emailCount = await prisma.emailLog.count({ where: { customerId },});
if (!customer) notFound();Müşteri özet kartı içeriği:
- Avatar (gradient + initials, liste sayfasındaki helper’la birebir)
- name (büyük), email + phone (mono)
- company, country flag emoji, locale badge
- Status badge (AKTİF/PASİF), email verified rozet, 2FA rozet
- Kayıt tarihi, son güncelleme, son login (son session veya audit log)
- Aksiyon butonları: Düzenle, E-posta Gönder (ileri faz), Pasifleştir / Aktifleştir, Sil (mevcut
DeleteCustomerButton)
Tab bar bileşeni:
components/admin/CustomerDetailTabs.tsx — client component. Props: customerId, counts: Record<TabId, number>. usePathname() ile aktif tab’ı tespit eder. 10 tab:
| Slug | Etiket | Count alanı |
|---|---|---|
| “ (index) | ÖZET | — |
services | SERVİSLER | services |
invoices | FATURALAR | invoices |
emails | E-POSTA | emails (ayrı count) |
tickets | TICKET | tickets |
activity | AKTİVİTE | auditLogs |
sessions | OTURUM | sessions |
files | DOSYA | files |
subscriptions | ABONELİK | subscriptions |
installations | KURULUM | portalInstallations |
Tasarım dili: FilterTabs primitive ile uyumlu. Sayı badge’leri font-mono, küçük puntoda, tab etiketinin sağında.
3. Liste Sayfası Arama (customers/page.tsx)
Bölüm başlığı “3. Liste Sayfası Arama (customers/page.tsx)”URL şeması: ?q=foo&filter=active&page=1
Component değişikliği:
- Yeni:
components/admin/CustomerSearchInput.tsx— client component,ChamferedInputprimitive ile. - 300 ms debounce +
useTransitionilerouter.replace(...)çağrısı → SSR re-fetch tetiklenir, blocking UI yok. - Mevcut
CustomerFilterTabsile aynı satırda, sol tarafta arama input.
Server WHERE clause (kombinasyon):
const where: Prisma.CustomerWhereInput = { ...(filter === 'active' ? { isActive: true } : {}), ...(filter === 'passive' ? { isActive: false } : {}), ...(q ? { OR: [ { name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }, { company: { contains: q, mode: 'insensitive' } }, { phone: { contains: q, mode: 'insensitive' } }, { vatId: { contains: q, mode: 'insensitive' } }, { stripeCustomerId: { contains: q, mode: 'insensitive' } }, { billingCity: { contains: q, mode: 'insensitive' } }, { countryIso: { contains: q, mode: 'insensitive' } }, ], } : {}),};UI ipuçları:
- Arama aktifken başlığın altında “X sonuç bulundu —
q” + “Aramayı temizle” linki (?filter=...ile q çıkarılmış). - 0 sonuç boş durumu:
TechCardile “Aramayla eşleşen müşteri yok” + temizleme CTA.
4. Global Cmd+K Palette
Bölüm başlığı “4. Global Cmd+K Palette”Yer: Admin layout’a mount (örn. app/[locale]/admin/(panel)/layout.tsx veya en yakın admin shell).
Bileşen: components/admin/CommandPalette.tsx — cmdk paketi (~3KB gzipped, headless, Industrial Design System ile stillenir).
Davranış:
- Global keydown:
Cmd/Ctrl+K→ modal aç. - Esc / blur → kapat.
- Yazıldıkça
fetch('/api/admin/search?q=' + encodeURIComponent(q) + '&limit=8')(debounced 200 ms). - Sonuçlar gruplu: Müşteriler, Servisler, Faturalar, Ticket’lar. Her grup max 8 satır.
- Enter / tıklama → ilgili admin URL’sine yönlendir:
- Customer →
/admin/customers/{id} - Service →
/admin/services/{id}/edit - Invoice →
/admin/invoices/{id} - Ticket →
/admin/tickets/{id}
- Customer →
API endpoint: GET /api/admin/search?q=&limit=8
export async function GET(req: Request) { const admin = await validateAdminSession(); if (!admin) return new Response('Unauthorized', { status: 401 });
const url = new URL(req.url); const q = url.searchParams.get('q')?.trim() ?? ''; const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '8'), 20);
if (q.length < 2) return Response.json({ customers: [], services: [], invoices: [], tickets: [] });
const [customers, services, invoices, tickets] = await Promise.all([ prisma.customer.findMany({ where: { OR: [ { name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }, { company: { contains: q, mode: 'insensitive' } }, ], }, select: { id: true, name: true, email: true, company: true }, take: limit, }), prisma.service.findMany({ where: { name: { contains: q, mode: 'insensitive' } }, select: { id: true, name: true, customerId: true }, take: limit, }), prisma.invoice.findMany({ where: { OR: [ { invoiceNumber: { contains: q, mode: 'insensitive' } }, // diğer aranabilir alanlar şema doğrulamasıyla netleşir ], }, select: { id: true, invoiceNumber: true }, take: limit, }), prisma.ticket.findMany({ where: { OR: [ { subject: { contains: q, mode: 'insensitive' } }, // ticketNumber varsa eklenir ], }, select: { id: true, subject: true }, take: limit, }), ]);
return Response.json({ customers, services, invoices, tickets });}Rate limit: lib/rate-limit.ts ile admin başına 30 req/dk (eğer mevcut admin endpoint pattern’i varsa onu uygula).
5. Tab İçerikleri Detayı
Bölüm başlığı “5. Tab İçerikleri Detayı”5.1 Overview ([id]/page.tsx)
Bölüm başlığı “5.1 Overview ([id]/page.tsx)”- StatCard ×4: toplam servis, toplam fatura tutarı, açık ticket sayısı, son 30 gün e-posta sayısı.
- Mini timeline: son 5 audit log.
- Mini list: son 5 fatura (ödenmemiş highlight).
- Mini list: son 3 ticket (status badge).
5.2 Servisler ([id]/services/page.tsx)
Bölüm başlığı “5.2 Servisler ([id]/services/page.tsx)”Mevcut sayfa kodu küçültülerek taşınır: <SectionHeading>, “Müşterilere Dön” link, müşteri özet kartı kaldırılır (layout’ta var). Sadece tablo + boş durum kalır.
5.3 Faturalar ([id]/invoices/page.tsx)
Bölüm başlığı “5.3 Faturalar ([id]/invoices/page.tsx)”- Üstte StatCard ×2: toplam ciro, ödenmemiş tutar.
- Tablo: invoice number, tarih, tutar, status badge, “PDF” indir link, “Düzenle” link.
5.4 E-postalar ([id]/emails/page.tsx)
Bölüm başlığı “5.4 E-postalar ([id]/emails/page.tsx)”- Tablo: template, subject, status (
pending/sent/bounced/failed), date, “Yeniden Gönder” link. - Status badge renkleri: sent→teal, pending→neutral, failed/bounced→red.
- Filter: template, status.
5.5 Ticket’lar ([id]/tickets/page.tsx)
Bölüm başlığı “5.5 Ticket’lar ([id]/tickets/page.tsx)”- Tablo: subject, kategori, status (
open/closed/pending), öncelik (varsa), son mesaj zamanı, açılış tarihi. - Satır tıklama →
/admin/tickets/{id}.
5.6 Aktivite ([id]/activity/page.tsx)
Bölüm başlığı “5.6 Aktivite ([id]/activity/page.tsx)”- Timeline view: CustomerAuditLog kayıtları.
- Her satır: action (icon + Türkçe etiket), target, IP, UA parse (browser+OS), status, tarih.
- Filter: action (login, login_failed, password_change, 2fa_*, profile_update, …).
- Pagination (50 satır/sayfa).
5.7 Oturumlar ([id]/sessions/page.tsx)
Bölüm başlığı “5.7 Oturumlar ([id]/sessions/page.tsx)”- Tablo: IP, UA parse, oluşturulma, expiresAt, “Sonlandır” buton.
- “Sonlandır” →
POST /api/admin/customers/[id]/sessions/[sid]/revoke→ CustomerSession sil. - Süresi dolmuşları ayrı bölümde göster (soluk).
5.8 Dosyalar ([id]/files/page.tsx)
Bölüm başlığı “5.8 Dosyalar ([id]/files/page.tsx)”- Tablo: ad, boyut (human), mime, upload date, “İndir” link.
- Şema doğrulaması: File model alanlarına göre kolonlar netleştirilecek (file path / S3 key dahil mi?).
5.9 Abonelikler ([id]/subscriptions/page.tsx)
Bölüm başlığı “5.9 Abonelikler ([id]/subscriptions/page.tsx)”- Card grid: her abonelik için plan adı, status, billing cycle, mevcut dönem, Stripe link.
- Şema doğrulamasıyla Subscription alanları netleştirilecek.
5.10 Portal Kurulumları ([id]/installations/page.tsx)
Bölüm başlığı “5.10 Portal Kurulumları ([id]/installations/page.tsx)”- Card list: domain, status (kurulum tamam/beklemede/hata), kurulum tarihi, log link.
- PortalInstallation model alanları okunarak kolonlar netleştirilecek.
6. Loading & Error Standardı
Bölüm başlığı “6. Loading & Error Standardı”Her page.tsx yanına:
import { Loader2 } from 'lucide-react';export default function Loading() { return ( <div className="p-6 flex items-center justify-center min-h-[400px]"> <Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--brand-red)' }} /> </div> );}'use client';import { AlertCircle } from 'lucide-react';export default function Error({ error, reset }: { error: Error; reset: () => void }) { 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">Yüklenemedi</p> <p className="t-3 text-[12px] mb-4">{error.message}</p> <button onClick={reset} className="btn-wrap btn--primary btn--sm"> <span className="btn-inner">Tekrar Dene</span> </button> </TechCard> </div> );}Kanon: app/[locale]/customer/(portal)/billing/page.tsx deseni.
7. i18n
Bölüm başlığı “7. i18n”Namespace: messages/admin/admin_{locale}.json → AdminCustomer altına ek:
tabs.overview,tabs.services,tabs.invoices,tabs.emails,tabs.tickets,tabs.activity,tabs.sessions,tabs.files,tabs.subscriptions,tabs.installationssearch.placeholder,search.noResults,search.clear,search.resultsCountcommandPalette.placeholder,commandPalette.groups.{customers,services,invoices,tickets}- Tüm tab’lar için boş durum mesajları, kolon başlıkları, aksiyon butonları
Kural (CLAUDE.md zorunlu): 24 dil için manuel çeviri, minimum 4 paralel agent, script yasak, profesyonel B2B SaaS terminolojisi.
8. Yeni API Endpoint’leri
Bölüm başlığı “8. Yeni API Endpoint’leri”| Endpoint | Amaç | Auth |
|---|---|---|
GET /api/admin/search?q=&limit= | Cmd+K palette global arama | adminSession |
POST /api/admin/customers/[id]/sessions/[sid]/revoke | Oturum sonlandırma | adminSession + audit log |
İleri faz (bu spec dışında bırakılabilir):
POST /api/admin/customers/[id]/email— manuel e-posta tetikle.
9. Performans Hesabı
Bölüm başlığı “9. Performans Hesabı”- Layout query: tek
findUnique + _count(8 ilişki için JOIN’siz subquery) → DB için tek round-trip, indexed FK’lar üzerinden hızlı. - EmailLog count: ayrı sorgu (
@@index([customerId])mevcut). Layout’taPromise.allile parallel. - Liste arama (
ILIKE): Dataset şu an küçük;ORclause 8 alanda full table scan’e yakın. 50K+ müşteriye ulaşıncapg_trgmGIN index gerekir (gelecek iyileştirme, bu spec dışında). - Command palette: 200 ms debounce +
q.length < 2ise erken return → gereksiz query önlenir. 4 parallel sorgu, her biritake: 8. - Sayfa başına re-render:
layout.tsxURL değişiminde re-render olmaz; sadece aktif tab’ınpage.tsxSSR’de yeniden çalışır.
10. Güvenlik
Bölüm başlığı “10. Güvenlik”- Tüm yeni route’lar
validateAdminSession()ile korunur (mevcut pattern). - Search endpoint admin-only.
- Session revoke endpoint: CSRF token (state-changing, CLAUDE.md zorunlu), audit log kaydı (
adminAction: 'session_revoke'). - Arama input’u Prisma parametreli — SQL injection korumalı.
11. Edge Cases
Bölüm başlığı “11. Edge Cases”| Durum | Davranış |
|---|---|
| Customer ID bulunamadı | notFound() (Next.js 404 sayfası) |
| Tab verisi boş | TechCard empty state + ilgili CTA |
| Arama 0 sonuç | ”Aramayla eşleşen müşteri yok” + temizleme link |
| Arama çok hızlı yazılıyor | 300 ms debounce → sadece son input gönderilir |
| Cmd+K query < 2 karakter | API çağrısı yapılma, “En az 2 karakter yazın” mesajı |
| Aktif tab silinmiş bir rota | Layout’ta 10 tab sabit; URL bilinmiyorsa Next.js 404 |
| User’ın hiç session’ı yok | ”Aktif oturum yok” empty state |
| EmailLog’da Customer ile match yoksa | Tab’da “E-posta gönderilmemiş” empty state |
12. Implementation Sırası
Bölüm başlığı “12. Implementation Sırası”- Schema audit + altyapı: Invoice, Ticket, File, Subscription, PortalInstallation modellerini okuyup tab kolonlarını kesinleştir.
loading.tsx/error.tsxtemplate’lerini hazırla. [id]/layout.tsx+ müşteri özet kartı +CustomerDetailTabscomponent + 10 count query.- Overview tab (
[id]/page.tsx). - Servisler tab refactor — mevcut sayfayı layout’a uyumlu hale getir.
- Yüksek değerli tab’lar: Invoices, Emails, Tickets, Activity.
- Kalan tab’lar: Sessions, Files, Subscriptions, Installations.
- Liste arama (
?q=+CustomerSearchInput). - Cmd+K palette +
/api/admin/searchendpoint. - i18n çevirileri — 24 dil, 4+ paralel agent (CLAUDE.md kuralı).
- Session revoke endpoint + UI hook.
13. Açık Sorular (Implementation öncesi netleşecek)
Bölüm başlığı “13. Açık Sorular (Implementation öncesi netleşecek)”- Invoice modelinde
invoiceNumber, status alanları, hangi PDF endpoint mevcut? - Ticket modelinde
ticketNumberveya benzer aranabilir alan var mı? - File modelinde storage path/URL alan adı nedir?
- Subscription modelinde Stripe ile sync alanları?
- PortalInstallation status enum değerleri?
pg_trgmextension ileride aktivasyonu hangi migration ile yapılacak? (bu spec dışında not.)
14. Trade-off’lar
Bölüm başlığı “14. Trade-off’lar”cmdkpaketi: Yeni dependency (~3KB gzipped) — admin bundle’a yansır, customer/landing’i etkilemez. Alternatif (headless, custom) olabilir ama maliyet/fayda dengesicmdklehine.- 10 tab: UX olarak çok tab gibi görünebilir; ancak gereksinim “her şey tek yerde” olduğu için kabul. İlerideki iyileştirme: az kullanılan tab’lar (Installations gibi) Overview’a card olarak gömülüp tab’dan çıkarılabilir.
- EmailLog count ayrı query: Schema’ya
customer Customer? @relation(...)eklenmediği sürece_count’a dahil edilemez. Bu spec şema değişikliği yapmaz; iki sorgu Promise.all ile parallel çalıştığı için maliyet ihmal edilebilir.
15. Tasarım Sistem Uyumu
Bölüm başlığı “15. Tasarım Sistem Uyumu”- Primitives kullanılır:
SectionHeading,StatCard,TechCard,IndustrialBadge,ChamferedInput,FilterTabs,IndustrialButton,PrefixLabel. - Hardcoded hex/rgb yasak:
var(--brand-red),var(--brand-teal),var(--fg-1..4),var(--border-hairline)kullan. - Style kaynağı:
docs/admin-design-system/STYLE.md.