# Admin User Detail Page Implementation Plan

> **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:** Add `/admin/users/[id]` detail page with editable user info, period-based usage stats, recent trip list, and a plan tier foundation (free/paid + expiry) visible only to admins — no user-facing restrictions.

**Architecture:** Server component fetches user + recent trips on page load. Two client components handle editing/plan/actions (`AdminUserDetailClient`) and stats tab switching (`AdminUserDetailStats`). Plan tier (`plan` + `planExpiresAt`) is stored in DB, surfaced only in admin UI, and enforced nowhere yet.

**Tech Stack:** Next.js App Router, Prisma 5 (PostgreSQL), NextAuth, inline styles with CSS variables (`var(--text-primary)` etc.)

---

## File Map

| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `prisma/schema.prisma` | Add `plan` + `planExpiresAt` to User |
| Create | `lib/plan.ts` | `isPaid()` gating helper (unused for now) |
| Modify | `app/api/admin/users/[id]/route.ts` | Extend PATCH allowed fields |
| Create | `app/api/admin/users/[id]/stats/route.ts` | GET period stats |
| Modify | `app/admin/users/page.tsx` | Include `plan`/`planExpiresAt` in select |
| Modify | `app/admin/users/AdminUsersClient.tsx` | Plan badge + "상세보기" link |
| Create | `app/admin/users/[id]/page.tsx` | Server component, data fetch, ADMIN guard |
| Create | `app/admin/users/[id]/AdminUserDetailStats.tsx` | Period tabs + stat cards + recent trips |
| Create | `app/admin/users/[id]/AdminUserDetailClient.tsx` | Header + actions + info edit + plan UI |

---

## Task 1: Add plan fields to Prisma schema

**Files:**
- Modify: `prisma/schema.prisma`

- [ ] **Step 1: Add plan fields to User model**

In `prisma/schema.prisma`, add two lines after the `isActive` field inside the `User` model:

```prisma
  isActive             Boolean        @default(true)
  plan                 String         @default("free")
  planExpiresAt        DateTime?
  createdAt            DateTime       @default(now())
```

- [ ] **Step 2: Run migration**

```bash
cd /home/lucas/.www/transit && npx prisma migrate dev --name add_plan_fields
```

Expected output includes: `The following migration(s) have been applied: ...add_plan_fields`

`prisma migrate dev` runs `prisma generate` automatically — no separate generate step needed.

- [ ] **Step 3: Commit**

```bash
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(db): add plan and planExpiresAt fields to User"
```

---

## Task 2: Create plan utility

**Files:**
- Create: `lib/plan.ts`

- [ ] **Step 1: Write the utility**

Create `/home/lucas/.www/transit/lib/plan.ts`:

```ts
export function isPaid(user: { plan: string; planExpiresAt: Date | null }): boolean {
  if (user.plan !== 'paid') return false;
  if (!user.planExpiresAt) return true;
  return new Date(user.planExpiresAt) > new Date();
}
```

This function is not called anywhere yet. It exists so future feature gates are a single import away.

- [ ] **Step 2: Commit**

```bash
git add lib/plan.ts
git commit -m "feat(plan): add isPaid utility for future feature gating"
```

---

## Task 3: Extend PATCH API to accept new fields

**Files:**
- Modify: `app/api/admin/users/[id]/route.ts`

- [ ] **Step 1: Update the PATCH handler**

Replace the `PATCH` function body in `app/api/admin/users/[id]/route.ts` with:

