İçeriğe geç

Admin Müşteri Detay Sayfası & Global Arama — Implementation Plan

Derin

For 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/


FazKapsamTask Sayısı
1Foundation: ortak template’ler + test helper3
2[id]/layout.tsx + müşteri kartı + tab bar4
3Overview tab + Services refactor3
4Yüksek değerli tab’lar (Invoices, Emails, Tickets, Activity)4
5Kalan tab’lar (Sessions, Files, Subscriptions, Installations)4
6Liste sayfası arama (?q=)3
7Cmd+K palette + /api/admin/search4
8Session revoke endpoint + UI2
9i18n çevirileri (24 dil, 4+ paralel agent)1 (dispatch)

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
Terminal window
git add components/admin/CustomerLoadingState.tsx components/admin/CustomerErrorState.tsx
git commit -m "feat(admin): müşteri yönetimi için ortak loading/error bileşenleri"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/loading.tsx app/[locale]/admin/(panel)/customers/error.tsx
git commit -m "feat(admin): müşteri listesi loading/error sayfaları"

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
Terminal window
git add lib/admin-customer-search.ts lib/admin-customer-search.test.ts
git commit -m "feat(admin): müşteri arama WHERE clause builder (8 alan OR)"

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
Terminal window
git add components/admin/CustomerDetailTabs.tsx
git commit -m "feat(admin): müşteri detay 10-sekmeli tab bar bileşeni"

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
Terminal window
git add components/admin/CustomerSummaryCard.tsx
git 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
Terminal window
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.tsx
git 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
Terminal window
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ı"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/services/page.tsx
git commit -m "refactor(admin): müşteri servisler sayfasını layout'a uyumlu yap (duplicate header kaldırıldı)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/page.tsx
git commit -m "feat(admin): müşteri detay özet sekmesi (StatCard+son fatura/ticket/aktivite)"

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
Terminal window
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.tsx
git commit -m "feat(admin): müşteri edit/create sayfaları için loading/error"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/invoices/page.tsx
git commit -m "feat(admin): müşteri faturalar sekmesi (paid/pending stat + tablo + PDF)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/emails/page.tsx
git commit -m "feat(admin): müşteri e-posta logları sekmesi (template/status/pagination)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/tickets/page.tsx
git commit -m "feat(admin): müşteri ticket'lar sekmesi (status/priority/mesaj sayısı)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/activity/page.tsx
git commit -m "feat(admin): müşteri aktivite sekmesi (audit log + action filter + pagination)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/sessions/page.tsx components/admin/RevokeSessionButton.tsx
git commit -m "feat(admin): müşteri oturumlar sekmesi + revoke buton (UI)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/files/page.tsx
git 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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/subscriptions/page.tsx
git 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
Terminal window
git add app/[locale]/admin/(panel)/customers/[id]/installations/page.tsx
git commit -m "feat(admin): müşteri portal kurulumları sekmesi (card grid)"

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
Terminal window
git add components/admin/CustomerSearchInput.tsx
git commit -m "feat(admin): müşteri arama input bileşeni (debounced URL update)"

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:

  1. Import ekle (en üstte):
import CustomerSearchInput from '@/components/admin/CustomerSearchInput';
import { buildCustomerSearchWhere } from '@/lib/admin-customer-search';
  1. searchParams type’ına q?: string ekle 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;
  1. 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şmeli
const filterWhere = q
? { AND: [statusWhere, searchWhere] }
: statusWhere;
  1. Pagination link’lerine q parametresini koru. Mevcut pagination’da href={/admin/customers?filter=${filter}&page=…} olan 3 yerde sona ${q ? &q=${encodeURIComponent(q)} : ''} ekle.

  2. 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
  1. http://49.12.188.137:3077/tr/admin/customers aç. Sağ üstte arama kutusu görünmeli.
  2. Bir şey yaz (örn. mevcut bir müşteri ismi). 300ms sonra URL’e ?q=... eklenmeli ve liste filtrelensin.
  3. “ARAMAYI TEMİZLE” linki tıklayınca arama temizlensin.
  4. Aktif filter (active/passive) ile arama birlikte çalışmalı.
  • Step 4: Commit
