page.tsx431 lines · main
1import Link from 'next/link';
2import { notFound } from 'next/navigation';
3
4import { ApiError, apiJson } from '../../../../../lib/api';
5import { DeleteMigrationButton } from './delete-migration-button';
6
7export const metadata = { title: 'migration detail' };
8export const dynamic = 'force-dynamic';
9
10interface CustomerRequest {
11 id: string;
12 source: string;
13 sourceUrl: string | null;
14 sourceNotes: string;
15 estimatedTables: number | null;
16 estimatedRows: string | null;
17 estimatedFunctions: number | null;
18 urgency: string;
19 status: string;
20 contactEmail: string;
21 createdAt: string;
22 updatedAt: string;
23}
24
25interface TimelineEntry {
26 id: string;
27 action: string;
28 createdAt: string;
29 metadata: {
30 source: string | null;
31 statusChanged: boolean | null;
32 messageIncluded: boolean | null;
33 linkedUserId: boolean;
34 // Status-change entries carry the new status name so the timeline can
35 // map directly into `phaseTimestamps` without guessing sequentially.
36 // Optional because only `statusChanged` entries include it.
37 newStatus?: string | null;
38 };
39}
40
41const ACTION_LABEL: Record<string, string> = {
42 'migration_request.create': 'request submitted from the dashboard wizard',
43 'migration_request.public_create': 'request submitted via the public /migrate form',
44 'admin.migration_request.update': 'briven team updated your request',
45 'admin.migration_request.promote_to_user': 'linked to your briven account',
46};
47
48const STATUS_BLURB: Record<string, string> = {
49 new: 'queued — we’ll be in touch within one business day.',
50 contacted: 'we’ve reached out — check your inbox.',
51 scheduled: 'a migration window is scheduled.',
52 in_progress: 'we’re moving your project right now.',
53 completed: 'migration completed.',
54 cancelled: 'request cancelled.',
55};
56
57function statusTone(status: string): string {
58 if (status === 'completed') {
59 return 'border-[var(--color-primary)] bg-[var(--color-primary-subtle)] text-[var(--color-primary)]';
60 }
61 if (status === 'cancelled') {
62 return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]';
63 }
64 if (status === 'in_progress') {
65 return 'border-[var(--color-warning)] text-[var(--color-warning)]';
66 }
67 return 'border-[var(--color-border)] text-[var(--color-text-muted)]';
68}
69
70function formatTime(iso: string): string {
71 return new Date(iso).toISOString().replace('T', ' ').slice(0, 16) + ' utc';
72}
73
74/** Compact one-line variant for the timeline — `May 17 · 08:29`. */
75function formatTimeShort(iso: string): string {
76 const d = new Date(iso);
77 const month = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
78 const day = d.getUTCDate().toString().padStart(2, '0');
79 const hh = d.getUTCHours().toString().padStart(2, '0');
80 const mm = d.getUTCMinutes().toString().padStart(2, '0');
81 return `${month} ${day} · ${hh}:${mm}`;
82}
83
84/** Ordered phases every migration passes through. */
85const PHASES = [
86 { key: 'new', label: 'Request submitted', idx: 1 },
87 { key: 'contacted', label: 'Request reviewed', idx: 2 },
88 { key: 'scheduled', label: 'Migration scheduled', idx: 3 },
89 { key: 'in_progress', label: 'Migration in progress', idx: 4 },
90 { key: 'completed', label: 'Migration completed', idx: 5 },
91] as const;
92
93const PHASE_ORDER: Record<string, number> = Object.fromEntries(
94 PHASES.map((p) => [p.key, p.idx]),
95);
96
97function StepTimeline({
98 status,
99 timeline,
100}: {
101 status: string;
102 timeline: TimelineEntry[];
103}) {
104 const currentIdx = PHASE_ORDER[status] ?? 0;
105 const isCancelled = status === 'cancelled';
106
107 // Phase timestamps: Phase 1 = request creation time.
108 // Status-change entries carry `newStatus` (the API now serialises it).
109 // Map newStatus → phase key directly instead of guessing sequentially.
110 const phaseTimestamps: Record<string, string | null> = {};
111 for (const entry of timeline) {
112 if (
113 entry.action === 'migration_request.public_create' ||
114 entry.action === 'migration_request.create'
115 ) {
116 if (!phaseTimestamps['new']) phaseTimestamps['new'] = entry.createdAt;
117 }
118 if (entry.metadata.statusChanged && entry.metadata.newStatus) {
119 phaseTimestamps[entry.metadata.newStatus] = entry.createdAt;
120 }
121 }
122 // Fallback: if no explicit create entry exists, use the first entry.
123 const firstEntry = timeline[0];
124 if (!phaseTimestamps['new'] && firstEntry) {
125 phaseTimestamps['new'] = firstEntry.createdAt;
126 }
127
128 // Progress fill spans from dot-1 center to dot-N center. Cells are
129 // even-width; dot centres sit at 10%, 30%, 50%, 70%, 90% for 5 phases.
130 // Track therefore runs from 10% → 90%; fill from 10% to
131 // (10% + (currentIdx - 1) / (PHASES.length - 1) * 80%).
132 const segments = PHASES.length - 1;
133 const cellCenterStart = 100 / (PHASES.length * 2);
134 const trackStart = cellCenterStart;
135 const trackEnd = 100 - cellCenterStart;
136 const trackWidth = trackEnd - trackStart;
137 const progressIdx = Math.max(0, Math.min(segments, currentIdx - 1));
138 const fillWidth = (progressIdx / segments) * trackWidth;
139
140 return (
141 <div className="mb-4 rounded-lg border border-[var(--color-border-subtle)] bg-gradient-to-b from-[var(--color-surface)] to-[var(--color-surface-raised)]/40 p-6">
142 {isCancelled ? (
143 <div className="flex items-center gap-2 font-mono text-sm text-[var(--color-text-subtle)]">
144 <span
145 className="inline-flex size-2 rounded-full bg-[var(--color-text-subtle)]"
146 aria-hidden="true"
147 />
148 this request was cancelled.
149 </div>
150 ) : (
151 <div className="relative">
152 {/* Track: continuous behind the dots */}
153 <div
154 className="absolute top-4 h-px bg-[var(--color-border-subtle)]"
155 style={{
156 left: `${trackStart}%`,
157 width: `${trackWidth}%`,
158 }}
159 aria-hidden="true"
160 />
161 {/* Track progress fill */}
162 <div
163 className="absolute top-4 h-px bg-[var(--color-primary)] transition-[width] duration-500"
164 style={{
165 left: `${trackStart}%`,
166 width: `${fillWidth}%`,
167 }}
168 aria-hidden="true"
169 />
170 {/* Steps */}
171 <ol
172 className="relative grid"
173 style={{ gridTemplateColumns: `repeat(${PHASES.length}, minmax(0, 1fr))` }}
174 >
175 {PHASES.map((phase) => {
176 const reached = currentIdx >= phase.idx;
177 const isCurrent = currentIdx === phase.idx;
178 const ts = phaseTimestamps[phase.key];
179
180 return (
181 <li
182 key={phase.key}
183 className="flex flex-col items-center gap-2 px-1"
184 aria-current={isCurrent ? 'step' : undefined}
185 >
186 {/* Dot */}
187 <div
188 className={`relative z-10 flex size-8 items-center justify-center rounded-full font-mono text-[11px] font-medium transition-colors ${
189 reached && !isCurrent
190 ? 'bg-[var(--color-primary)] text-[var(--color-text-inverse)] shadow-[0_0_0_4px_var(--color-bg)]'
191 : isCurrent
192 ? 'bg-[var(--color-bg)] text-[var(--color-primary)] shadow-[0_0_0_4px_var(--color-bg),0_0_0_5px_var(--color-primary),0_0_18px_var(--color-primary)/40]'
193 : 'bg-[var(--color-bg)] text-[var(--color-text-subtle)] shadow-[0_0_0_4px_var(--color-bg),inset_0_0_0_1px_var(--color-border)]'
194 }`}
195 >
196 {reached && !isCurrent ? (
197 <svg
198 viewBox="0 0 24 24"
199 fill="none"
200 stroke="currentColor"
201 strokeWidth="3"
202 strokeLinecap="round"
203 strokeLinejoin="round"
204 className="size-4"
205 aria-hidden="true"
206 >
207 <polyline points="20 6 9 17 4 12" />
208 </svg>
209 ) : isCurrent ? (
210 <span
211 className="size-2 rounded-full bg-[var(--color-primary)]"
212 aria-hidden="true"
213 />
214 ) : (
215 phase.idx
216 )}
217 </div>
218
219 {/* Phase number eyebrow */}
220 <p
221 className={`font-mono text-[10px] uppercase tracking-wider ${
222 reached
223 ? 'text-[var(--color-text-muted)]'
224 : 'text-[var(--color-text-subtle)]'
225 }`}
226 >
227 phase {phase.idx}
228 </p>
229
230 {/* Label */}
231 <p
232 className={`text-center font-mono text-xs leading-tight ${
233 reached
234 ? 'text-[var(--color-text)]'
235 : 'text-[var(--color-text-subtle)]'
236 } ${isCurrent ? 'font-medium' : ''}`}
237 >
238 {phase.label.toLowerCase()}
239 </p>
240
241 {/* Timestamp / processing chip */}
242 {ts ? (
243 <span className="rounded-sm bg-[var(--color-bg)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
244 {formatTimeShort(ts)}
245 </span>
246 ) : isCurrent ? (
247 <span className="inline-flex items-center gap-1 rounded-sm bg-[var(--color-bg)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-primary)]">
248 <span
249 className="inline-flex size-1.5 animate-pulse rounded-full bg-[var(--color-primary)]"
250 aria-hidden="true"
251 />
252 in progress
253 </span>
254 ) : null}
255 </li>
256 );
257 })}
258 </ol>
259 </div>
260 )}
261 </div>
262 );
263}
264
265export default async function MigrationDetailPage({
266 params,
267}: {
268 params: Promise<{ id: string }>;
269}) {
270 const { id } = await params;
271 let data: { request: CustomerRequest; timeline: TimelineEntry[] };
272 try {
273 data = await apiJson<{ request: CustomerRequest; timeline: TimelineEntry[] }>(
274 `/v1/migration-requests/${id}`,
275 );
276 } catch (err) {
277 if (err instanceof ApiError && err.status === 404) notFound();
278 throw err;
279 }
280
281 const { request, timeline } = data;
282
283 return (
284 <section className="flex flex-col gap-6">
285 <p className="font-mono text-xs text-[var(--color-text-muted)]">
286 <Link href="/dashboard/migrations" className="hover:text-[var(--color-text)]">
287 ← all migrations
288 </Link>
289 </p>
290
291 <header className="flex flex-col gap-3">
292 <div className="flex flex-wrap items-center gap-2">
293 <span
294 className={`rounded-full border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${statusTone(request.status)}`}
295 >
296 {request.status.replace(/_/g, ' ')}
297 </span>
298 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
299 {request.source}
300 </span>
301 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
302 {request.id}
303 </span>
304 </div>
305 <h1 className="font-mono text-xl tracking-tight">
306 {request.source} → briven
307 </h1>
308 <p className="font-mono text-sm text-[var(--color-text-muted)]">
309 {STATUS_BLURB[request.status] ?? 'in progress.'}
310 </p>
311 </header>
312
313 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
314 <p className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
315 what you submitted
316 </p>
317 <dl className="mt-3 grid grid-cols-2 gap-3 font-mono text-xs text-[var(--color-text-muted)] sm:grid-cols-4">
318 <div>
319 <dt className="text-[var(--color-text-subtle)]">submitted</dt>
320 <dd className="text-[var(--color-text)]">{formatTime(request.createdAt)}</dd>
321 </div>
322 <div>
323 <dt className="text-[var(--color-text-subtle)]">urgency</dt>
324 <dd className="text-[var(--color-text)]">
325 {request.urgency.replace(/_/g, ' ')}
326 </dd>
327 </div>
328 <div>
329 <dt className="text-[var(--color-text-subtle)]">contact</dt>
330 <dd className="text-[var(--color-text)]">{request.contactEmail}</dd>
331 </div>
332 <div>
333 <dt className="text-[var(--color-text-subtle)]">last update</dt>
334 <dd className="text-[var(--color-text)]">{formatTime(request.updatedAt)}</dd>
335 </div>
336 <div>
337 <dt className="text-[var(--color-text-subtle)]">tables</dt>
338 <dd className="text-[var(--color-text)]">{request.estimatedTables ?? '—'}</dd>
339 </div>
340 <div>
341 <dt className="text-[var(--color-text-subtle)]">rows</dt>
342 <dd className="text-[var(--color-text)]">{request.estimatedRows ?? '—'}</dd>
343 </div>
344 <div>
345 <dt className="text-[var(--color-text-subtle)]">functions</dt>
346 <dd className="text-[var(--color-text)]">
347 {request.estimatedFunctions ?? '—'}
348 </dd>
349 </div>
350 {request.sourceUrl ? (
351 <div className="col-span-2 sm:col-span-2">
352 <dt className="text-[var(--color-text-subtle)]">source URL</dt>
353 <dd className="break-all text-[var(--color-text)]">
354 <code>{request.sourceUrl}</code>
355 </dd>
356 </div>
357 ) : null}
358 </dl>
359 {request.sourceNotes ? (
360 <details className="mt-4 font-mono text-xs text-[var(--color-text-muted)]">
361 <summary className="cursor-pointer">your notes at submission</summary>
362 <pre className="mt-2 whitespace-pre-wrap break-words rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg)] p-3">
363 {request.sourceNotes}
364 </pre>
365 </details>
366 ) : null}
367 </div>
368
369 <div>
370 <h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
371 migration steps
372 </h2>
373 <StepTimeline status={request.status} timeline={timeline} />
374 {timeline.length === 0 ? (
375 <p className="font-mono text-sm text-[var(--color-text-muted)]">
376 no activity yet.
377 </p>
378 ) : (
379 <ol className="flex flex-col gap-3">
380 {timeline.map((entry) => (
381 <li
382 key={entry.id}
383 className="flex gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3"
384 >
385 <span className="mt-1 size-2 shrink-0 rounded-full bg-[var(--color-primary)]" />
386 <div className="min-w-0 flex-1">
387 <p className="font-mono text-sm text-[var(--color-text)]">
388 {ACTION_LABEL[entry.action] ?? entry.action}
389 </p>
390 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
391 {formatTime(entry.createdAt)}
392 {entry.metadata.statusChanged
393 ? ' · status changed'
394 : ''}
395 {entry.metadata.messageIncluded
396 ? ' · message sent to your inbox'
397 : ''}
398 {entry.metadata.linkedUserId
399 ? ' · linked to this account'
400 : ''}
401 </p>
402 </div>
403 </li>
404 ))}
405 </ol>
406 )}
407 </div>
408
409 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
410 questions? reply to any email we sent, or write to{' '}
411 <a
412 href="mailto:migrations@flndrn.com"
413 className="underline underline-offset-2 hover:text-[var(--color-text-muted)]"
414 >
415 migrations@flndrn.com
416 </a>{' '}
417 and quote the request id.
418 </p>
419
420 <div className="mt-2 flex flex-col gap-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
421 <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
422 danger zone
423 </h3>
424 <p className="font-mono text-xs text-[var(--color-text-muted)]">
425 deleting drops this request + its audit history permanently.
426 </p>
427 <DeleteMigrationButton requestId={request.id} />
428 </div>
429 </section>
430 );
431}