```ts
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  if (!await getAdminSession()) return NextResponse.json({ error: "Forbidden" }, { status: 403 });

  const { id } = await params;
  const userId = parseInt(id);
  if (isNaN(userId)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });

  const body = await req.json();
  const allowed = [
    "approvalStatus", "role", "isActive", "transportMode", "transportModeConfirmed",
    "name", "phone", "vehicleNumber", "vehicleType", "vehicleTonnage",
    "bankName", "accountNumber", "accountHolder", "profileMemo",
    "plan", "planExpiresAt",
  ];
  const data: Record<string, unknown> = {};
  for (const key of allowed) {
    if (key in body) data[key] = body[key];
  }

  if ("transportMode" in body) {
    if (!["", "general", "trailer", "dump"].includes(body.transportMode)) {
      return NextResponse.json({ error: "Invalid transportMode" }, { status: 400 });
    }
  }
  if ("plan" in body) {
    if (!["free", "paid"].includes(body.plan)) {
      return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
    }
  }
  if ("planExpiresAt" in body) {
    data.planExpiresAt = body.planExpiresAt ? new Date(body.planExpiresAt) : null;
  }

  const user = await prisma.user.update({ where: { id: userId }, data });
  return NextResponse.json({ ok: true, approvalStatus: user.approvalStatus, isActive: user.isActive });
}
```

- [ ] **Step 2: Verify build**

```bash
cd /home/lucas/.www/transit && npx tsc --noEmit 2>&1 | head -20
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add app/api/admin/users/[id]/route.ts
git commit -m "feat(api): extend admin PATCH to accept user info and plan fields"
```

---

## Task 4: Create stats API

**Files:**
- Create: `app/api/admin/users/[id]/stats/route.ts`

- [ ] **Step 1: Create the route file**

Create `app/api/admin/users/[id]/stats/route.ts`:

```ts
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import prisma from "@/lib/prisma";

export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const session = await getServerSession(authOptions);
  if (!session?.user || session.user.role !== "ADMIN") {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const { id } = await params;
  const userId = parseInt(id);
  if (isNaN(userId)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });

  const { searchParams } = new URL(req.url);
  const period = searchParams.get("period") ?? "thisMonth";

  const now = new Date();
  let dateFilter: { gte: Date; lt?: Date } | undefined;

  if (period === "thisMonth") {
    dateFilter = { gte: new Date(now.getFullYear(), now.getMonth(), 1) };
  } else if (period === "lastMonth") {
    dateFilter = {
      gte: new Date(now.getFullYear(), now.getMonth() - 1, 1),
      lt: new Date(now.getFullYear(), now.getMonth(), 1),
    };
  }

  const dateWhere = dateFilter ? { date: dateFilter } : {};
  const base = { userId, ...dateWhere };

  const [tripCount, trailerTripCount, income, expense, fuel] = await Promise.all([
    prisma.trip.count({ where: base }),
    prisma.trailerTrip.count({ where: base }),
    prisma.incomeLog.aggregate({ where: base, _sum: { amount: true } }),
    prisma.expense.aggregate({ where: base, _sum: { amount: true } }),
    prisma.fuelLog.aggregate({ where: base, _sum: { totalAmount: true } }),
  ]);

  return NextResponse.json({
    tripCount,
    trailerTripCount,
    totalIncome: income._sum.amount ?? 0,
    totalExpense: expense._sum.amount ?? 0,
    totalFuel: fuel._sum.totalAmount ?? 0,
  });
}
```

- [ ] **Step 2: Verify build**

```bash
cd /home/lucas/.www/transit && npx tsc --noEmit 2>&1 | head -20
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add app/api/admin/users/[id]/stats/route.ts
git commit -m "feat(api): add admin user stats endpoint with period filtering"
```

---

## Task 5: Update admin user list (plan badge + detail link)

**Files:**
- Modify: `app/admin/users/page.tsx`
- Modify: `app/admin/users/AdminUsersClient.tsx`

- [ ] **Step 1: Add plan fields to the list page select**

In `app/admin/users/page.tsx`, add `plan: true, planExpiresAt: true,` to the `select` object:

```ts
select: {
  id: true, email: true, name: true, phone: true,
  vehicleNumber: true, vehicleType: true, vehicleTonnage: true,
  profileMemo: true, approvalStatus: true, profileComplete: true,
  role: true, googleId: true, kakaoId: true, isActive: true, createdAt: true,
  transportMode: true, transportModeConfirmed: true,
  bankName: true, accountNumber: true, accountHolder: true,
  plan: true, planExpiresAt: true,
},
```