Terminal window
git add app/[locale]/admin/(panel)/customers/page.tsx
git commit -m "feat(admin): müşteri listesi inline arama (8 alanlı ILIKE + URL ?q=)"

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
Terminal window
git add app/[locale]/admin/(panel)/customers/page.tsx
git commit -m "feat(admin): müşteri listesi 0-sonuç durumunda farklı mesaj + temizle linki"

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
Terminal window
git add package.json package-lock.json
git commit -m "chore(deps): cmdk paketi eklendi (admin Cmd+K palette için)"

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
Terminal window
git add lib/admin-global-search.ts lib/admin-global-search.test.ts app/api/admin/search/route.ts
git commit -m "feat(admin): global arama endpoint (Cmd+K palette için, 4 kaynak parallel)"

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
Terminal window
git add components/admin/CommandPalette.tsx
git commit -m "feat(admin): Cmd+K command palette bileşeni (cmdk + 4 grup gruplu sonuç)"

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
  1. Admin paneline login ol.
  2. Herhangi bir admin sayfasında Cmd+K (Mac) veya Ctrl+K (Windows/Linux) bas.
  3. Palette modal açılmalı, input auto-focus olmalı.
  4. 2+ karakter yaz — sonuçlar gelir, gruplara ayrılmış.
  5. Enter / tıklama → ilgili sayfaya gider, palette kapanır.
  6. Esc / dış tıklama → palette kapanır.
  • Step 4: Commit
Terminal window
git add app/[locale]/admin/<bulunan-layout-path>.tsx
git commit -m "feat(admin): Cmd+K palette admin layout'a mount edildi"

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
  1. Bir müşteri sessions sekmesine git.
  2. Aktif oturum varsa “X” butonuna tıkla.
  3. “EVET” tıkla.
  4. Network tab’da POST /api/admin/customers/<id>/sessions/<sid>/revoke 200 OK görünmeli.
  5. Sayfa refresh olunca oturum listeden kaybolmalı.
  6. Aynı müşterinin activity sekmesine git — yeni “SESSION_REVOKED_BY_ADMIN” satırı görünmeli.
  • Step 4: Commit
Terminal window
git add app/api/admin/customers/[id]/sessions/[sid]/revoke/route.ts
git commit -m "feat(admin): müşteri oturumu sonlandırma endpoint'i (IDOR korumalı + audit log)"

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.

Terminal window
git add app/api/admin/customers/[id]/sessions/[sid]/revoke/route.ts
git commit -m "fix(admin): session revoke endpoint admin-session imzasıyla uyum"

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}.json
dosyaları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 AdminCustomer namespace 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/customers
  • http://49.12.188.137:3077/en/admin/customers
  • http://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
Terminal window
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"

  • 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 sayfa sayfa kontrol:

Spec maddesiKarşılayan task
§1 Route yapısıTask 6, 7, 10
§2 Layout + ortak headerTask 4, 5, 6
§3 Liste aramaTask 3, 19, 20, 21
§4 Cmd+K paletteTask 22, 23, 24, 25
§5 Overview tabTask 9
§5 Services tab refactorTask 8
§5 Invoices/Emails/Tickets/ActivityTask 11, 12, 13, 14
§5 Sessions/Files/Subs/InstallationsTask 15, 16, 17, 18
§6 Loading/error standardıTask 1, 2, 7, 10
§7 i18nTask 28
§8 Yeni API endpointsTask 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 sorularPlan başında schema okuması ile kapatıldı

  • Customer.tickets relation çakışması: Customer modelinde tickets Ticket[] vardı; layout’taki _count.tickets doğrudan bunu kullanır. EmailLog’da relation tanımı olmadığı için ayrı sorgu (Task 6’daki Promise.all).
  • BigInt: File.fileSize BigInt — Task 16’daki humanSize() 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: string olarak 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.