diff --git a/.abacusai/config.json b/.abacusai/config.json deleted file mode 100644 index 42e9ce9..0000000 --- a/.abacusai/config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cp *)", - "Bash(npm run type-check *)", - "Bash(npm run test *)", - "Bash(npm run build *)", - "Bash(npm run lint *)", - "Bash(docker *)", - "Bash(Bash(curl *))", - "Bash(Bash(nc *))", - "Bash(Bash(curl *))", - "Bash(Bash(jq *))", - "Bash(curl *)", - "Bash(find *)", - "Bash(docker-compose *)", - "Bash(npm run docker:dev:start:tools *)", - "Bash(npm *)" - ] - } -} \ No newline at end of file diff --git a/.env.docker.dev b/.env.docker.dev index b22939a..db96cde 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -34,15 +34,17 @@ CLERK_SECRET_KEY=placeholder NODE_ENV=development CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000 -# Email - disable Resend (placeholder), use SMTP instead -RESEND_API_KEY=placeholder -EMAIL_FROM=rentaldrivego@gmail.com +# Email — Resend (primary) with SMTP fallback +# Get your API key at https://resend.com/api-keys +RESEND_API_KEY=re_PLACEHOLDER +EMAIL_FROM=noreply@rentaldrivego.ma EMAIL_FROM_NAME=RentalDriveGo +# SMTP fallback (only used if Resend fails or is unconfigured) MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD=placeholder +MAIL_PASSWORD=kfahihfzbcvkczew MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com diff --git a/admin/next-env.d.ts b/admin/next-env.d.ts deleted file mode 100644 index c4b7818..0000000 --- a/admin/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/admin/next.config.js b/admin/next.config.js deleted file mode 100644 index eef4f4a..0000000 --- a/admin/next.config.js +++ /dev/null @@ -1,55 +0,0 @@ -/** @type {import('next').NextConfig} */ - -const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders') -const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') -const apiUrl = new URL(apiOrigin) -const ADMIN_BASE_PATH = '/admin' - -// In Docker dev the admin app runs on port 3002 while the marketplace proxy -// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits -// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from -// port 3002, bypassing the proxy (which can't upgrade WebSocket connections). -const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH) -const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] }) - -const nextConfig = { - basePath: ADMIN_BASE_PATH, - ...(assetPrefix ? { assetPrefix } : {}), - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'res.cloudinary.com', - }, - { - protocol: apiUrl.protocol.replace(':', ''), - hostname: apiUrl.hostname, - ...(apiUrl.port ? { port: apiUrl.port } : {}), - pathname: '/storage/**', - }, - ], - }, - transpilePackages: ['@rentaldrivego/types'], - async headers() { - return [ - { - source: '/:path*', - headers: securityHeaders, - }, - ] - }, - async rewrites() { - return [ - { - source: '/api/:path*', - destination: `${apiOrigin}/api/:path*`, - }, - { - source: '/storage/:path*', - destination: `${apiOrigin}/storage/:path*`, - }, - ] - }, -} - -module.exports = nextConfig diff --git a/admin/package.json b/admin/package.json deleted file mode 100644 index 3707ed9..0000000 --- a/admin/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@rentaldrivego/admin", - "version": "1.0.0", - "private": true, - "scripts": { - "predev": "npm run build --workspace @rentaldrivego/types", - "dev": "next dev -H 0.0.0.0 -p 3002", - "prebuild": "npm run build --workspace @rentaldrivego/types", - "build": "next build", - "prestart": "npm run build --workspace @rentaldrivego/types", - "pretype-check": "npm run build --workspace @rentaldrivego/types", - "start": "next start -H 0.0.0.0 -p 3002", - "type-check": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@rentaldrivego/types": "*", - "autoprefixer": "^10.4.19", - "dayjs": "^1.11.11", - "lucide-react": "^0.376.0", - "next": "^16.2.7", - "postcss": "^8.4.38", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "recharts": "^2.12.7", - "tailwindcss": "^3.4.3", - "zod": "^3.23.0" - }, - "devDependencies": { - "@types/node": "^20.12.0", - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.0", - "typescript": "^5.4.0", - "vitest": "^1.6.0" - } -} diff --git a/admin/postcss.config.js b/admin/postcss.config.js deleted file mode 100644 index 33ad091..0000000 --- a/admin/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/admin/src/app/auth-redirect/page.tsx b/admin/src/app/auth-redirect/page.tsx deleted file mode 100644 index b929b2c..0000000 --- a/admin/src/app/auth-redirect/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client' - -import { useEffect } from 'react' - -function resolveAdminNextPath(next: string | null) { - const fallback = '/admin/dashboard' - if (!next) return fallback - - if (/^https?:\/\//i.test(next)) return next - if (next.startsWith('/admin/')) return next - if (next === '/admin') return '/admin/dashboard' - if (next.startsWith('/')) return `/admin${next}` - - return `/admin/${next.replace(/^\/+/, '')}` -} - -export default function AuthRedirectPage() { - useEffect(() => { - const hash = window.location.hash - const params = new URLSearchParams(hash.replace(/^#/, '')) - const next = resolveAdminNextPath(params.get('next')) - - // Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie. - window.history.replaceState(null, '', window.location.pathname) - - window.location.replace(next) - }, []) - - return ( -
-

Redirecting to admin dashboard…