- [ ] **Step 2: Add plan fields to the User type in AdminUsersClient.tsx**

In the `type User` block at the top of `app/admin/users/AdminUsersClient.tsx`, add:

```ts
  plan: string;
  planExpiresAt: Date | null;
```

- [ ] **Step 3: Add plan badge helper and "상세보기" link**

After the `STATUS_LABEL` constant, add:

```ts
function PlanBadge({ plan, expiresAt }: { plan: string; expiresAt: Date | null }) {
  if (plan !== 'paid') return null;
  const expired = expiresAt && new Date(expiresAt) < new Date();
  return (
    <span style={{
      fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700,
      background: expired ? 'rgba(239,68,68,0.15)' : 'rgba(34,197,94,0.15)',
      color: expired ? 'var(--accent-red)' : 'var(--accent-green)',
    }}>
      {expired ? '만료' : '유료'}
    </span>
  );
}
```

- [ ] **Step 4: Add the badge and detail link to the user row**

In the user name row (around line 222 in `AdminUsersClient.tsx`), after the status badge span, add the `<PlanBadge>` and a link to the detail page. Also add the `Link` import at the top.

Add to imports:
```ts
import Link from 'next/link';
```

In the name/badge row, after the `<span>` showing the status badge, add:
```tsx
<PlanBadge plan={user.plan} expiresAt={user.planExpiresAt} />
<Link href={`/admin/users/${user.id}`}
  onClick={e => e.stopPropagation()}
  style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, fontWeight: 600,
    background: 'rgba(59,130,246,0.1)', color: 'var(--accent-blue)',
    textDecoration: 'none', marginLeft: 'auto' }}>
  상세보기
</Link>
```

- [ ] **Step 5: Verify build**

```bash
cd /home/lucas/.www/transit && npx tsc --noEmit 2>&1 | head -20
```

Expected: no errors.

- [ ] **Step 6: Commit**

```bash
git add app/admin/users/page.tsx app/admin/users/AdminUsersClient.tsx
git commit -m "feat(admin): add plan badge and detail page link to user list"
```

---

## Task 6: Create detail page server component

**Files:**
- Create: `app/admin/users/[id]/page.tsx`

- [ ] **Step 1: Create the server component**

Create `app/admin/users/[id]/page.tsx`:

```tsx
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect, notFound } from "next/navigation";
import prisma from "@/lib/prisma";
import AdminUserDetailClient from "./AdminUserDetailClient";

export default async function AdminUserDetailPage({ params }: { params: Promise<{ id: string }> }) {
  const session = await getServerSession(authOptions);
  if (!session?.user || session.user.role !== "ADMIN") redirect("/login");

  const { id } = await params;
  const userId = parseInt(id);
  if (isNaN(userId)) notFound();

  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      id: true, email: true, name: true, phone: true,
      vehicleNumber: true, vehicleType: true, vehicleTonnage: true,
      profileMemo: true, approvalStatus: true, profileComplete: true,
      role: true, googleId: true, kakaoId: true, isActive: true, createdAt: true,
      transportMode: true, transportModeConfirmed: true,
      bankName: true, accountNumber: true, accountHolder: true,
      plan: true, planExpiresAt: true,
    },
  });

  if (!user) notFound();

  const recentTrips = await prisma.trip.findMany({
    where: { userId },
    orderBy: { date: "desc" },
    take: 10,
    select: { id: true, date: true, loadingLoc: true, unloadingLoc: true, fee: true, isSettled: true },
  });

  return <AdminUserDetailClient user={user as any} recentTrips={recentTrips as any} />;
}
```

- [ ] **Step 2: Verify build**

```bash
cd /home/lucas/.www/transit && npx tsc --noEmit 2>&1 | head -20
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add app/admin/users/[id]/page.tsx
git commit -m "feat(admin): add user detail page server component"
```

---

## Task 7: Create AdminUserDetailStats component

