admin login fixed.
Build & Push / Pipeline Tests (push) Failing after 1m34s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Failing after 43s
Test / API Integration Tests (push) Successful in 1m8s
Build & Push / Pipeline Tests (push) Failing after 1m34s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Failing after 43s
Test / API Integration Tests (push) Successful in 1m8s
This commit is contained in:
@@ -212,11 +212,12 @@ const draftLineItem = (): DraftLineItem => ({
|
||||
})
|
||||
|
||||
function money(amount: number, currency: string) {
|
||||
const safeAmount = Number.isFinite(amount) ? amount : 0
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount / 100)
|
||||
}).format(safeAmount / 100)
|
||||
}
|
||||
|
||||
function dateLabel(value: string | null | undefined) {
|
||||
|
||||
@@ -207,6 +207,17 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
|
||||
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
|
||||
|
||||
const COMPANY_TABS = [
|
||||
{ id: 'company', label: 'Company' },
|
||||
{ id: 'subscription', label: 'Subscription' },
|
||||
{ id: 'publicProfile', label: 'Public profile' },
|
||||
{ id: 'legalProfile', label: 'Legal profile' },
|
||||
{ id: 'operations', label: 'Operations & finance' },
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
] as const
|
||||
|
||||
type CompanyTabId = (typeof COMPANY_TABS)[number]['id']
|
||||
|
||||
function toDateInput(value: string | null | undefined) {
|
||||
return value ? new Date(value).toISOString().slice(0, 10) : ''
|
||||
}
|
||||
@@ -318,6 +329,7 @@ export default function AdminCompanyDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -566,8 +578,40 @@ export default function AdminCompanyDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<section className="panel p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-x-auto border-b border-zinc-800">
|
||||
<div className="flex min-w-max gap-1" role="tablist" aria-label="Company detail subjects">
|
||||
{COMPANY_TABS.map((tab) => {
|
||||
const selected = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
aria-controls={`company-tab-${tab.id}`}
|
||||
id={`company-tab-trigger-${tab.id}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`rounded-t-xl px-4 py-3 text-sm font-semibold transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="company-tab-company"
|
||||
role="tabpanel"
|
||||
aria-labelledby="company-tab-trigger-company"
|
||||
hidden={activeTab !== 'company'}
|
||||
className="panel p-6"
|
||||
>
|
||||
<h2 className="text-base font-semibold">Company</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
@@ -597,7 +641,13 @@ export default function AdminCompanyDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section
|
||||
id="company-tab-subscription"
|
||||
role="tabpanel"
|
||||
aria-labelledby="company-tab-trigger-subscription"
|
||||
hidden={activeTab !== 'subscription'}
|
||||
className="panel p-6"
|
||||
>
|
||||
<h2 className="text-base font-semibold">Subscription</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
@@ -663,7 +713,13 @@ export default function AdminCompanyDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section
|
||||
id="company-tab-publicProfile"
|
||||
role="tabpanel"
|
||||
aria-labelledby="company-tab-trigger-publicProfile"
|
||||
hidden={activeTab !== 'publicProfile'}
|
||||
className="panel p-6"
|
||||
>
|
||||
<h2 className="text-base font-semibold">Brand and public profile</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
@@ -729,7 +785,13 @@ export default function AdminCompanyDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section
|
||||
id="company-tab-legalProfile"
|
||||
role="tabpanel"
|
||||
aria-labelledby="company-tab-trigger-legalProfile"
|
||||
hidden={activeTab !== 'legalProfile'}
|
||||
className="panel p-6"
|
||||
>
|
||||
<h2 className="text-base font-semibold">Company legal profile</h2>
|
||||
<div className="mt-4 grid gap-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -817,7 +879,13 @@ export default function AdminCompanyDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section
|
||||
id="company-tab-operations"
|
||||
role="tabpanel"
|
||||
aria-labelledby="company-tab-trigger-operations"
|
||||
hidden={activeTab !== 'operations'}
|
||||
className="panel p-6"
|
||||
>
|
||||
<h2 className="text-base font-semibold">Operations and finance</h2>
|
||||
<div className="mt-4 grid gap-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -915,7 +983,13 @@ export default function AdminCompanyDetailPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
id="company-tab-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="company-tab-trigger-activity"
|
||||
hidden={activeTab !== 'activity'}
|
||||
className="grid gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="panel p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold">Company actions</h2>
|
||||
|
||||
@@ -13,12 +13,11 @@ import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
|
||||
const params = new URLSearchParams({
|
||||
portal: 'admin',
|
||||
next: nextPath || '/dashboard',
|
||||
next: `/admin${nextPath || '/dashboard'}`,
|
||||
})
|
||||
return `${dashboardUrl}/sign-in?${params.toString()}`
|
||||
return `${websiteUrl}/en/light/admin-sign-in?${params.toString()}`
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
locale: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
company: { name: string } | null
|
||||
companyId: string | null
|
||||
renterId: string | null
|
||||
}
|
||||
|
||||
interface Paginated {
|
||||
data: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
import {
|
||||
fetchAdminNotifications,
|
||||
formatNotificationDate,
|
||||
type NotificationsPageResult,
|
||||
} from '@/lib/adminNotifications'
|
||||
|
||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
|
||||
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'text-sky-400 bg-sky-950/40',
|
||||
@@ -41,11 +23,26 @@ const STATUS_BADGE: Record<string, string> = {
|
||||
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||
FAILED: 'text-red-400 bg-red-950/40',
|
||||
QUEUED: 'text-sky-400 bg-sky-950/40',
|
||||
SKIPPED: 'text-zinc-400 bg-zinc-800',
|
||||
DEAD_LETTER: 'text-red-300 bg-red-950/60',
|
||||
READ: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
function formatRecipient(item: {
|
||||
recipientType: string | null
|
||||
recipientName: string | null
|
||||
recipientEmail: string | null
|
||||
employeeId: string | null
|
||||
renterId: string | null
|
||||
}) {
|
||||
const fallbackId = item.employeeId ?? item.renterId
|
||||
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
|
||||
return item.recipientType ? `${item.recipientType}: ${label}` : label
|
||||
}
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const [result, setResult] = useState<Paginated | null>(null)
|
||||
const [result, setResult] = useState<NotificationsPageResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
@@ -53,27 +50,30 @@ export default function AdminNotificationsPage() {
|
||||
const [filterCompany, setFilterCompany] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
function load(p: number) {
|
||||
function load(p: number, signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterCompany) params.set('companyId', filterCompany)
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setResult(json.data ?? null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
fetchAdminNotifications(
|
||||
p,
|
||||
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
|
||||
signal,
|
||||
)
|
||||
.then((data) => setResult(data))
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setResult(null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load notifications')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!signal?.aborted) setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setPage(1)
|
||||
load(1)
|
||||
load(1, controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [filterChannel, filterStatus, filterCompany])
|
||||
|
||||
function goToPage(p: number) {
|
||||
@@ -81,7 +81,7 @@ export default function AdminNotificationsPage() {
|
||||
load(p)
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
|
||||
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
@@ -118,6 +118,12 @@ export default function AdminNotificationsPage() {
|
||||
<option value="">All statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input
|
||||
value={filterCompany}
|
||||
onChange={(e) => setFilterCompany(e.target.value)}
|
||||
placeholder="Company ID"
|
||||
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
@@ -129,6 +135,7 @@ export default function AdminNotificationsPage() {
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
||||
@@ -138,17 +145,23 @@ export default function AdminNotificationsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||
) : result.data.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
{formatNotificationDate(item.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||
</td>
|
||||
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
|
||||
<p className="truncate">{formatRecipient(item)}</p>
|
||||
{item.recipientEmail && item.recipientName ? (
|
||||
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||
{item.type.replaceAll('_', ' ')}
|
||||
</td>
|
||||
@@ -167,7 +180,7 @@ export default function AdminNotificationsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||
{formatNotificationDate(item.sentAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default async function AdminLoginPage() {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
const websiteUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
|
||||
redirect(`${websiteUrl}/en/light/admin-sign-in?next=/admin/dashboard`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user