diff --git a/DESIGN_MIGRATION_REPORT.md b/DESIGN_MIGRATION_REPORT.md new file mode 100644 index 0000000..4b265dd --- /dev/null +++ b/DESIGN_MIGRATION_REPORT.md @@ -0,0 +1,47 @@ +# RentalDriveGo Phase 16 Design Migration + +## Outcome + +The Phase 16 visual system has been applied at source level to the legacy application front ends in this archive: + +- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy archive. +- `admin`: migrated to the Phase 16 token palette and component treatment; navigation was rebuilt as a responsive, accessible application shell. +- `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy. +- `storefront`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming. +- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear. + +## Design rules applied + +- Blue is the operational primary color. +- Orange is reserved for conversion emphasis and focus visibility. +- Green is restored to success/status meaning instead of being used as a disguised primary action. +- Light and dark themes share semantic tokens rather than unrelated hard-coded palettes. +- English and French remain LTR; Arabic continues to use document RTL and an Arabic font fallback stack. +- Interactive controls use a minimum 44px target, visible focus treatment, reduced-motion handling, and high-contrast fallbacks. +- Cards and panels use the Phase 16 radius, border, surface, and shadow hierarchy. +- The retired `RentalDriveGo` name was replaced with `RentalDriveGo` throughout all frontend source files. + +## Important source finding + +The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence and launch-governance package, not the Phase 16 source repository. It contains 98 files, while its manifest describes a separate 648-file repository that was not included. The legacy archive already contained most of that repository's marketing homepage implementation: 182 files match the Phase 16 manifest checksums exactly. That embedded homepage implementation was therefore used as the reliable visual source of truth rather than fabricating a new design from launch paperwork. + +## Files added + +- `admin/src/styles/phase16-tokens.css` +- `dashboard/src/styles/phase16-tokens.css` +- `storefront/src/styles/phase16-tokens.css` +- `scripts/validate-phase16-design.mjs` +- `DESIGN_MIGRATION_REPORT.md` +- `PHASE16_DESIGN_MIGRATION_MANIFEST.json` + +## Validation performed + +- Parsed 633 TypeScript and TSX source files with TypeScript 5.8 syntax validation: passed. +- Checked 53 CSS files for balanced rule blocks: passed. +- Verified all three migrated apps import the Phase 16 tokens: passed. +- Verified no retired `RentalDriveGo` branding remains in frontend source: passed. +- Ran `node scripts/validate-phase16-design.mjs`: passed. + +## Validation not claimed + +A full dependency-backed build and browser regression suite was not run. The supplied legacy archive omits its root workspace configuration and the local packages referenced as `@rentaldrivego/types` and `@rentaldrivego/database`. The Phase 16 evidence upload also omits the source repository and dependency lockfile it references. Those are packaging defects in the inputs, not facts to conceal with optimistic prose. diff --git a/NAVBAR_FOOTER_REFACTOR_REPORT.md b/NAVBAR_FOOTER_REFACTOR_REPORT.md new file mode 100644 index 0000000..7afdc7a --- /dev/null +++ b/NAVBAR_FOOTER_REFACTOR_REPORT.md @@ -0,0 +1,33 @@ +# RentalDriveGo Public Chrome Refactor Report + +## Completed + +1. Extracted the dashboard public navbar into `components/public/PublicNavbar.tsx`. +2. Extracted the dashboard public footer into `components/public/PublicFooter.tsx`. +3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages. +4. Updated sign-in, create-account, forgot-password, reset-password, verify-email, onboarding, and invitation pages to use the public component API. +5. Preserved legacy component paths through compatibility exports. +6. Moved storefront navbar/footer assembly out of the Next.js route layout and into reusable public components. +7. Added active-page semantics to dashboard sign-in and create-account navbar actions. +8. Added focused tests and implementation documentation. + +## Deliberately unchanged + +- Authentication requests and API endpoints +- Redirect behavior between storefront, dashboard, and admin applications +- Language and theme persistence +- Embedded workspace behavior +- Authenticated dashboard and admin navigation + +## Validation performed + +- Parsed all TypeScript and TSX source files successfully. +- Verified all relative and `@/` local imports resolve. +- Verified every `use client` directive remains the first statement. +- Verified sign-in and create-account import and render `PublicPageLayout`. +- Verified the storefront route layout no longer assembles navbar/footer inline. +- Verified embedded password-recovery navigation preserves `embedded=1`. + +## Validation limitation + +A complete Next.js build was not executed because this archive does not include the monorepo root, workspace packages, lockfile, or installed dependencies referenced by the application package files. Static source validation was completed instead; pretending that equals a production build would be charmingly irresponsible. diff --git a/PHASE16_DESIGN_MIGRATION_MANIFEST.json b/PHASE16_DESIGN_MIGRATION_MANIFEST.json new file mode 100644 index 0000000..2af58ef --- /dev/null +++ b/PHASE16_DESIGN_MIGRATION_MANIFEST.json @@ -0,0 +1,232 @@ +{ + "schema_version": "1.0", + "project": "RentalDriveGo", + "migration": "Apply Phase 16 design system to legacy Archive.zip frontends", + "generated_at": "2026-06-26T02:14:35+00:00", + "source_archives": { + "legacy_archive": "Archive.zip", + "phase16_evidence": "RentalDriveGo_Phase16_Evidence_Package_v1.0.zip" + }, + "scope": { + "homepage": "retained as embedded Phase 16 source of truth", + "admin": "migrated", + "dashboard": "migrated", + "storefront": "migrated", + "api": "unchanged" + }, + "validation": { + "typescript_syntax_files": 633, + "typescript_syntax": "passed", + "css_files_checked": 53, + "css_balance": "passed", + "design_migration_validator": "passed", + "full_build": "not run because root workspace and local packages are absent from supplied archive" + }, + "changed_file_count": 34, + "files": [ + { + "path": "DESIGN_MIGRATION_REPORT.md", + "status": "added", + "bytes": 3260, + "sha256": "35c41d70484b917b38991791dfb2feb3e78234e4f30e2c99ba922d3d90ab83a6" + }, + { + "path": "scripts/validate-phase16-design.mjs", + "status": "added", + "bytes": 1891, + "sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64" + }, + { + "path": "storefront/src/app/layout.tsx", + "status": "modified", + "bytes": 1833, + "sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27" + }, + { + "path": "storefront/src/app/globals.css", + "status": "modified", + "bytes": 7525, + "sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6" + }, + { + "path": "storefront/src/components/MarketplaceFooter.tsx", + "status": "modified", + "bytes": 4632, + "sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3" + }, + { + "path": "storefront/src/components/MarketplaceHeader.tsx", + "status": "modified", + "bytes": 10438, + "sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213" + }, + { + "path": "storefront/src/styles/phase16-tokens.css", + "status": "added", + "bytes": 4214, + "sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0" + }, + { + "path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx", + "status": "modified", + "bytes": 13979, + "sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b" + }, + { + "path": "dashboard/src/app/layout.tsx", + "status": "modified", + "bytes": 2133, + "sha256": "09399551351e3c7015e6700c3892cf81f2b45cf4d1b835e01645580e73608c05" + }, + { + "path": "dashboard/src/app/globals.css", + "status": "modified", + "bytes": 16161, + "sha256": "0d01004900a4a6c078e98167c910d02b5fa29b4ebeafe0520a45cb7f9274c8b8" + }, + { + "path": "dashboard/src/styles/phase16-tokens.css", + "status": "added", + "bytes": 4214, + "sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0" + }, + { + "path": "dashboard/src/components/layout/PublicFooter.tsx", + "status": "modified", + "bytes": 8348, + "sha256": "657f96cc725459f7bf7a668fe616aee4d295625c15366204b5987176fd60c8b8" + }, + { + "path": "dashboard/src/components/layout/PublicHeader.tsx", + "status": "modified", + "bytes": 10250, + "sha256": "3e5ace09e73afbb95f09a1acf164011764cc789474430962426793564b8db459" + }, + { + "path": "dashboard/src/components/layout/Sidebar.tsx", + "status": "modified", + "bytes": 18500, + "sha256": "d0f7571149a073c39b6a1b4331e6055d8347a8092bd858d7128a37a19847b4d7" + }, + { + "path": "dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx", + "status": "modified", + "bytes": 6888, + "sha256": "9b6bbc1479529c51d5dd65ce0dffa4ed6245a1de687c2cc9b2f62d3a396a8f83" + }, + { + "path": "dashboard/src/app/onboarding/page.tsx", + "status": "modified", + "bytes": 10593, + "sha256": "a2255be38766258da3495191ce793da21992b6f4c13520d760959b7eff5ccc8e" + }, + { + "path": "dashboard/src/app/reset-password/ResetPasswordPageClient.tsx", + "status": "modified", + "bytes": 11998, + "sha256": "3a8f0c268c825097da5a8814c26c2526103536b7b3e6f3f5d04e0311cd3fbe27" + }, + { + "path": "dashboard/src/app/verify-email/page.tsx", + "status": "modified", + "bytes": 5176, + "sha256": "e1fc11c7e2af90c4b3614a1a8c6b9babe2ea5e9884eaa4e94fc0994462d554d6" + }, + { + "path": "dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx", + "status": "modified", + "bytes": 14464, + "sha256": "976e8f9244a8ca64e092373831970e3c4d7197ead33e3c478f6e8d9d375f9c3e" + }, + { + "path": "dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx", + "status": "modified", + "bytes": 19760, + "sha256": "861d033b4b86792644538f69a38634d46d6c58d43e87bbeb67dcef806cf6ab24" + }, + { + "path": "dashboard/src/app/(dashboard)/settings/page.tsx", + "status": "modified", + "bytes": 42414, + "sha256": "c46bdfd9c1c14d471b1aa03cca5e6d91f49dd8405aa27502f040bd97c0d5a64d" + }, + { + "path": "admin/src/app/layout.tsx", + "status": "modified", + "bytes": 1210, + "sha256": "aaa6c6aedb14972a159a9bcf9f8dc0bdedd2681d8a1c37be989644975c94c243" + }, + { + "path": "admin/src/app/globals.css", + "status": "modified", + "bytes": 8317, + "sha256": "4134ea739c645e1550e75d16fdf1b45c13392fd153250840c1272603adbd2832" + }, + { + "path": "admin/src/styles/phase16-tokens.css", + "status": "added", + "bytes": 4214, + "sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0" + }, + { + "path": "admin/src/app/dashboard/layout.tsx", + "status": "modified", + "bytes": 10294, + "sha256": "8390dba373ae0f8d02e8d872f5b0f1736f60553bf33e90f8c07c02207dedadda" + }, + { + "path": "admin/src/app/dashboard/page.tsx", + "status": "modified", + "bytes": 13442, + "sha256": "87995a735f7665723c791a6d528a14ebd78e529105e1bfa14654221248b62ca5" + }, + { + "path": "admin/src/app/dashboard/admin-users/page.tsx", + "status": "modified", + "bytes": 12067, + "sha256": "64dfa83abc16aef56347c1d832edb2b30f5f11497a6cb2908c5158d891031983" + }, + { + "path": "admin/src/app/dashboard/audit-logs/page.tsx", + "status": "modified", + "bytes": 4369, + "sha256": "0db9f45a4486dd4c3a8c89e82618fe49bf3f555aff9a7e8e16b028d2b53d72ef" + }, + { + "path": "admin/src/app/dashboard/billing/page.tsx", + "status": "modified", + "bytes": 48678, + "sha256": "598125e6be30d184c2277269e163bd3f54605a839a63041fa57ba27cb03cdadd" + }, + { + "path": "admin/src/app/dashboard/companies/page.tsx", + "status": "modified", + "bytes": 8622, + "sha256": "3513aa5a4ac525c0a6887cb3d77299c595b921e3e7f93001095f81f73f19fe31" + }, + { + "path": "admin/src/app/dashboard/containers/page.tsx", + "status": "modified", + "bytes": 18580, + "sha256": "d01c7cddedf9fb222554f8ea48fa44a515baae353b6675496eea7578b1c0a105" + }, + { + "path": "admin/src/app/dashboard/renters/page.tsx", + "status": "modified", + "bytes": 6988, + "sha256": "5ae947e70d674b930a5fc39f1c0577c25bb7c2ca484c761129fe091019aa0034" + }, + { + "path": "admin/src/app/dashboard/site-config/page.tsx", + "status": "modified", + "bytes": 41636, + "sha256": "800108cec0ea22c84ea8f60452ddb633e472237505a16a8bcc73ad89467b08ea" + }, + { + "path": "admin/src/app/dashboard/companies/[id]/page.tsx", + "status": "modified", + "bytes": 50874, + "sha256": "5b763c4280f9d561004dbb08faa945b7b868f48086ca3b0ac62cacb228db02d1" + } + ] +} diff --git a/PHASE16_OPERATIONAL_UI_MANIFEST.json b/PHASE16_OPERATIONAL_UI_MANIFEST.json new file mode 100644 index 0000000..926b106 --- /dev/null +++ b/PHASE16_OPERATIONAL_UI_MANIFEST.json @@ -0,0 +1,43 @@ +{ + "package": "RentalDriveGo_Phase16_Operational_UI_v1.0", + "generatedOn": "2026-06-25", + "scope": [ + "dashboard", + "admin" + ], + "includedApplications": [ + "homepage", + "storefront", + "dashboard", + "admin", + "api" + ], + "designAuthority": "Phase 16 homepage implementation", + "routeCoverage": { + "admin": 12, + "dashboard": 19 + }, + "validation": { + "typescriptSourceFilesParsed": 596, + "typescriptSyntaxFailures": 0, + "localImportsChecked": 190, + "unresolvedLocalImports": 0, + "designGate": "passed", + "productionBuild": "not run: missing monorepo root, workspace package, lockfile, and installed dependencies" + }, + "keyFiles": [ + "admin/src/styles/phase16-tokens.css", + "admin/src/app/globals.css", + "admin/src/app/dashboard/layout.tsx", + "dashboard/src/styles/phase16-tokens.css", + "dashboard/src/app/globals.css", + "dashboard/src/app/(dashboard)/layout.tsx", + "dashboard/src/components/layout/Sidebar.tsx", + "dashboard/src/components/layout/TopBar.tsx", + "scripts/validate-phase16-design.mjs" + ], + "documentation": [ + "PHASE16_OPERATIONAL_UI_REPORT.md", + "docs/PHASE16_OPERATIONAL_UI_GUIDE.md" + ] +} diff --git a/PHASE16_OPERATIONAL_UI_REPORT.md b/PHASE16_OPERATIONAL_UI_REPORT.md new file mode 100644 index 0000000..c900cf3 --- /dev/null +++ b/PHASE16_OPERATIONAL_UI_REPORT.md @@ -0,0 +1,95 @@ +# RentalDriveGo Phase 16 Operational UI Migration Report + +**Date:** 2026-06-25 +**Scope:** Operator dashboard and platform admin +**Design authority:** Existing Phase 16 homepage implementation and its blue/orange semantic design system + +## Result + +The dashboard and admin applications now use the same visual language as the Phase 16 homepage rather than behaving like separate products. The migration changes shared shells, global tokens, navigation, page hierarchy, cards, forms, tables, status treatments, responsive behavior, dark mode, RTL behavior, and focus states. API business logic and route contracts were deliberately left unchanged. + +## Repository recovery + +The previous navbar/footer refactor archive omitted the `homepage` and `api` applications. This package was rebuilt from the complete Phase 16 archive, then overlaid with the public navbar/footer refactor before the operational UI migration. The final repository contains: + +- `homepage` +- `storefront` +- `dashboard` +- `admin` +- `api` +- validation scripts and documentation + +## Shared visual system + +Both operational applications now consume matching `phase16-tokens.css` files with: + +- Homepage blue as the operational action color +- Orange reserved for conversion, focus, warning, and selected emphasis +- Shared light and dark surface mappings +- Shared 10 px, 16 px, 24 px, and 32 px radius scale +- Shared 44 px minimum interactive target +- Shared 76 px topbar and 272 px desktop sidebar dimensions +- Shared card, overlay, and focus shadows +- Inter for Latin content and Noto Sans Arabic fallbacks for Arabic + +## Dashboard changes + +- Rebuilt the authenticated shell with the Phase 16 page background, sidebar, sticky topbar, content width, and responsive spacing. +- Reworked sidebar branding, active navigation, user card, status marker, RTL logical borders, and mobile behavior. +- Reworked the topbar search, page title, status pill, notifications, account controls, and semantic icon buttons. +- Migrated all 19 dashboard routes to the shared page-heading or hero pattern. +- Applied shared styling to overview metrics, quick actions, reservations, contracts, online reservations, fleet, customers, team, reviews, complaints, offers, billing, subscription, reports, notifications, and settings. +- Preserved printable contract styling as a document surface while aligning its surrounding application controls. + +## Admin changes + +- Rebuilt the admin shell with the same Phase 16 sidebar, topbar, page background, status treatment, mobile drawer, language controls, theme controls, and user card. +- Migrated all 12 admin routes to the shared page-heading or hero pattern. +- Standardized companies, company details, renters, administrators, billing, pricing, containers, audit logs, notifications, menu management, site configuration, and overview pages. +- Converted operational create/save/preview actions from orange to homepage blue. Orange remains available for promotions, conversion previews, warnings, and focus. +- Standardized dynamic form controls through the shared `.field` utility. + +## Shared component and CSS contract + +The main reusable classes are: + +- `.rdg-app-shell` +- `.rdg-workspace` +- `.rdg-sidebar` +- `.rdg-nav-item` +- `.rdg-topbar` +- `.rdg-app-main` +- `.rdg-app-content` +- `.rdg-page-hero` +- `.rdg-page-heading` +- `.rdg-page-kicker` +- `.panel` +- `.card` +- `.field` +- `.btn-primary` +- `.btn-secondary` +- `.btn-conversion` + +## Behavior preserved + +- Existing routes and URL structure +- Authentication and access guards +- API calls and data mutations +- English, French, and Arabic support +- True RTL layout behavior through logical properties +- Light, dark, and system theme behavior +- Embedded authentication behavior through `embedded=1` +- Print behavior for contracts + +## Validation completed + +- 596 TypeScript/TSX source files parsed with zero syntax failures. +- 190 local and alias imports checked in admin/dashboard with zero unresolved paths. +- 31 operational route pages checked for a Phase 16 page heading or hero. +- CSS brace balance checked for the migrated stylesheets. +- Shared shell, token, responsive, forced-color, auth-layout, and retired-brand assertions passed. +- Final archive integrity is checked after packaging. + +## Build limitation + +A production Next.js build was not run because the supplied archive does not include the monorepo root, root lockfile, root workspace configuration, installed dependencies, or the referenced `@rentaldrivego/types` workspace. Each application invokes that absent workspace in its `prebuild` script. Claiming a clean production build under those conditions would be fiction with a progress bar. diff --git a/PHASE16_OPERATIONAL_VALIDATION_REPORT.md b/PHASE16_OPERATIONAL_VALIDATION_REPORT.md new file mode 100644 index 0000000..796e2ea --- /dev/null +++ b/PHASE16_OPERATIONAL_VALIDATION_REPORT.md @@ -0,0 +1,40 @@ +# Phase 16 Operational UI Validation Report + +**Validated:** 2026-06-25 +**Result:** Static migration gates passed + +## Passed checks + +| Check | Result | +|---|---:| +| Frontend/API source TypeScript and TSX parsed | 596 files, 0 syntax failures | +| Admin/dashboard local and `@/` imports resolved | 190 imports, 0 unresolved | +| Dashboard operational routes using Phase 16 heading/hero | 19 of 19 | +| Admin operational routes using Phase 16 heading/hero | 12 of 12 | +| Dashboard/admin token files present | Passed | +| Shared sidebar, topbar, shell, panel, field, and action contracts present | Passed | +| CSS brace balance | Passed | +| Light/dark and Arabic RTL token mappings present | Passed | +| Reduced-motion and forced-colors rules present | Passed | +| Shared sign-in/create-account public layout retained | Passed | +| Shared storefront navbar/footer components retained | Passed | +| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches | +| Complete app directories retained | `homepage`, `storefront`, `dashboard`, `admin`, `api` | + +## Reproducible design gate + +Run this from the extracted repository root: + +```bash +node scripts/validate-phase16-design.mjs +``` + +Expected output: + +```text +Phase 16 dashboard/admin design checks passed. +``` + +## Not claimed + +A production build, type-check against installed dependencies, integration test run, and browser visual regression run are not claimed. The supplied archive lacks the root workspace, root lockfile, `@rentaldrivego/types` package, and installed dependencies required by every application `prebuild` script. diff --git a/admin/next-env.d.ts b/admin/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/admin/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +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 new file mode 100644 index 0000000..eef4f4a --- /dev/null +++ b/admin/next.config.js @@ -0,0 +1,55 @@ +/** @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 new file mode 100644 index 0000000..3707ed9 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,37 @@ +{ + "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/apps/homepage/postcss.config.js b/admin/postcss.config.js similarity index 100% rename from apps/homepage/postcss.config.js rename to admin/postcss.config.js diff --git a/admin/src/app/auth-redirect/page.tsx b/admin/src/app/auth-redirect/page.tsx new file mode 100644 index 0000000..b929b2c --- /dev/null +++ b/admin/src/app/auth-redirect/page.tsx @@ -0,0 +1,34 @@ +'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 new file mode 100644 index 0000000..6de0e93 --- /dev/null +++ b/admin/src/app/dashboard/AdminSessionContext.test.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..31c199b --- /dev/null +++ b/admin/src/app/dashboard/AdminSessionContext.tsx @@ -0,0 +1,34 @@ +'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 new file mode 100644 index 0000000..0044129 --- /dev/null +++ b/admin/src/app/dashboard/admin-users/page.tsx @@ -0,0 +1,277 @@ +'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 new file mode 100644 index 0000000..ba8ca6d --- /dev/null +++ b/admin/src/app/dashboard/audit-logs/page.tsx @@ -0,0 +1,98 @@ +'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 new file mode 100644 index 0000000..511d61f --- /dev/null +++ b/admin/src/app/dashboard/billing/page.tsx @@ -0,0 +1,1149 @@ +'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 new file mode 100644 index 0000000..068b96f --- /dev/null +++ b/admin/src/app/dashboard/companies/[id]/page.tsx @@ -0,0 +1,1031 @@ +'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

+
+
+ + + + + + + + +