**Files:**
- Create: `app/admin/users/[id]/AdminUserDetailStats.tsx`

- [ ] **Step 1: Create the component**

Create `app/admin/users/[id]/AdminUserDetailStats.tsx`:

```tsx
'use client';

import { useState, useEffect } from 'react';

type Stats = {
  tripCount: number;
  trailerTripCount: number;
  totalIncome: number;
  totalExpense: number;
  totalFuel: number;
};

type RecentTrip = {
  id: number;
  date: Date;
  loadingLoc: string;
  unloadingLoc: string;
  fee: number;
  isSettled: boolean;
};

type Period = 'thisMonth' | 'lastMonth' | 'all';

const PERIOD_LABELS: Record<Period, string> = {
  thisMonth: '이번달',
  lastMonth: '지난달',
  all: '전체',
};

function fmt(n: number) {
  return n.toLocaleString('ko-KR') + '원';
}

export default function AdminUserDetailStats({ userId, recentTrips }: { userId: number; recentTrips: RecentTrip[] }) {
  const [period, setPeriod] = useState<Period>('thisMonth');
  const [stats, setStats] = useState<Stats | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/admin/users/${userId}/stats?period=${period}`)
      .then(r => r.json())
      .then(data => { setStats(data); setLoading(false); })
      .catch(() => setLoading(false));
  }, [userId, period]);

  return (
    <>
      {/* Stats */}
      <div className="card" style={{ padding: 20, marginBottom: 16 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
          <h2 style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginRight: 8 }}>사용량 통계</h2>
          {(Object.keys(PERIOD_LABELS) as Period[]).map(p => (
            <button key={p} onClick={() => setPeriod(p)} style={{
              padding: '5px 14px', borderRadius: 16, fontSize: 12, fontWeight: 600, cursor: 'pointer',
              border: `1px solid ${period === p ? 'var(--accent-orange)' : 'var(--border-color)'}`,
              background: period === p ? 'var(--accent-orange)' : 'transparent',
              color: period === p ? '#fff' : 'var(--text-muted)',
            }}>
              {PERIOD_LABELS[p]}
            </button>
          ))}
        </div>

        {loading || !stats ? (
          <div style={{ textAlign: 'center', padding: '24px 0', color: 'var(--text-muted)', fontSize: 13 }}>
            {loading ? '불러오는 중...' : '데이터 없음'}
          </div>
        ) : (
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
            {[
              { label: '운행 횟수', value: `${stats.tripCount + stats.trailerTripCount}회`, sub: `일반 ${stats.tripCount} · 트레일러 ${stats.trailerTripCount}` },
              { label: '총 수입', value: fmt(stats.totalIncome), color: 'var(--accent-green)' },
              { label: '총 지출', value: fmt(stats.totalExpense), color: 'var(--accent-red)' },
              { label: '연료비', value: fmt(stats.totalFuel), color: 'var(--accent-orange)' },
            ].map(card => (
              <div key={card.label} style={{
                background: 'var(--bg-secondary)', borderRadius: 10, padding: '12px 14px',
                border: '1px solid var(--border-color)',
              }}>
                <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{card.label}</div>
                <div style={{ fontSize: 16, fontWeight: 700, color: card.color ?? 'var(--text-primary)' }}>{card.value}</div>
                {card.sub && <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}>{card.sub}</div>}
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Recent Trips */}
      <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
        <div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-color)' }}>
          <h2 style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>최근 운행 내역</h2>
        </div>
        {recentTrips.length === 0 ? (
          <div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>운행 내역 없음</div>
        ) : (
          recentTrips.map((trip, i) => (
            <div key={trip.id} style={{
              padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 12,
              borderBottom: i < recentTrips.length - 1 ? '1px solid var(--border-color)' : 'none',
            }}>
              <div style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 64 }}>
                {new Date(trip.date).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
              </div>
              <div style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)' }}>
                {trip.loadingLoc} → {trip.unloadingLoc}
              </div>
              <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
                {trip.fee.toLocaleString('ko-KR')}원
              </div>
              <span style={{
                fontSize: 10, padding: '2px 7px', borderRadius: 4, fontWeight: 600,
                background: trip.isSettled ? 'rgba(34,197,94,0.12)' : 'rgba(255,165,0,0.12)',
                color: trip.isSettled ? 'var(--accent-green)' : 'var(--accent-orange)',
              }}>
                {trip.isSettled ? '정산완료' : '미정산'}
              </span>
            </div>
          ))
        )}
      </div>
    </>
  );
}
```

- [ ] **Step 2: Verify build**

```bash
cd /home/lucas/.www/transit && npx tsc --noEmit 2>&1 | head -20
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add app/admin/users/[id]/AdminUserDetailStats.tsx
git commit -m "feat(admin): add user detail stats and recent trips component"
```

---

## Task 8: Create AdminUserDetailClient component

**Files:**
- Create: `app/admin/users/[id]/AdminUserDetailClient.tsx`

- [ ] **Step 1: Create the component**

Create `app/admin/users/[id]/AdminUserDetailClient.tsx`:

```tsx
'use client';