-
- ) -} diff --git a/admin/src/app/dashboard/AdminSessionContext.test.ts b/admin/src/app/dashboard/AdminSessionContext.test.ts deleted file mode 100644 index 6de0e93..0000000 --- a/admin/src/app/dashboard/AdminSessionContext.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { canAccessAdminMetrics } from './AdminSessionContext' - -describe('canAccessAdminMetrics', () => { - it('allows finance and higher roles after admin 2FA enrollment', () => { - expect(canAccessAdminMetrics({ role: 'FINANCE', totpEnabled: true })).toBe(true) - expect(canAccessAdminMetrics({ role: 'SUPPORT', totpEnabled: true })).toBe(true) - expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: true })).toBe(true) - expect(canAccessAdminMetrics({ role: 'SUPER_ADMIN', totpEnabled: true })).toBe(true) - }) - - it('denies viewer role and admins who still need 2FA enrollment', () => { - expect(canAccessAdminMetrics({ role: 'VIEWER', totpEnabled: true })).toBe(false) - expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: false })).toBe(false) - expect(canAccessAdminMetrics(null)).toBe(false) - }) -}) diff --git a/admin/src/app/dashboard/AdminSessionContext.tsx b/admin/src/app/dashboard/AdminSessionContext.tsx deleted file mode 100644 index 31c199b..0000000 --- a/admin/src/app/dashboard/AdminSessionContext.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client' - -import { createContext, useContext } from 'react' - -export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER' - -export type AdminSessionUser = { - id: string - email: string - role: AdminRole - totpEnabled?: boolean -} - -const METRICS_ALLOWED_ROLES = new Set(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE']) - -const AdminSessionContext = createContext(null) - -export function AdminSessionProvider({ - admin, - children, -}: { - admin: AdminSessionUser - children: React.ReactNode -}) { - return {children} -} - -export function useAdminSession() { - return useContext(AdminSessionContext) -} - -export function canAccessAdminMetrics(admin: Pick | null | undefined) { - return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role)) -} diff --git a/admin/src/app/dashboard/admin-users/page.tsx b/admin/src/app/dashboard/admin-users/page.tsx deleted file mode 100644 index 0044129..0000000 --- a/admin/src/app/dashboard/admin-users/page.tsx +++ /dev/null @@ -1,277 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { ADMIN_API_BASE } from '@/lib/api' - -interface AdminUser { - id: string - firstName: string - lastName: string - email: string - role: string - isActive: boolean - createdAt: string - permissions?: { id: string; resource: string; actions: string[] }[] -} - -const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'] -const EMPTY_FORM = { - firstName: '', - lastName: '', - email: '', - password: '', - role: 'SUPPORT', - isActive: true, -} - -export default function AdminUsersPage() { - const [admins, setAdmins] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [showModal, setShowModal] = useState(false) - const [editingAdminId, setEditingAdminId] = useState(null) - const [form, setForm] = useState(EMPTY_FORM) - const [saving, setSaving] = useState(false) - - async function fetchAdmins() { - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, { - cache: 'no-store', - credentials: 'include', - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Failed') - setAdmins(json.data ?? []) - } catch (err: any) { - setError(err.message) - } finally { - setLoading(false) - } - } - - useEffect(() => { fetchAdmins() }, []) - - function openCreateModal() { - setEditingAdminId(null) - setForm(EMPTY_FORM) - setError(null) - setShowModal(true) - } - - function openEditModal(admin: AdminUser) { - setEditingAdminId(admin.id) - setForm({ - firstName: admin.firstName, - lastName: admin.lastName, - email: admin.email, - password: '', - role: admin.role, - isActive: admin.isActive, - }) - setError(null) - setShowModal(true) - } - - function closeModal() { - setShowModal(false) - setEditingAdminId(null) - setForm(EMPTY_FORM) - } - - async function saveAdmin(e: React.FormEvent) { - e.preventDefault() - setSaving(true) - setError(null) - try { - const isEditing = Boolean(editingAdminId) - const payload = { - firstName: form.firstName, - lastName: form.lastName, - email: form.email, - role: form.role, - isActive: form.isActive, - ...(form.password ? { password: form.password } : {}), - } - - const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, { - method: isEditing ? 'PATCH' : 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(payload), - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? `Failed to ${isEditing ? 'update' : 'create'} admin`) - closeModal() - await fetchAdmins() - } catch (err: any) { - setError(err.message) - } finally { - setSaving(false) - } - } - - const ROLE_COLORS: Record = { - SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40', - ADMIN: 'text-sky-400 bg-sky-950/40', - SUPPORT: 'text-orange-400 bg-orange-950/40', - FINANCE: 'text-violet-400 bg-violet-950/40', - VIEWER: 'text-zinc-400 bg-zinc-800', - } - - return ( -
-
-
-

Platform

-

Admin Users

-
- -
- - {error &&
{error}
} - -
-
- - - - - - - - - - - - - - {loading ? ( - - ) : admins.length === 0 ? ( - - ) : admins.map((a) => ( - - - - - - - - - - ))} - -
NameEmailRolePermissionsStatusJoinedActions
Loading…
No admin users found
{a.firstName} {a.lastName}{a.email} - - {a.role} - - - {a.permissions && a.permissions.length > 0 - ? a.permissions.map((permission) => permission.resource).join(', ') - : 'Role-based only'} - - - {a.isActive ? 'Active' : 'Inactive'} - - {new Date(a.createdAt).toLocaleDateString()} - -
-
-
- - {showModal && ( -
-
-
-

{editingAdminId ? 'Edit admin user' : 'New admin user'}

- -
-
-
-
- - setForm({ ...form, firstName: e.target.value })} - /> -
-
- - setForm({ ...form, lastName: e.target.value })} - /> -
-
-
- - setForm({ ...form, email: e.target.value })} - /> -
-
- - setForm({ ...form, password: e.target.value })} - /> -
-
- - -
-
- - -
-
- - -
-
-
-
- )} -
- ) -} diff --git a/admin/src/app/dashboard/audit-logs/page.tsx b/admin/src/app/dashboard/audit-logs/page.tsx deleted file mode 100644 index ba8ca6d..0000000 --- a/admin/src/app/dashboard/audit-logs/page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { ADMIN_API_BASE } from '@/lib/api' - -interface AuditLog { - id: string - action: string - resource: string - resourceId: string | null - createdAt: string - adminUser: { email: string; firstName: string; lastName: string } | null -} - -const ACTION_COLORS: Record = { - CREATE: 'text-emerald-400 bg-emerald-950/40', - UPDATE: 'text-sky-400 bg-sky-950/40', - DELETE: 'text-red-400 bg-red-950/40', - SUSPEND: 'text-orange-400 bg-orange-950/40', - ACTIVATE: 'text-emerald-400 bg-emerald-950/40', - LOGIN: 'text-zinc-400 bg-zinc-800', -} - -export default function AdminAuditLogsPage() { - const [logs, setLogs] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [filter, setFilter] = useState('') - - useEffect(() => { - fetch(`${ADMIN_API_BASE}/admin/audit-logs`, { - credentials: 'include', - cache: 'no-store', - }) - .then((r) => r.json()) - .then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.data ?? []))) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)) - }, []) - - const filtered = filter - ? logs.filter((l) => l.action.toLowerCase().includes(filter.toLowerCase()) || l.resource.toLowerCase().includes(filter.toLowerCase())) - : logs - - return ( -
-
-
-

Platform

-

Audit Logs

-
- setFilter(e.target.value)} - /> -
- - {error &&
{error}
} - -
-
- - - - - - - - - - - - {loading ? ( - - ) : filtered.length === 0 ? ( - - ) : filtered.map((log) => ( - - - - - - - - ))} - -
ActionEntityEntity IDAdminTime
Loading…
No logs found
- - {log.action} - - {log.resource}{log.resourceId ? `${log.resourceId.slice(0, 12)}…` : '—'}{log.adminUser ? `${log.adminUser.firstName} ${log.adminUser.lastName}` : '—'}{new Date(log.createdAt).toLocaleString()}
-
-
-
- ) -} diff --git a/admin/src/app/dashboard/billing/page.tsx b/admin/src/app/dashboard/billing/page.tsx deleted file mode 100644 index 511d61f..0000000 --- a/admin/src/app/dashboard/billing/page.tsx +++ /dev/null @@ -1,1149 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import { useEffect, useMemo, useState, useTransition } from 'react' -import { useAdminI18n } from '@/components/I18nProvider' -import { ADMIN_API_BASE } from '@/lib/api' - -interface BillingStats { - billingAccountCount: number - openInvoiceCount: number - pastDueInvoiceCount: number - paidInvoiceCount: number - accountsReceivableTotal: number - recognizedRevenueTotal: number -} - -interface SubscriptionSummary { - id: string - plan: string - billingPeriod: string - status: string - currentPeriodEnd: string | null - cancelAtPeriodEnd: boolean -} - -interface CompanySummary { - id: string - name: string - email: string - slug: string - status: string - subscription: SubscriptionSummary | null -} - -interface CreditBalance { - id: string - currency: string - balanceAmount: number -} - -interface InvoiceLineItem { - id?: string - type?: string - description: string - quantity: number - unitAmount: number - amount: number - currency: string - periodStart?: string | null - periodEnd?: string | null -} - -interface PaymentAttempt { - id: string - status: string - amount: number - currency: string - providerPaymentId?: string | null - failureCode?: string | null - failureMessage?: string | null - attemptedAt: string -} - -interface CreditNote { - id: string - amount: number - currency: string - reason: string - status: string - createdAt: string -} - -interface Refund { - id: string - amount: number - currency: string - reason: string - status: string - createdAt: string -} - -interface TaxRecord { - id: string - taxAmount: number - taxRate?: number | null - taxExempt: boolean - taxType?: string | null -} - -interface BillingEvent { - id: string - eventType: string - source: string - createdAt: string -} - -interface BillingInvoice { - id: string - invoiceNumber: string | null - invoiceType: string - status: string - currency: string - totalAmount: number - amountPaid: number - amountDue: number - invoiceDate: string | null - dueAt: string | null - paidAt: string | null - createdAt: string - isSubscriptionBlocking: boolean - lineItems: InvoiceLineItem[] - paymentAttempts: PaymentAttempt[] - creditNotes: CreditNote[] - refunds: Refund[] - taxRecords: TaxRecord[] -} - -interface BillingAccountSummary { - id: string - companyId: string - legalName: string - billingEmail: string - invoiceTerms: string - netTermsDays: number - taxId?: string | null - taxExempt: boolean - dunningPaused: boolean - createdAt: string - company: CompanySummary - invoices: BillingInvoice[] - creditBalances: CreditBalance[] - openBalance: number - paidBalance: number - creditBalance: number -} - -interface BillingAccountDetail extends BillingAccountSummary { - billingAddress?: Record | null - paymentMethods: Array<{ - id: string - type: string - label?: string | null - brand?: string | null - last4?: string | null - isDefault: boolean - isActive: boolean - }> - creditLedgerEntries: Array<{ - id: string - type: string - amount: number - currency: string - reason: string - createdAt: string - }> - events: BillingEvent[] -} - -interface PaginatedResponse { - data: T[] - total: number - page: number - pageSize: number - totalPages: number - stats?: BillingStats -} - -interface DraftLineItem { - type: string - description: string - quantity: number - unitAmount: number -} - -const ACCOUNT_STATUS_COLORS: Record = { - ACTIVE: 'text-emerald-300 bg-emerald-500/15', - TRIALING: 'text-sky-300 bg-sky-500/15', - PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15', - PAST_DUE: 'text-rose-300 bg-rose-500/15', - SUSPENDED: 'text-red-300 bg-red-500/15', - CANCELLED: 'text-zinc-300 bg-zinc-700/60', - UNPAID: 'text-red-300 bg-red-500/15', -} - -const INVOICE_STATUS_COLORS: Record = { - DRAFT: 'text-zinc-300 bg-zinc-700/60', - OPEN: 'text-amber-300 bg-amber-500/15', - PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15', - PARTIALLY_PAID: 'text-sky-300 bg-sky-500/15', - PAID: 'text-emerald-300 bg-emerald-500/15', - PAST_DUE: 'text-rose-300 bg-rose-500/15', - VOID: 'text-zinc-300 bg-zinc-700/60', - UNCOLLECTIBLE: 'text-red-200 bg-red-950/60', - REFUNDED: 'text-zinc-300 bg-zinc-700/60', - PARTIALLY_REFUNDED: 'text-zinc-300 bg-zinc-700/60', -} - -const FIELD_CLASS = - 'w-full rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none transition focus:border-blue-700' - -const ACTION_PRIMARY_CLASS = - 'rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400' - -const ACTION_SECONDARY_CLASS = - 'rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500' - -const draftLineItem = (): DraftLineItem => ({ - type: 'PROFESSIONAL_SERVICES', - description: '', - quantity: 1, - unitAmount: 0, -}) - -function money(amount: number, currency: string) { - return new Intl.NumberFormat('en-GB', { - style: 'currency', - currency, - minimumFractionDigits: 2, - }).format(amount / 100) -} - -function dateLabel(value: string | null | undefined) { - if (!value) return '—' - return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) -} - -function termsLabel(invoiceTerms: string, netTermsDays: number) { - if (invoiceTerms === 'DUE_ON_RECEIPT') return 'Due on receipt' - return `Net ${netTermsDays || invoiceTerms.replace('NET_', '')}` -} - -export default function AdminBillingPage() { - const { language } = useAdminI18n() - const [isPending, startTransition] = useTransition() - - const copy = { - en: { - title: 'Billing Console', - eyebrow: 'Platform Finance', - searchPlaceholder: 'Search company, billing contact, or slug', - all: 'All', - active: 'Active', - pastDue: 'Past Due', - suspended: 'Suspended', - cancelled: 'Cancelled', - accounts: 'Accounts', - receivables: 'Receivables', - openInvoices: 'Open invoices', - paidInvoices: 'Paid invoices', - revenue: 'Revenue', - billingAccounts: 'Billing Accounts', - selectAccount: 'Select a billing account to manage invoices, credits, dunning, and payment history.', - company: 'Company', - plan: 'Plan', - subscription: 'Subscription', - openBalance: 'Open balance', - credit: 'Credit', - dunning: 'Dunning', - accountDetails: 'Billing Account', - invoiceFeed: 'Invoices', - draftInvoice: 'Create Draft Invoice', - saveAccount: 'Save account', - pauseDunning: 'Pause dunning', - resumeDunning: 'Resume dunning', - createDraft: 'Create draft', - loading: 'Loading…', - noAccounts: 'No billing accounts found', - amountDue: 'Amount due', - amountPaid: 'Amount paid', - dueDate: 'Due date', - issueDate: 'Issue date', - actions: 'Actions', - lineItems: 'Line items', - payments: 'Payment attempts', - credits: 'Credits', - refunds: 'Refunds', - timeline: 'Timeline', - finalize: 'Finalize', - pay: 'Record payment', - retry: 'Retry payment', - void: 'Void', - writeoff: 'Write off', - refund: 'Refund', - creditNote: 'Credit note', - downloadPdf: 'PDF', - invoiceType: 'Invoice type', - blocking: 'Blocking invoice', - reason: 'Reason', - billingEmail: 'Billing email', - legalName: 'Legal name', - taxId: 'Tax ID', - taxExempt: 'Tax exempt', - invoiceTerms: 'Invoice terms', - selectedInvoice: 'Selected invoice', - noInvoices: 'No invoices yet', - }, - fr: { - title: 'Console de facturation', - eyebrow: 'Finance plateforme', - searchPlaceholder: 'Rechercher une entreprise, un contact facturation ou un slug', - all: 'Tous', - active: 'Actifs', - pastDue: 'En retard', - suspended: 'Suspendus', - cancelled: 'Annulés', - accounts: 'Comptes', - receivables: 'Créances', - openInvoices: 'Factures ouvertes', - paidInvoices: 'Factures payées', - revenue: 'Revenu', - billingAccounts: 'Comptes de facturation', - selectAccount: 'Sélectionnez un compte de facturation pour gérer factures, crédits, relances et paiements.', - company: 'Entreprise', - plan: 'Plan', - subscription: 'Abonnement', - openBalance: 'Solde ouvert', - credit: 'Crédit', - dunning: 'Relance', - accountDetails: 'Compte de facturation', - invoiceFeed: 'Factures', - draftInvoice: 'Créer un brouillon', - saveAccount: 'Enregistrer', - pauseDunning: 'Mettre en pause', - resumeDunning: 'Reprendre', - createDraft: 'Créer le brouillon', - loading: 'Chargement…', - noAccounts: 'Aucun compte trouvé', - amountDue: 'Montant dû', - amountPaid: 'Montant payé', - dueDate: 'Échéance', - issueDate: 'Date émission', - actions: 'Actions', - lineItems: 'Lignes', - payments: 'Tentatives de paiement', - credits: 'Crédits', - refunds: 'Remboursements', - timeline: 'Historique', - finalize: 'Finaliser', - pay: 'Enregistrer paiement', - retry: 'Relancer paiement', - void: 'Annuler', - writeoff: 'Passer en perte', - refund: 'Rembourser', - creditNote: 'Avoir', - downloadPdf: 'PDF', - invoiceType: 'Type de facture', - blocking: 'Facture bloquante', - reason: 'Raison', - billingEmail: 'Email de facturation', - legalName: 'Raison sociale', - taxId: 'Identifiant fiscal', - taxExempt: 'Exonéré de taxe', - invoiceTerms: 'Conditions', - selectedInvoice: 'Facture sélectionnée', - noInvoices: 'Aucune facture', - }, - ar: { - title: 'لوحة الفوترة', - eyebrow: 'مالية المنصة', - searchPlaceholder: 'ابحث عن الشركة أو جهة الفوترة أو الرابط', - all: 'الكل', - active: 'نشط', - pastDue: 'متأخر', - suspended: 'معلق', - cancelled: 'ملغى', - accounts: 'الحسابات', - receivables: 'الذمم', - openInvoices: 'الفواتير المفتوحة', - paidInvoices: 'الفواتير المدفوعة', - revenue: 'الإيراد', - billingAccounts: 'حسابات الفوترة', - selectAccount: 'اختر حساب فوترة لإدارة الفواتير والأرصدة ومحاولات الدفع.', - company: 'الشركة', - plan: 'الخطة', - subscription: 'الاشتراك', - openBalance: 'الرصيد المفتوح', - credit: 'الرصيد', - dunning: 'المتابعة', - accountDetails: 'حساب الفوترة', - invoiceFeed: 'الفواتير', - draftInvoice: 'إنشاء مسودة فاتورة', - saveAccount: 'حفظ', - pauseDunning: 'إيقاف المتابعة', - resumeDunning: 'استئناف المتابعة', - createDraft: 'إنشاء المسودة', - loading: 'جارٍ التحميل…', - noAccounts: 'لا توجد حسابات فوترة', - amountDue: 'المبلغ المستحق', - amountPaid: 'المبلغ المدفوع', - dueDate: 'تاريخ الاستحقاق', - issueDate: 'تاريخ الإصدار', - actions: 'الإجراءات', - lineItems: 'البنود', - payments: 'محاولات الدفع', - credits: 'الأرصدة', - refunds: 'الاستردادات', - timeline: 'السجل', - finalize: 'اعتماد', - pay: 'تسجيل الدفع', - retry: 'إعادة المحاولة', - void: 'إلغاء', - writeoff: 'شطب', - refund: 'استرداد', - creditNote: 'إشعار دائن', - downloadPdf: 'PDF', - invoiceType: 'نوع الفاتورة', - blocking: 'فاتورة حاجزة', - reason: 'السبب', - billingEmail: 'بريد الفوترة', - legalName: 'الاسم القانوني', - taxId: 'الرقم الضريبي', - taxExempt: 'معفى من الضريبة', - invoiceTerms: 'شروط الفاتورة', - selectedInvoice: 'الفاتورة المحددة', - noInvoices: 'لا توجد فواتير', - }, - }[language] - - const [accounts, setAccounts] = useState([]) - const [stats, setStats] = useState(null) - const [search, setSearch] = useState('') - const [statusFilter, setStatusFilter] = useState('') - const [loadingList, setLoadingList] = useState(true) - const [listError, setListError] = useState(null) - - const [selectedCompanyId, setSelectedCompanyId] = useState(null) - const [detail, setDetail] = useState(null) - const [loadingDetail, setLoadingDetail] = useState(false) - const [detailError, setDetailError] = useState(null) - - const [accountForm, setAccountForm] = useState({ - legalName: '', - billingEmail: '', - taxId: '', - taxExempt: false, - invoiceTerms: 'DUE_ON_RECEIPT', - netTermsDays: 0, - }) - const [invoiceDraft, setInvoiceDraft] = useState({ - invoiceType: 'MANUAL', - isSubscriptionBlocking: false, - adminReason: '', - lineItems: [draftLineItem()], - }) - const [selectedInvoiceId, setSelectedInvoiceId] = useState(null) - const [payAmount, setPayAmount] = useState('') - const [creditAmount, setCreditAmount] = useState('') - const [creditReason, setCreditReason] = useState('') - const [refundAmount, setRefundAmount] = useState('') - const [refundReason, setRefundReason] = useState('') - const [voidReason, setVoidReason] = useState('') - const [writeoffReason, setWriteoffReason] = useState('') - const [actionError, setActionError] = useState(null) - const [actionMessage, setActionMessage] = useState(null) - - const filters = [ - { key: '', label: copy.all }, - { key: 'ACTIVE', label: copy.active }, - { key: 'PAST_DUE', label: copy.pastDue }, - { key: 'SUSPENDED', label: copy.suspended }, - { key: 'CANCELLED', label: copy.cancelled }, - ] - - const selectedInvoice = useMemo( - () => detail?.invoices.find((invoice) => invoice.id === selectedInvoiceId) ?? detail?.invoices[0] ?? null, - [detail, selectedInvoiceId], - ) - - async function api(path: string, init?: RequestInit): Promise { - const res = await fetch(`${ADMIN_API_BASE}${path}`, { - ...init, - headers: { - 'Content-Type': 'application/json', - ...(init?.headers ?? {}), - }, - credentials: 'include', - cache: 'no-store', - }) - if (res.headers.get('content-type')?.includes('application/pdf')) { - return await (res.blob() as unknown as Promise) - } - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? `Request failed (${res.status})`) - return json.data as T - } - - async function fetchAccounts(q?: string, status?: string) { - setLoadingList(true) - setListError(null) - try { - const params = new URLSearchParams({ pageSize: '100' }) - if (q) params.set('q', q) - if (status) params.set('status', status) - const data = await api>(`/admin/billing?${params.toString()}`) - setAccounts(data.data) - setStats(data.stats ?? null) - setSelectedCompanyId((current) => current ?? data.data[0]?.company.id ?? null) - } catch (error: any) { - setListError(error.message) - } finally { - setLoadingList(false) - } - } - - async function fetchDetail(companyId: string) { - setLoadingDetail(true) - setDetailError(null) - try { - const data = await api(`/admin/billing/${companyId}`) - setDetail(data) - setAccountForm({ - legalName: data.legalName, - billingEmail: data.billingEmail, - taxId: data.taxId ?? '', - taxExempt: data.taxExempt, - invoiceTerms: data.invoiceTerms, - netTermsDays: data.netTermsDays, - }) - setSelectedInvoiceId(data.invoices[0]?.id ?? null) - } catch (error: any) { - setDetailError(error.message) - } finally { - setLoadingDetail(false) - } - } - - async function refreshSelectedAccount() { - await fetchAccounts(search || undefined, statusFilter || undefined) - if (selectedCompanyId) await fetchDetail(selectedCompanyId) - } - - useEffect(() => { - const id = setTimeout(() => { - fetchAccounts(search || undefined, statusFilter || undefined) - }, 200) - return () => clearTimeout(id) - }, [search, statusFilter]) - - useEffect(() => { - if (!selectedCompanyId) return - fetchDetail(selectedCompanyId) - }, [selectedCompanyId]) - - function setDraftLine(index: number, key: keyof DraftLineItem, value: string | number) { - setInvoiceDraft((current) => ({ - ...current, - lineItems: current.lineItems.map((item, itemIndex) => - itemIndex === index ? { ...item, [key]: value } : item, - ), - })) - } - - async function saveBillingAccount() { - if (!detail) return - setActionError(null) - setActionMessage(null) - try { - await api(`/admin/billing/accounts/${detail.id}`, { - method: 'PATCH', - body: JSON.stringify({ - legalName: accountForm.legalName, - billingEmail: accountForm.billingEmail, - taxId: accountForm.taxId || null, - taxExempt: accountForm.taxExempt, - invoiceTerms: accountForm.invoiceTerms, - netTermsDays: accountForm.netTermsDays, - }), - }) - setActionMessage('Billing account updated.') - await refreshSelectedAccount() - } catch (error: any) { - setActionError(error.message) - } - } - - async function toggleDunning(paused: boolean) { - if (!detail) return - setActionError(null) - setActionMessage(null) - try { - await api(`/admin/billing/accounts/${detail.id}/${paused ? 'pause-dunning' : 'resume-dunning'}`, { - method: 'POST', - body: JSON.stringify({}), - }) - setActionMessage(paused ? 'Dunning paused.' : 'Dunning resumed.') - await refreshSelectedAccount() - } catch (error: any) { - setActionError(error.message) - } - } - - async function createDraftInvoice() { - if (!detail) return - setActionError(null) - setActionMessage(null) - try { - await api(`/admin/billing/accounts/${detail.id}/invoices`, { - method: 'POST', - body: JSON.stringify({ - invoiceType: invoiceDraft.invoiceType, - isSubscriptionBlocking: invoiceDraft.isSubscriptionBlocking, - adminReason: invoiceDraft.adminReason || null, - lineItems: invoiceDraft.lineItems.map((item) => ({ - ...item, - unitAmount: Number(item.unitAmount), - quantity: Number(item.quantity), - })), - }), - }) - setInvoiceDraft({ - invoiceType: 'MANUAL', - isSubscriptionBlocking: false, - adminReason: '', - lineItems: [draftLineItem()], - }) - setActionMessage('Draft invoice created.') - await refreshSelectedAccount() - } catch (error: any) { - setActionError(error.message) - } - } - - async function runInvoiceAction(path: string, body?: Record) { - if (!selectedInvoice) return - setActionError(null) - setActionMessage(null) - try { - await api(`/admin/billing/invoices/${selectedInvoice.id}/${path}`, { - method: 'POST', - body: JSON.stringify(body ?? {}), - }) - setActionMessage('Invoice action completed.') - await refreshSelectedAccount() - } catch (error: any) { - setActionError(error.message) - } - } - - async function downloadPdf() { - if (!selectedInvoice) return - setActionError(null) - try { - const blob = await api(`/admin/billing/invoices/${selectedInvoice.id}/pdf`) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${selectedInvoice.invoiceNumber ?? selectedInvoice.id}.pdf` - a.click() - URL.revokeObjectURL(url) - } catch (error: any) { - setActionError(error.message) - } - } - - return ( -
-
-
-

{copy.eyebrow}

-

{copy.title}

-
-
- setSearch(event.target.value)} - placeholder={copy.searchPlaceholder} - className="w-full rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-100 outline-none transition focus:border-blue-700" - /> -
-
- - {stats && ( -
- - - - - -
- )} - -
- {filters.map((filter) => ( - - ))} -
- -
-
-
-

{copy.billingAccounts}

-
-
- {loadingList ? ( -
{copy.loading}
- ) : listError ? ( -
{listError}
- ) : accounts.length === 0 ? ( -
{copy.noAccounts}
- ) : ( -
- {accounts.map((account) => { - const isSelected = selectedCompanyId === account.company.id - const subscription = account.company.subscription - return ( - - ) - })} -
- )} -
-
- -
- {!selectedCompanyId ? ( -
{copy.selectAccount}
- ) : loadingDetail && !detail ? ( -
{copy.loading}
- ) : detailError ? ( -
{detailError}
- ) : detail ? ( - <> -
-
-
-

{copy.accountDetails}

-

{detail.company.name}

-
- - {detail.company.subscription?.status ?? 'NO_SUBSCRIPTION'} - - - {detail.company.subscription?.plan ?? 'No plan'} / {detail.company.subscription?.billingPeriod ?? '—'} - - - {termsLabel(detail.invoiceTerms, detail.netTermsDays)} - -
-
-
- - - - -
-
- -
- - setAccountForm((current) => ({ ...current, legalName: event.target.value }))} - className={FIELD_CLASS} - /> - - - setAccountForm((current) => ({ ...current, billingEmail: event.target.value }))} - className={FIELD_CLASS} - /> - - - setAccountForm((current) => ({ ...current, taxId: event.target.value }))} - className={FIELD_CLASS} - /> - - - - -
- -
- - - -
-
- -
-
-

{copy.invoiceFeed}

- {isPending || loadingDetail ? {copy.loading} : null} -
- - {detail.invoices.length === 0 ? ( -
{copy.noInvoices}
- ) : ( -
-
- - - - - - - - - - - {detail.invoices.map((invoice) => ( - setSelectedInvoiceId(invoice.id)} - > - - - - - - ))} - -
Invoice{copy.amountDue}{copy.dueDate}{copy.actions}
-
-

{invoice.invoiceNumber ?? invoice.invoiceType}

-

{dateLabel(invoice.invoiceDate ?? invoice.createdAt)}

-
-
{money(invoice.amountDue, invoice.currency)}{dateLabel(invoice.dueAt)} - - {invoice.status} - -
-
- - {selectedInvoice && ( -
-
-

{copy.selectedInvoice}

-
-
-

{selectedInvoice.invoiceNumber ?? selectedInvoice.invoiceType}

-

- {copy.issueDate}: {dateLabel(selectedInvoice.invoiceDate ?? selectedInvoice.createdAt)} -

-
- - {selectedInvoice.status} - -
-
- -
- - -
- -
-

{copy.lineItems}

-
- {selectedInvoice.lineItems.map((item, index) => ( -
-
-
-

{item.description}

-

- {item.quantity} × {money(item.unitAmount, item.currency)} -

-
-

{money(item.amount, item.currency)}

-
-
- ))} -
-
- -
- {selectedInvoice.status === 'DRAFT' ? ( - - ) : null} - {['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(selectedInvoice.status) ? ( - <> -
- setPayAmount(event.target.value)} - placeholder="Amount in cents" - className={FIELD_CLASS} - /> - -
- -
- setVoidReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
-
- setWriteoffReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
-
- setCreditAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} /> - setCreditReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
- - ) : null} - {['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(selectedInvoice.status) ? ( -
- setRefundAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} /> - setRefundReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
- ) : null} - {selectedInvoice.invoiceNumber ? ( - - ) : null} -
- - ({ - id: attempt.id, - primary: `${attempt.status} · ${money(attempt.amount, attempt.currency)}`, - secondary: `${dateLabel(attempt.attemptedAt)}${attempt.failureMessage ? ` · ${attempt.failureMessage}` : ''}`, - }))} /> - ({ - id: note.id, - primary: `${money(note.amount, note.currency)} · ${note.status}`, - secondary: note.reason, - }))} /> - ({ - id: refund.id, - primary: `${money(refund.amount, refund.currency)} · ${refund.status}`, - secondary: refund.reason, - }))} /> -
- )} -
- )} -
- -
-

{copy.draftInvoice}

-
- - - - - setInvoiceDraft((current) => ({ ...current, adminReason: event.target.value }))} - className={FIELD_CLASS} - /> - - -
- -
- {invoiceDraft.lineItems.map((item, index) => ( -
- - setDraftLine(index, 'description', event.target.value)} placeholder="Description" className={FIELD_CLASS} /> - setDraftLine(index, 'quantity', Number(event.target.value))} className={FIELD_CLASS} /> - setDraftLine(index, 'unitAmount', Number(event.target.value))} placeholder="Unit amount (cents)" className={FIELD_CLASS} /> - -
- ))} -
- -
- - -
-
- -
-
- ({ - id: event.id, - primary: event.eventType, - secondary: `${event.source} · ${dateLabel(event.createdAt)}`, - }))} /> - ({ - id: entry.id, - primary: `${money(entry.amount, entry.currency)} · ${entry.type}`, - secondary: entry.reason, - }))} /> -
-
- - ) : null} -
-
- - {actionError ?
{actionError}
: null} - {actionMessage ?
{actionMessage}
: null} -
- ) -} - -function StatCard({ label, value, tone }: { label: string; value: string; tone: string }) { - return ( -
-

{label}

-

{value}

-
- ) -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ) -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( - - ) -} - -function DetailList({ - title, - items, -}: { - title: string - items: Array<{ id: string; primary: string; secondary: string }> -}) { - return ( -
-

{title}

- {items.length === 0 ? ( -
- ) : ( -
- {items.map((item) => ( -
-

{item.primary}

-

{item.secondary}

-
- ))} -
- )} -
- ) -} diff --git a/admin/src/app/dashboard/companies/[id]/page.tsx b/admin/src/app/dashboard/companies/[id]/page.tsx deleted file mode 100644 index 068b96f..0000000 --- a/admin/src/app/dashboard/companies/[id]/page.tsx +++ /dev/null @@ -1,1031 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { useParams, useRouter } from 'next/navigation' -import Link from 'next/link' -import { ADMIN_API_BASE } from '@/lib/api' - -interface CompanyAddressProfile { - streetAddress?: string | null - city?: string | null - country?: string | null - zipCode?: string | null - legalName?: string | null - legalForm?: string | null - companyEmail?: string | null - managerName?: string | null - iceNumber?: string | null - operatingLicenseNumber?: string | null - operatingLicenseIssuedAt?: string | null - operatingLicenseIssuedBy?: string | null - fax?: string | null - yearsActive?: string | null - representativeName?: string | null - representativeTitle?: string | null - responsibleName?: string | null - responsibleRole?: string | null - responsibleIdentityNumber?: string | null - responsibleQualification?: string | null - responsiblePhone?: string | null - responsibleEmail?: string | null -} - -interface CompanyDetail { - id: string - name: string - slug: string - email: string - phone: string | null - address: CompanyAddressProfile | string | null - status: string - subscriptionPaymentRef: string | null - createdAt: string - subscription: { - id: string - plan: string - billingPeriod: string - status: string - currency: string - trialStartAt: string | null - trialEndAt: string | null - currentPeriodStart: string | null - currentPeriodEnd: string | null - cancelledAt: string | null - cancelAtPeriodEnd: boolean - paymentPendingSince: string | null - pastDueSince: string | null - suspendedAt: string | null - retryCount: number - } | null - brand: { - displayName: string - tagline: string | null - subdomain: string - customDomain: string | null - publicEmail: string | null - publicPhone: string | null - publicAddress: string | null - publicCity: string | null - publicCountry: string | null - websiteUrl: string | null - whatsappNumber: string | null - defaultLocale: string - defaultCurrency: string - isListedOnMarketplace: boolean - } | null - contractSettings: { - legalName: string | null - registrationNumber: string | null - taxId: string | null - terms: string - fuelPolicyType: string - lateFeePerHour: number | null - taxRate: number | null - signatureRequired: boolean - showTax: boolean - } | null - accountingSettings: { - reportingPeriod: string - fiscalYearStart: number - currency: string - accountantEmail: string | null - accountantName: string | null - autoSendReport: boolean - reportFormat: string - } | null - employees?: Array<{ id: string }> - vehicles?: Array<{ id: string }> - customers?: Array<{ id: string }> - reservations?: Array<{ id: string }> - _count?: { employees?: number; vehicles?: number; customers?: number; reservations?: number } -} - -interface AuditLog { - id: string - action: string - resource: string - resourceId: string | null - createdAt: string - adminUser: { email: string } | null -} - -interface SubEvent { - id: string - eventType: string - source: string - payload: Record - occurredAt: string -} - -interface FormState { - company: { - name: string - slug: string - email: string - phone: string - status: string - subscriptionPaymentRef: string - } - companyProfile: { - legalForm: string - managerName: string - zipCode: string - fax: string - yearsActive: string - iceNumber: string - operatingLicenseNumber: string - operatingLicenseIssuedAt: string - operatingLicenseIssuedBy: string - representativeName: string - representativeTitle: string - responsibleName: string - responsibleRole: string - responsibleIdentityNumber: string - responsibleQualification: string - responsiblePhone: string - responsibleEmail: string - } - subscription: { - plan: string - billingPeriod: string - status: string - currency: string - trialStartAt: string - trialEndAt: string - currentPeriodStart: string - currentPeriodEnd: string - cancelledAt: string - cancelAtPeriodEnd: boolean - } - brand: { - displayName: string - tagline: string - subdomain: string - customDomain: string - publicEmail: string - publicPhone: string - publicAddress: string - publicCity: string - publicCountry: string - websiteUrl: string - whatsappNumber: string - defaultLocale: string - defaultCurrency: string - isListedOnMarketplace: boolean - } - contractSettings: { - legalName: string - registrationNumber: string - taxId: string - terms: string - fuelPolicyType: string - lateFeePerHour: string - taxRate: string - signatureRequired: boolean - showTax: boolean - } - accountingSettings: { - reportingPeriod: string - fiscalYearStart: string - currency: string - accountantEmail: string - accountantName: string - autoSendReport: boolean - reportFormat: string - } -} - -const STATUS_COLORS: Record = { - ACTIVE: 'text-emerald-400', - TRIALING: 'text-sky-400', - SUSPENDED: 'text-red-400', - PENDING: 'text-orange-400', - CANCELLED: 'text-zinc-400', - PAST_DUE: 'text-orange-400', -} - -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-blue-700' -const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500' - -function toDateInput(value: string | null | undefined) { - return value ? new Date(value).toISOString().slice(0, 10) : '' -} - -function toNullable(value: string) { - const trimmed = value.trim() - return trimmed ? trimmed : null -} - -function normalizeAddressProfile(value: CompanyDetail['address']): CompanyAddressProfile { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value -} - -function createFormState(company: CompanyDetail): FormState { - const address = normalizeAddressProfile(company.address) - return { - company: { - name: company.name ?? '', - slug: company.slug ?? '', - email: company.email ?? '', - phone: company.phone ?? '', - status: company.status ?? 'TRIALING', - subscriptionPaymentRef: company.subscriptionPaymentRef ?? '', - }, - companyProfile: { - legalForm: address.legalForm ?? '', - managerName: address.managerName ?? '', - zipCode: address.zipCode ?? '', - fax: address.fax ?? '', - yearsActive: address.yearsActive ?? '', - iceNumber: address.iceNumber ?? '', - operatingLicenseNumber: address.operatingLicenseNumber ?? '', - operatingLicenseIssuedAt: address.operatingLicenseIssuedAt ?? '', - operatingLicenseIssuedBy: address.operatingLicenseIssuedBy ?? '', - representativeName: address.representativeName ?? '', - representativeTitle: address.representativeTitle ?? '', - responsibleName: address.responsibleName ?? '', - responsibleRole: address.responsibleRole ?? '', - responsibleIdentityNumber: address.responsibleIdentityNumber ?? '', - responsibleQualification: address.responsibleQualification ?? '', - responsiblePhone: address.responsiblePhone ?? '', - responsibleEmail: address.responsibleEmail ?? '', - }, - subscription: { - plan: company.subscription?.plan ?? 'STARTER', - billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY', - status: company.subscription?.status ?? 'TRIALING', - currency: company.subscription?.currency ?? 'MAD', - trialStartAt: toDateInput(company.subscription?.trialStartAt), - trialEndAt: toDateInput(company.subscription?.trialEndAt), - currentPeriodStart: toDateInput(company.subscription?.currentPeriodStart), - currentPeriodEnd: toDateInput(company.subscription?.currentPeriodEnd), - cancelledAt: toDateInput(company.subscription?.cancelledAt), - cancelAtPeriodEnd: company.subscription?.cancelAtPeriodEnd ?? false, - }, - brand: { - displayName: company.brand?.displayName ?? company.name ?? '', - tagline: company.brand?.tagline ?? '', - subdomain: company.brand?.subdomain ?? company.slug ?? '', - customDomain: company.brand?.customDomain ?? '', - publicEmail: company.brand?.publicEmail ?? '', - publicPhone: company.brand?.publicPhone ?? '', - publicAddress: company.brand?.publicAddress ?? '', - publicCity: company.brand?.publicCity ?? '', - publicCountry: company.brand?.publicCountry ?? '', - websiteUrl: company.brand?.websiteUrl ?? '', - whatsappNumber: company.brand?.whatsappNumber ?? '', - defaultLocale: company.brand?.defaultLocale ?? 'en', - defaultCurrency: company.brand?.defaultCurrency ?? 'MAD', - isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true, - }, - contractSettings: { - legalName: company.contractSettings?.legalName ?? '', - registrationNumber: company.contractSettings?.registrationNumber ?? '', - taxId: company.contractSettings?.taxId ?? '', - terms: company.contractSettings?.terms ?? '', - fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL', - lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '', - taxRate: company.contractSettings?.taxRate?.toString() ?? '', - signatureRequired: company.contractSettings?.signatureRequired ?? true, - showTax: company.contractSettings?.showTax ?? false, - }, - accountingSettings: { - reportingPeriod: company.accountingSettings?.reportingPeriod ?? 'MONTHLY', - fiscalYearStart: company.accountingSettings?.fiscalYearStart?.toString() ?? '1', - currency: company.accountingSettings?.currency ?? 'MAD', - accountantEmail: company.accountingSettings?.accountantEmail ?? '', - accountantName: company.accountingSettings?.accountantName ?? '', - autoSendReport: company.accountingSettings?.autoSendReport ?? false, - reportFormat: company.accountingSettings?.reportFormat ?? 'PDF', - }, - } -} - -export default function AdminCompanyDetailPage() { - const { id } = useParams<{ id: string }>() - const router = useRouter() - const [company, setCompany] = useState(null) - const [form, setForm] = useState(null) - const [auditLogs, setAuditLogs] = useState([]) - const [subEvents, setSubEvents] = useState([]) - const [subActioning, setSubActioning] = useState(null) - const [subOverrideReason, setSubOverrideReason] = useState('') - const [showSubActions, setShowSubActions] = useState(false) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [actioning, setActioning] = useState(false) - const [saving, setSaving] = useState(false) - const [savedMessage, setSavedMessage] = useState(null) - const [deleteConfirm, setDeleteConfirm] = useState(false) - - async function fetchData() { - try { - const [cRes, aRes] = await Promise.all([ - fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { cache: 'no-store', credentials: 'include' }), - fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { cache: 'no-store', credentials: 'include' }), - ]) - const cJson = await cRes.json() - const aJson = await aRes.json() - if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found') - setCompany(cJson.data) - setForm(createFormState(cJson.data)) - setAuditLogs(aJson.data?.data ?? []) - - const subId = cJson.data?.subscription?.id - if (subId) { - const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { cache: 'no-store', credentials: 'include' }) - const eJson = await eRes.json() - setSubEvents(Array.isArray(eJson.data) ? eJson.data : []) - } - setError(null) - } catch (err: any) { - setError(err.message) - } finally { - setLoading(false) - } - } - - useEffect(() => { fetchData() }, [id]) - - function updateSection(section: K, patch: Partial) { - setForm((current) => current ? { ...current, [section]: { ...current[section], ...patch } } : current) - } - - async function saveChanges() { - if (!form) return - setSaving(true) - setSavedMessage(null) - setError(null) - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - company: { - name: form.company.name, - slug: form.company.slug, - email: form.company.email, - phone: toNullable(form.company.phone), - subscriptionPaymentRef: toNullable(form.company.subscriptionPaymentRef), - address: { - streetAddress: toNullable(form.brand.publicAddress), - city: toNullable(form.brand.publicCity), - country: toNullable(form.brand.publicCountry), - zipCode: toNullable(form.companyProfile.zipCode), - legalName: toNullable(form.contractSettings.legalName), - legalForm: toNullable(form.companyProfile.legalForm), - companyEmail: toNullable(form.company.email), - managerName: toNullable(form.companyProfile.managerName), - iceNumber: toNullable(form.companyProfile.iceNumber), - operatingLicenseNumber: toNullable(form.companyProfile.operatingLicenseNumber), - operatingLicenseIssuedAt: toNullable(form.companyProfile.operatingLicenseIssuedAt), - operatingLicenseIssuedBy: toNullable(form.companyProfile.operatingLicenseIssuedBy), - fax: toNullable(form.companyProfile.fax), - yearsActive: toNullable(form.companyProfile.yearsActive), - representativeName: toNullable(form.companyProfile.representativeName), - representativeTitle: toNullable(form.companyProfile.representativeTitle), - responsibleName: toNullable(form.companyProfile.responsibleName), - responsibleRole: toNullable(form.companyProfile.responsibleRole), - responsibleIdentityNumber: toNullable(form.companyProfile.responsibleIdentityNumber), - responsibleQualification: toNullable(form.companyProfile.responsibleQualification), - responsiblePhone: toNullable(form.companyProfile.responsiblePhone), - responsibleEmail: toNullable(form.companyProfile.responsibleEmail), - }, - }, - subscription: { - plan: form.subscription.plan, - billingPeriod: form.subscription.billingPeriod, - status: form.subscription.status, - currency: form.subscription.currency, - trialStartAt: toNullable(form.subscription.trialStartAt), - trialEndAt: toNullable(form.subscription.trialEndAt), - currentPeriodStart: toNullable(form.subscription.currentPeriodStart), - currentPeriodEnd: toNullable(form.subscription.currentPeriodEnd), - cancelledAt: toNullable(form.subscription.cancelledAt), - cancelAtPeriodEnd: form.subscription.cancelAtPeriodEnd, - }, - brand: { - displayName: form.brand.displayName, - tagline: toNullable(form.brand.tagline), - subdomain: form.brand.subdomain, - customDomain: toNullable(form.brand.customDomain), - publicEmail: toNullable(form.brand.publicEmail), - publicPhone: toNullable(form.brand.publicPhone), - publicAddress: toNullable(form.brand.publicAddress), - publicCity: toNullable(form.brand.publicCity), - publicCountry: toNullable(form.brand.publicCountry), - websiteUrl: toNullable(form.brand.websiteUrl), - whatsappNumber: toNullable(form.brand.whatsappNumber), - defaultLocale: form.brand.defaultLocale, - defaultCurrency: form.brand.defaultCurrency, - isListedOnMarketplace: form.brand.isListedOnMarketplace, - }, - contractSettings: { - legalName: toNullable(form.contractSettings.legalName), - registrationNumber: toNullable(form.contractSettings.registrationNumber), - taxId: toNullable(form.contractSettings.taxId), - terms: form.contractSettings.terms, - fuelPolicyType: form.contractSettings.fuelPolicyType, - lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null, - taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null, - signatureRequired: form.contractSettings.signatureRequired, - showTax: form.contractSettings.showTax, - }, - accountingSettings: { - reportingPeriod: form.accountingSettings.reportingPeriod, - fiscalYearStart: Number(form.accountingSettings.fiscalYearStart || 1), - currency: form.accountingSettings.currency, - accountantEmail: toNullable(form.accountingSettings.accountantEmail), - accountantName: toNullable(form.accountingSettings.accountantName), - autoSendReport: form.accountingSettings.autoSendReport, - reportFormat: form.accountingSettings.reportFormat, - }, - }), - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Save failed') - setCompany(json.data) - setForm(createFormState(json.data)) - setSavedMessage('Company settings updated.') - await fetchData() - } catch (err: any) { - setError(err.message) - } finally { - setSaving(false) - } - } - - async function doSubAction(action: string) { - if (!company?.subscription?.id) return - if (!subOverrideReason.trim()) { setError('A reason is required for subscription overrides'); return } - setSubActioning(action) - setError(null) - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ reason: subOverrideReason }), - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Action failed') - setSubOverrideReason('') - setShowSubActions(false) - await fetchData() - } catch (err: any) { - setError(err.message) - } finally { - setSubActioning(null) - } - } - - async function changeStatus(status: string) { - setActioning(true) - setError(null) - 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 }), - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Action failed') - await fetchData() - } catch (err: any) { - setError(err.message) - } finally { - setActioning(false) - } - } - - async function deleteCompany() { - setActioning(true) - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { - method: 'DELETE', - credentials: 'include', - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Delete failed') - router.push('/dashboard/companies') - } catch (err: any) { - setError(err.message) - setActioning(false) - } - } - - if (loading) return
Loading…
- if (error && !company) return
{error}
- if (!company || !form) return null - - const counts = { - employees: company._count?.employees ?? company.employees?.length ?? 0, - vehicles: company._count?.vehicles ?? company.vehicles?.length ?? 0, - customers: company._count?.customers ?? company.customers?.length ?? 0, - reservations: company._count?.reservations ?? company.reservations?.length ?? 0, - } - - return ( -
-
- ← Companies - -
- -
-
-

{company.name}

-

{company.slug}

-
- {company.status} -
- - {error &&
{error}
} - {savedMessage &&
{savedMessage}
} - -
- {[ - { label: 'Employees', value: counts.employees }, - { label: 'Vehicles', value: counts.vehicles }, - { label: 'Customers', value: counts.customers }, - { label: 'Reservations', value: counts.reservations }, - ].map((kpi) => ( -
-

{kpi.label}

-

{kpi.value}

-
- ))} -
- -
-
-

Company

-
- - - - -
- Company status -

{form.company.status}

-
- -
-
- -
-

Subscription

-
- - -
- Status -

{form.subscription.status}

- {company.subscription?.paymentPendingSince && ( -

Pending since {new Date(company.subscription.paymentPendingSince).toLocaleDateString()}

- )} - {company.subscription?.pastDueSince && ( -

Past due since {new Date(company.subscription.pastDueSince).toLocaleDateString()}

- )} - {company.subscription?.suspendedAt && ( -

Suspended {new Date(company.subscription.suspendedAt).toLocaleDateString()}

- )} -
- - - - - - - -
-
- -
-

Brand and public profile

-
- - - - - - - - - - - - - - -
-
- -
-

Company legal profile

-
-
- - - - - - - - - -
- -
-

Legal representative

-
- - -
-
- -
-

Responsible person

-
- - - - - - -
-
-
-
- -
-

Operations and finance

-
-
- - - - - - - - -