İçeriğe geç

Admin Müşteri Detay Sayfası & Global Arama — Tasarım

Derin

Tarih: 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.

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 sayfa

loading.tsx ve error.tsx dosyaları hiçbir customers route’unda yok — eklenecek.

Customer modelinde mevcut ilişkiler (Prisma):

  • sessionsCustomerSession[]
  • servicesService[]
  • filesFile[]
  • invoicesInvoice[]
  • developmentRequestsDevelopmentRequest[]
  • ticketsTicket[]
  • trialCredentialsTrialCredential[]
  • portalInstallationsPortalInstallation[]
  • subscriptionsSubscription[]
  • auditLogsCustomerAuditLog[]

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.

KararTercihNeden
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 mimarisiNested layout.tsx + her tab kendi page.tsx+loading.tsx+error.tsxNext.js best practice; layout re-render olmaz; lazy SSR; deep-link
Liste aramasıDebounced URL ?q= + SSR ILIKE ORSEO/share friendly; mevcut SSR yapısıyla uyumlu
Global aramaCmd+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, countryIsoGeniş kapsam; mevcut dataset büyüklüğü için ILIKE yeterli
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 ← NEW

Sorumluluğu:

  1. Müşterinin temel verisini ve tüm tab count’larını tek sorguda çek.
  2. Müşteri özet kartını render et.
  3. Aktif tab’ı usePathname() ile belirleyip tab bar göster.
  4. children üzerinden aktif tab’ın page.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:

SlugEtiketCount alanı
“ (index)ÖZET
servicesSERVİSLERservices
invoicesFATURALARinvoices
emailsE-POSTAemails (ayrı count)
ticketsTICKETtickets
activityAKTİVİTEauditLogs
sessionsOTURUMsessions
filesDOSYAfiles
subscriptionsABONELİKsubscriptions
installationsKURULUMportalInstallations

Tasarım dili: FilterTabs primitive ile uyumlu. Sayı badge’leri font-mono, küçük puntoda, tab etiketinin sağında.

URL şeması: ?q=foo&filter=active&page=1

Component değişikliği:

  • Yeni: components/admin/CustomerSearchInput.tsx — client component, ChamferedInput primitive ile.
  • 300 ms debounce + useTransition ile router.replace(...) çağrısı → SSR re-fetch tetiklenir, blocking UI yok.
  • Mevcut CustomerFilterTabs ile 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: TechCard ile “Aramayla eşleşen müşteri yok” + temizleme CTA.

Yer: Admin layout’a mount (örn. app/[locale]/admin/(panel)/layout.tsx veya en yakın admin shell).

Bileşen: components/admin/CommandPalette.tsxcmdk 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}

API endpoint: GET /api/admin/search?q=&limit=8

app/api/admin/search/route.ts
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).

  • 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).

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.

  • Üstte StatCard ×2: toplam ciro, ödenmemiş tutar.
  • Tablo: invoice number, tarih, tutar, status badge, “PDF” indir link, “Düzenle” link.
  • 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.
  • Tablo: subject, kategori, status (open/closed/pending), öncelik (varsa), son mesaj zamanı, açılış tarihi.
  • Satır tıklama → /admin/tickets/{id}.
  • 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).
  • 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).
  • 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?).
  • 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.

Her page.tsx yanına:

loading.tsx
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>
);
}
error.tsx
'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.

Namespace: messages/admin/admin_{locale}.jsonAdminCustomer altına ek:

  • tabs.overview, tabs.services, tabs.invoices, tabs.emails, tabs.tickets, tabs.activity, tabs.sessions, tabs.files, tabs.subscriptions, tabs.installations
  • search.placeholder, search.noResults, search.clear, search.resultsCount
  • commandPalette.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.

EndpointAmaçAuth
GET /api/admin/search?q=&limit=Cmd+K palette global aramaadminSession
POST /api/admin/customers/[id]/sessions/[sid]/revokeOturum sonlandırmaadminSession + audit log

İleri faz (bu spec dışında bırakılabilir):

  • POST /api/admin/customers/[id]/email — manuel e-posta tetikle.
  • 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’ta Promise.all ile parallel.
  • Liste arama (ILIKE): Dataset şu an küçük; OR clause 8 alanda full table scan’e yakın. 50K+ müşteriye ulaşınca pg_trgm GIN index gerekir (gelecek iyileştirme, bu spec dışında).
  • Command palette: 200 ms debounce + q.length < 2 ise erken return → gereksiz query önlenir. 4 parallel sorgu, her biri take: 8.
  • Sayfa başına re-render: layout.tsx URL değişiminde re-render olmaz; sadece aktif tab’ın page.tsx SSR’de yeniden çalışır.
  • 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ı.
DurumDavranış
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ıyor300 ms debounce → sadece son input gönderilir
Cmd+K query < 2 karakterAPI çağrısı yapılma, “En az 2 karakter yazın” mesajı
Aktif tab silinmiş bir rotaLayout’ta 10 tab sabit; URL bilinmiyorsa Next.js 404
User’ın hiç session’ı yok”Aktif oturum yok” empty state
EmailLog’da Customer ile match yoksaTab’da “E-posta gönderilmemiş” empty state
  1. Schema audit + altyapı: Invoice, Ticket, File, Subscription, PortalInstallation modellerini okuyup tab kolonlarını kesinleştir. loading.tsx/error.tsx template’lerini hazırla.
  2. [id]/layout.tsx + müşteri özet kartı + CustomerDetailTabs component + 10 count query.
  3. Overview tab ([id]/page.tsx).
  4. Servisler tab refactor — mevcut sayfayı layout’a uyumlu hale getir.
  5. Yüksek değerli tab’lar: Invoices, Emails, Tickets, Activity.
  6. Kalan tab’lar: Sessions, Files, Subscriptions, Installations.
  7. Liste arama (?q= + CustomerSearchInput).
  8. Cmd+K palette + /api/admin/search endpoint.
  9. i18n çevirileri — 24 dil, 4+ paralel agent (CLAUDE.md kuralı).
  10. 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 ticketNumber veya 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_trgm extension ileride aktivasyonu hangi migration ile yapılacak? (bu spec dışında not.)
  • cmdk paketi: Yeni dependency (~3KB gzipped) — admin bundle’a yansır, customer/landing’i etkilemez. Alternatif (headless, custom) olabilir ama maliyet/fayda dengesi cmdk lehine.
  • 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.
  • 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.