import { useState } from 'react';
import Link from 'next/link';
import AdminUserDetailStats from './AdminUserDetailStats';

type User = {
  id: number; email: string; name: string | null; phone: string | null;
  vehicleNumber: string | null; vehicleType: string | null; vehicleTonnage: string | null;
  profileMemo: string | null; approvalStatus: string; role: string;
  googleId: string | null; kakaoId: string | null; isActive: boolean; createdAt: Date;
  transportMode: string; bankName: string | null; accountNumber: string | null;
  accountHolder: string | null; plan: string; planExpiresAt: Date | null;
};

type RecentTrip = {
  id: number; date: Date; loadingLoc: string; unloadingLoc: string; fee: number; isSettled: boolean;
};

const STATUS = { pending: { label: '대기중', color: 'var(--accent-orange)' }, approved: { label: '승인됨', color: 'var(--accent-green)' }, rejected: { label: '거부됨', color: 'var(--accent-red)' } } as const;
const MODES = [{ id: 'general', label: '일반' }, { id: 'trailer', label: '트레일러' }, { id: 'dump', label: '덤프' }];
const INFO_FIELDS = [
  { label: '이름', key: 'name' }, { label: '전화번호', key: 'phone' },
  { label: '차량번호', key: 'vehicleNumber' }, { label: '차종', key: 'vehicleType' },
  { label: '톤수', key: 'vehicleTonnage' }, { label: '은행명', key: 'bankName' },
  { label: '계좌번호', key: 'accountNumber' }, { label: '예금주', key: 'accountHolder' },
] as const;

function dateToInput(d: Date | null) { return d ? new Date(d).toISOString().split('T')[0] : ''; }
function planLabel(plan: string, exp: Date | null) {
  if (plan !== 'paid') return { text: '무료', color: 'var(--text-muted)' };
  if (!exp) return { text: '유료 (무기한)', color: 'var(--accent-green)' };
  if (new Date(exp) < new Date()) return { text: '유료 (만료)', color: 'var(--accent-red)' };
  const days = Math.ceil((new Date(exp).getTime() - Date.now()) / 86400000);
  return { text: `유료 · ${days}일 남음`, color: 'var(--accent-green)' };
}

