d7fb7b7a7b
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
214 lines
8.4 KiB
TypeScript
214 lines
8.4 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { useAdminI18n } from '@/components/I18nProvider'
|
|
import { ADMIN_API_BASE } from '@/lib/api'
|
|
|
|
interface Company {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
status: string
|
|
email: string
|
|
contractSettings: { legalName: string | null } | null
|
|
subscription: { plan: string; status: string } | null
|
|
_count: { employees: number; vehicles: number }
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
|
TRIALING: 'text-sky-400 bg-sky-900/30',
|
|
SUSPENDED: 'text-red-400 bg-red-900/30',
|
|
PENDING: 'text-orange-400 bg-orange-900/30',
|
|
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
|
}
|
|
|
|
export default function AdminCompaniesPage() {
|
|
const { language, dict } = useAdminI18n()
|
|
const copy = {
|
|
en: {
|
|
title: 'Companies',
|
|
search: 'Search by name or slug…',
|
|
loading: 'Loading…',
|
|
empty: 'No companies found',
|
|
company: 'Company',
|
|
legalName: 'Legal name',
|
|
slug: 'Slug',
|
|
status: 'Status',
|
|
plan: 'Plan',
|
|
fleet: 'Fleet',
|
|
vehicles: 'vehicles',
|
|
view: 'View',
|
|
suspend: 'Suspend',
|
|
reactivate: 'Reactivate',
|
|
},
|
|
fr: {
|
|
title: 'Entreprises',
|
|
search: 'Rechercher par nom ou slug…',
|
|
loading: 'Chargement…',
|
|
empty: 'Aucune entreprise trouvée',
|
|
company: 'Entreprise',
|
|
legalName: 'Raison sociale',
|
|
slug: 'Slug',
|
|
status: 'Statut',
|
|
plan: 'Plan',
|
|
fleet: 'Flotte',
|
|
vehicles: 'véhicules',
|
|
view: 'Voir',
|
|
suspend: 'Suspendre',
|
|
reactivate: 'Réactiver',
|
|
},
|
|
ar: {
|
|
title: 'الشركات',
|
|
search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
|
|
loading: 'جارٍ التحميل…',
|
|
empty: 'لم يتم العثور على شركات',
|
|
company: 'الشركة',
|
|
legalName: 'الاسم القانوني',
|
|
slug: 'Slug',
|
|
status: 'الحالة',
|
|
plan: 'الخطة',
|
|
fleet: 'الأسطول',
|
|
vehicles: 'مركبة',
|
|
view: 'عرض',
|
|
suspend: 'تعليق',
|
|
reactivate: 'إعادة التفعيل',
|
|
},
|
|
}[language]
|
|
const [companies, setCompanies] = useState<Company[]>([])
|
|
const [filtered, setFiltered] = useState<Company[]>([])
|
|
const [search, setSearch] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [actioning, setActioning] = useState<string | null>(null)
|
|
|
|
async function fetchCompanies() {
|
|
try {
|
|
const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
|
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
|
setCompanies(list)
|
|
setFiltered(list)
|
|
} catch (err: any) {
|
|
setError(err.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => { fetchCompanies() }, [])
|
|
|
|
useEffect(() => {
|
|
const q = search.toLowerCase()
|
|
setFiltered(companies.filter((c) => c.name.toLowerCase().includes(q) || c.slug.includes(q) || c.email.toLowerCase().includes(q)))
|
|
}, [search, companies])
|
|
|
|
async function changeStatus(id: string, status: string) {
|
|
setActioning(id)
|
|
try {
|
|
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ status }),
|
|
})
|
|
if (!res.ok) throw new Error('Action failed')
|
|
await fetchCompanies()
|
|
} catch (err: any) {
|
|
setError(err.message)
|
|
} finally {
|
|
setActioning(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="rdg-page-heading flex items-center justify-between">
|
|
<div>
|
|
<p className="rdg-page-kicker">{dict.platform}</p>
|
|
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
|
</div>
|
|
<input
|
|
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
placeholder={copy.search}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
|
|
|
<div className="panel overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-zinc-800">
|
|
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
|
|
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.slug}</th>
|
|
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
|
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
|
|
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.fleet}</th>
|
|
<th className="px-6 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-800/60">
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
|
) : filtered.length === 0 ? (
|
|
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
|
) : filtered.map((c) => (
|
|
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
|
<td className="px-6 py-4">
|
|
<p className="font-medium text-zinc-100">{c.name}</p>
|
|
{c.contractSettings?.legalName && c.contractSettings.legalName !== c.name ? (
|
|
<p className="text-xs text-zinc-400">{copy.legalName}: {c.contractSettings.legalName}</p>
|
|
) : null}
|
|
<p className="text-xs text-zinc-500">{c.email}</p>
|
|
</td>
|
|
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[c.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
|
{c.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-zinc-400">{c.subscription?.plan ?? '—'}</td>
|
|
<td className="px-6 py-4 text-zinc-400">{c._count?.vehicles ?? 0} {copy.vehicles}</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<Link href={`/dashboard/companies/${c.id}`} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors">
|
|
{copy.view}
|
|
</Link>
|
|
{c.status === 'ACTIVE' || c.status === 'TRIALING' ? (
|
|
<button
|
|
onClick={() => changeStatus(c.id, 'SUSPENDED')}
|
|
disabled={actioning === c.id}
|
|
className="px-3 py-1.5 rounded-lg bg-red-950/50 hover:bg-red-900/50 text-xs font-medium text-red-400 transition-colors disabled:opacity-50"
|
|
>
|
|
{copy.suspend}
|
|
</button>
|
|
) : c.status === 'SUSPENDED' ? (
|
|
<button
|
|
onClick={() => changeStatus(c.id, 'ACTIVE')}
|
|
disabled={actioning === c.id}
|
|
className="px-3 py-1.5 rounded-lg bg-emerald-950/50 hover:bg-emerald-900/50 text-xs font-medium text-emerald-400 transition-colors disabled:opacity-50"
|
|
>
|
|
{copy.reactivate}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|