export default function AdminUserDetailClient({ user: init, recentTrips }: { user: User; recentTrips: RecentTrip[] }) {
  const [user, setUser] = useState(init);
  const [editing, setEditing] = useState(false);
  const [form, setForm] = useState({ name: init.name ?? '', phone: init.phone ?? '', vehicleNumber: init.vehicleNumber ?? '', vehicleType: init.vehicleType ?? '', vehicleTonnage: init.vehicleTonnage ?? '', bankName: init.bankName ?? '', accountNumber: init.accountNumber ?? '', accountHolder: init.accountHolder ?? '', profileMemo: init.profileMemo ?? '', transportMode: init.transportMode });
  const [planForm, setPlanForm] = useState({ plan: init.plan, expiresAt: dateToInput(init.planExpiresAt) });
  const [loading, setLoading] = useState<string | null>(null);
  const [delConfirm, setDelConfirm] = useState(false);
  const [notify, setNotify] = useState<'sending' | 'ok' | 'fail' | null>(null);

  async function patch(data: Record<string, unknown>) {
    const r = await fetch(`/api/admin/users/${user.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
    return r.ok;
  }

  async function saveInfo() { setLoading('info'); if (await patch(form)) { setUser(u => ({ ...u, ...form })); setEditing(false); } setLoading(null); }
  async function savePlan() {
    setLoading('plan');
    const planExpiresAt = planForm.plan === 'paid' && planForm.expiresAt ? new Date(planForm.expiresAt).toISOString() : null;
    if (await patch({ plan: planForm.plan, planExpiresAt })) {
      setUser(u => ({ ...u, plan: planForm.plan, planExpiresAt: planExpiresAt ? new Date(planExpiresAt) : null }));
    }
    setLoading(null);
  }
  async function updateStatus(s: string) { setLoading(s); if (await patch({ approvalStatus: s })) setUser(u => ({ ...u, approvalStatus: s })); setLoading(null); }
  async function toggleActive() { setLoading('active'); if (await patch({ isActive: !user.isActive })) setUser(u => ({ ...u, isActive: !u.isActive })); setLoading(null); }
  async function deleteUser() {
    if (!delConfirm) { setDelConfirm(true); return; }
    setLoading('del');
    const r = await fetch(`/api/admin/users/${user.id}`, { method: 'DELETE' });
    if (r.ok) window.location.href = '/admin/users';
    else setLoading(null);
  }
  async function sendNotify() {
    setNotify('sending');
    try { const r = await fetch(`/api/admin/notify-user/${user.id}`, { method: 'POST' }); const d = await r.json(); setNotify(d.result ?? 'fail'); }
    catch { setNotify('fail'); }
  }

  const busy = loading !== null;
  const st = STATUS[user.approvalStatus as keyof typeof STATUS];
  const pl = planLabel(user.plan, user.planExpiresAt);

  return (
    <div className="page-content animate-fade-in">
      {/* Header */}
      <div style={{ marginBottom: 20 }}>
        <Link href="/admin/users" style={{ fontSize: 13, color: 'var(--accent-blue)', textDecoration: 'none' }}>← 목록으로</Link>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
          <h1 style={{ fontSize: 22, fontWeight: 800, color: 'var(--text-primary)' }}>{user.name || '(미입력)'}</h1>
          {st && <span style={{ fontSize: 11, padding: '3px 10px', borderRadius: 10, fontWeight: 600, background: `${st.color}22`, color: st.color }}>{st.label}</span>}
          {!user.isActive && <span style={{ fontSize: 11, padding: '3px 10px', borderRadius: 10, fontWeight: 600, background: 'rgba(239,68,68,0.1)', color: 'var(--accent-red)' }}>비활성</span>}
          {user.googleId && <span style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700, background: 'rgba(66,133,244,0.15)', color: '#4285F4' }}>Google</span>}
          {user.kakaoId && <span style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700, background: 'rgba(254,229,0,0.35)', color: '#3C1E1E' }}>Kakao</span>}
        </div>
        <div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
          {user.approvalStatus === 'pending' && <>
            <button onClick={() => updateStatus('approved')} disabled={busy} style={{ padding: '7px 18px', borderRadius: 8, fontSize: 13, fontWeight: 700, background: 'var(--accent-green)', color: '#fff', border: 'none', cursor: 'pointer' }}>{loading === 'approved' ? '처리중...' : '승인'}</button>
            <button onClick={() => updateStatus('rejected')} disabled={busy} style={{ padding: '7px 18px', borderRadius: 8, fontSize: 13, fontWeight: 700, background: 'var(--accent-red)', color: '#fff', border: 'none', cursor: 'pointer' }}>{loading === 'rejected' ? '처리중...' : '거부'}</button>
          </>}
          {user.approvalStatus === 'rejected' && <button onClick={() => updateStatus('approved')} disabled={busy} style={{ padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600, background: 'rgba(34,197,94,0.12)', color: 'var(--accent-green)', border: '1px solid rgba(34,197,94,0.2)', cursor: 'pointer' }}>재승인</button>}
          {user.approvalStatus === 'approved' && <button onClick={toggleActive} disabled={busy} style={{ padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600, background: user.isActive ? 'rgba(239,68,68,0.12)' : 'rgba(34,197,94,0.12)', color: user.isActive ? 'var(--accent-red)' : 'var(--accent-green)', border: `1px solid ${user.isActive ? 'rgba(239,68,68,0.2)' : 'rgba(34,197,94,0.2)'}`, cursor: 'pointer' }}>{loading === 'active' ? '처리중...' : user.isActive ? '비활성화' : '활성화'}</button>}
          {user.kakaoId && <button onClick={sendNotify} disabled={notify === 'sending'} style={{ padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 700, background: notify === 'ok' ? 'rgba(34,197,94,0.15)' : 'rgba(254,229,0,0.3)', color: notify === 'ok' ? 'var(--accent-green)' : '#3C1E1E', border: '1px solid rgba(0,0,0,0.08)', cursor: 'pointer' }}>{notify === 'sending' ? '전송중...' : notify === 'ok' ? '✓ 전송됨' : '카톡 알림'}</button>}
          <button onClick={deleteUser} disabled={busy} style={{ padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600, background: delConfirm ? '#dc2626' : 'rgba(239,68,68,0.08)', color: delConfirm ? '#fff' : 'var(--accent-red)', border: '1px solid rgba(239,68,68,0.2)', cursor: 'pointer' }}>{loading === 'del' ? '삭제중...' : delConfirm ? '정말 삭제' : '삭제'}</button>
        </div>
      </div>

      {/* Basic Info */}
      <div className="card" style={{ padding: 20, marginBottom: 16 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
          <h2 style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>기본 정보</h2>
          {editing ? (
            <div style={{ display: 'flex', gap: 8 }}>
              <button onClick={() => { setEditing(false); setForm({ name: user.name ?? '', phone: user.phone ?? '', vehicleNumber: user.vehicleNumber ?? '', vehicleType: user.vehicleType ?? '', vehicleTonnage: user.vehicleTonnage ?? '', bankName: user.bankName ?? '', accountNumber: user.accountNumber ?? '', accountHolder: user.accountHolder ?? '', profileMemo: user.profileMemo ?? '', transportMode: user.transportMode }); }} style={{ padding: '5px 12px', borderRadius: 6, fontSize: 12, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>취소</button>
              <button onClick={saveInfo} disabled={loading === 'info'} style={{ padding: '5px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'var(--accent-blue)', color: '#fff', border: 'none', cursor: 'pointer' }}>{loading === 'info' ? '저장중...' : '저장'}</button>
            </div>
          ) : (
            <button onClick={() => setEditing(true)} style={{ padding: '5px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'rgba(59,130,246,0.1)', color: 'var(--accent-blue)', border: '1px solid rgba(59,130,246,0.2)', cursor: 'pointer' }}>수정</button>
          )}
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
          {INFO_FIELDS.map(({ label, key }) => (
            <div key={key}>
              <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 3 }}>{label}</div>
              {editing ? <input value={form[key]} onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))} style={{ width: '100%', padding: '5px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 13 }} />
                : <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{(user as any)[key] || '—'}</div>}
            </div>
          ))}
          <div>
            <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 3 }}>운송방식</div>
            {editing ? (
              <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
                {MODES.map(m => <button key={m.id} onClick={() => setForm(f => ({ ...f, transportMode: m.id }))} style={{ padding: '4px 10px', borderRadius: 14, fontSize: 11, fontWeight: 600, cursor: 'pointer', border: '1.5px solid', borderColor: form.transportMode === m.id ? 'var(--accent-blue)' : 'var(--border-color)', background: form.transportMode === m.id ? 'rgba(59,130,246,0.15)' : 'transparent', color: form.transportMode === m.id ? 'var(--accent-blue)' : 'var(--text-muted)' }}>{m.label}</button>)}
              </div>
            ) : <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{user.transportMode || '미설정'}</div>}
          </div>
        </div>
        <div style={{ marginTop: 12 }}>
          <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 3 }}>메모</div>
          {editing ? <textarea value={form.profileMemo} onChange={e => setForm(f => ({ ...f, profileMemo: e.target.value }))} rows={2} style={{ width: '100%', padding: '5px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 13, resize: 'vertical' }} />
            : <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{user.profileMemo || '—'}</div>}
        </div>
        <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>이메일: {user.email} · 가입일: {new Date(user.createdAt).toLocaleDateString('ko-KR')}</div>
      </div>

      {/* Plan Management */}
      <div className="card" style={{ padding: 20, marginBottom: 16 }}>
        <h2 style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 12 }}>플랜 관리</h2>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
          {(['free', 'paid'] as const).map(p => (
            <button key={p} onClick={() => setPlanForm(f => ({ ...f, plan: p }))} style={{ padding: '6px 16px', borderRadius: 18, fontSize: 12, fontWeight: 600, cursor: 'pointer', border: '1.5px solid', borderColor: planForm.plan === p ? (p === 'paid' ? 'var(--accent-green)' : 'var(--border-color)') : 'var(--border-color)', background: planForm.plan === p ? (p === 'paid' ? 'rgba(34,197,94,0.15)' : 'var(--bg-card)') : 'transparent', color: planForm.plan === p ? (p === 'paid' ? 'var(--accent-green)' : 'var(--text-primary)') : 'var(--text-muted)' }}>
              {p === 'free' ? '무료' : '유료'}
            </button>
          ))}
          {planForm.plan === 'paid' && <input type="date" value={planForm.expiresAt} onChange={e => setPlanForm(f => ({ ...f, expiresAt: e.target.value }))} style={{ padding: '5px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 12 }} />}
          <button onClick={savePlan} disabled={loading === 'plan'} style={{ padding: '6px 14px', borderRadius: 7, fontSize: 12, fontWeight: 600, background: 'var(--accent-blue)', color: '#fff', border: 'none', cursor: 'pointer' }}>{loading === 'plan' ? '저장중...' : '저장'}</button>
          <span style={{ fontSize: 12, color: pl.color, fontWeight: 600 }}>{pl.text}</span>
        </div>
      </div>

      <AdminUserDetailStats userId={user.id} recentTrips={recentTrips} />
    </div>
  );
}
```

- [ ] **Step 2: Verify build**

```bash
cd /home/lucas/.www/transit && npx tsc --noEmit 2>&1 | head -30
```

Expected: no errors.

- [ ] **Step 3: Rebuild and restart**

```bash
cd /home/lucas/.www/transit && npm run build 2>&1 | tail -20
```

Expected: `✓ Compiled successfully`

Restart the Next.js server process so the new build takes effect (use your system's process manager — `pm2 restart`, `systemctl restart`, or kill and re-run `npm start`).

- [ ] **Step 4: Smoke test manually**

1. Open `https://kakago.net/admin/users` — verify list loads, plan badges appear, "상세보기" links visible
2. Click "상세보기" on any user → verify detail page opens at `/admin/users/[id]`
3. Click "수정" → change a field → "저장" → verify change persists on page reload
4. Toggle 무료/유료 in plan section → set an expiry date → "저장" → verify badge updates in list
5. Switch stats tabs (이번달/지난달/전체) → verify numbers change
6. Verify non-admin session cannot access `/admin/users/[id]` (redirects to login)

- [ ] **Step 5: Commit**

```bash
git add app/admin/users/[id]/AdminUserDetailClient.tsx
git commit -m "feat(admin): add user detail client component with editing and plan management"
```
