Răsfoiți Sursa

admin panel improvements

Bernt Christian Egeland 4 luni în urmă
părinte
comite
ab8ea61a70

+ 24 - 0
messages/de/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Zurück zu Benutzern",
+    "never": "Nie",
+    "lastLogin": "Letzte Anmeldung",
+    "organizations": "Organisationen",
+    "noOrganizations": "Kein Mitglied einer Organisation",
+    "role": "Rolle"
+  },
+  "orgDetails": {
+    "back": "Zurück",
+    "vehicles": "Fahrzeuge",
+    "workOrders": "Arbeitsaufträge",
+    "customers": "Kunden",
+    "quotes": "Angebote",
+    "inspections": "Inspektionen",
+    "inventoryParts": "Lagerteile",
+    "members": "Mitglieder",
+    "owner": "Inhaber",
+    "noOwner": "Kein Inhaber",
+    "subscription": "Abonnement",
+    "noSubscription": "Kein aktives Abonnement",
+    "renews": "Verlängert sich",
+    "cancelsAt": "Endet am"
   }
 }

+ 24 - 0
messages/en/admin.json

@@ -59,6 +59,30 @@
     "deleteUser": "Delete User",
     "emailVerified": "Email verified"
   },
+  "userDetails": {
+    "back": "Back to users",
+    "never": "Never",
+    "lastLogin": "Last login",
+    "organizations": "Organizations",
+    "noOrganizations": "Not a member of any organization",
+    "role": "Role"
+  },
+  "orgDetails": {
+    "back": "Back",
+    "vehicles": "Vehicles",
+    "workOrders": "Work Orders",
+    "customers": "Customers",
+    "quotes": "Quotes",
+    "inspections": "Inspections",
+    "inventoryParts": "Inventory Parts",
+    "members": "Members",
+    "owner": "Owner",
+    "noOwner": "No owner",
+    "subscription": "Subscription",
+    "noSubscription": "No active subscription",
+    "renews": "Renews",
+    "cancelsAt": "Cancels at"
+  },
   "organizations": {
     "searchPlaceholder": "Search by organization name...",
     "name": "Name",

+ 24 - 0
messages/es/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Volver a usuarios",
+    "never": "Nunca",
+    "lastLogin": "Último inicio de sesión",
+    "organizations": "Organizaciones",
+    "noOrganizations": "No es miembro de ninguna organización",
+    "role": "Rol"
+  },
+  "orgDetails": {
+    "back": "Volver",
+    "vehicles": "Vehículos",
+    "workOrders": "Órdenes de trabajo",
+    "customers": "Clientes",
+    "quotes": "Presupuestos",
+    "inspections": "Inspecciones",
+    "inventoryParts": "Piezas de inventario",
+    "members": "Miembros",
+    "owner": "Propietario",
+    "noOwner": "Sin propietario",
+    "subscription": "Suscripción",
+    "noSubscription": "Sin suscripción activa",
+    "renews": "Se renueva",
+    "cancelsAt": "Termina el"
   }
 }

+ 24 - 0
messages/fr/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Retour aux utilisateurs",
+    "never": "Jamais",
+    "lastLogin": "Dernière connexion",
+    "organizations": "Organisations",
+    "noOrganizations": "Membre d'aucune organisation",
+    "role": "Rôle"
+  },
+  "orgDetails": {
+    "back": "Retour",
+    "vehicles": "Véhicules",
+    "workOrders": "Ordres de travail",
+    "customers": "Clients",
+    "quotes": "Devis",
+    "inspections": "Inspections",
+    "inventoryParts": "Pièces en stock",
+    "members": "Membres",
+    "owner": "Propriétaire",
+    "noOwner": "Aucun propriétaire",
+    "subscription": "Abonnement",
+    "noSubscription": "Aucun abonnement actif",
+    "renews": "Renouvellement",
+    "cancelsAt": "Se termine le"
   }
 }

+ 24 - 0
messages/it/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Torna agli utenti",
+    "never": "Mai",
+    "lastLogin": "Ultimo accesso",
+    "organizations": "Organizzazioni",
+    "noOrganizations": "Non è membro di alcuna organizzazione",
+    "role": "Ruolo"
+  },
+  "orgDetails": {
+    "back": "Indietro",
+    "vehicles": "Veicoli",
+    "workOrders": "Ordini di lavoro",
+    "customers": "Clienti",
+    "quotes": "Preventivi",
+    "inspections": "Ispezioni",
+    "inventoryParts": "Parti in magazzino",
+    "members": "Membri",
+    "owner": "Proprietario",
+    "noOwner": "Nessun proprietario",
+    "subscription": "Abbonamento",
+    "noSubscription": "Nessun abbonamento attivo",
+    "renews": "Si rinnova",
+    "cancelsAt": "Termina il"
   }
 }

+ 24 - 0
messages/lt/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Atgal į vartotojus",
+    "never": "Niekada",
+    "lastLogin": "Paskutinis prisijungimas",
+    "organizations": "Organizacijos",
+    "noOrganizations": "Nepriklauso jokiai organizacijai",
+    "role": "Rolė"
+  },
+  "orgDetails": {
+    "back": "Atgal",
+    "vehicles": "Transporto priemonės",
+    "workOrders": "Darbo užsakymai",
+    "customers": "Klientai",
+    "quotes": "Pasiūlymai",
+    "inspections": "Patikrinimai",
+    "inventoryParts": "Atsargų dalys",
+    "members": "Nariai",
+    "owner": "Savininkas",
+    "noOwner": "Nėra savininko",
+    "subscription": "Prenumerata",
+    "noSubscription": "Nėra aktyvios prenumeratos",
+    "renews": "Atsinaujina",
+    "cancelsAt": "Baigiasi"
   }
 }

+ 24 - 0
messages/nb/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Tilbake til brukere",
+    "never": "Aldri",
+    "lastLogin": "Sist innlogget",
+    "organizations": "Organisasjoner",
+    "noOrganizations": "Ikke medlem av noen organisasjon",
+    "role": "Rolle"
+  },
+  "orgDetails": {
+    "back": "Tilbake",
+    "vehicles": "Kjøretøy",
+    "workOrders": "Arbeidsordrer",
+    "customers": "Kunder",
+    "quotes": "Tilbud",
+    "inspections": "Inspeksjoner",
+    "inventoryParts": "Lagerdeler",
+    "members": "Medlemmer",
+    "owner": "Eier",
+    "noOwner": "Ingen eier",
+    "subscription": "Abonnement",
+    "noSubscription": "Ingen aktivt abonnement",
+    "renews": "Fornyes",
+    "cancelsAt": "Avsluttes"
   }
 }

+ 24 - 0
messages/nl/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Terug naar gebruikers",
+    "never": "Nooit",
+    "lastLogin": "Laatste aanmelding",
+    "organizations": "Organisaties",
+    "noOrganizations": "Geen lid van een organisatie",
+    "role": "Rol"
+  },
+  "orgDetails": {
+    "back": "Terug",
+    "vehicles": "Voertuigen",
+    "workOrders": "Werkorders",
+    "customers": "Klanten",
+    "quotes": "Offertes",
+    "inspections": "Inspecties",
+    "inventoryParts": "Voorraadonderdelen",
+    "members": "Leden",
+    "owner": "Eigenaar",
+    "noOwner": "Geen eigenaar",
+    "subscription": "Abonnement",
+    "noSubscription": "Geen actief abonnement",
+    "renews": "Verlengt",
+    "cancelsAt": "Eindigt op"
   }
 }

+ 24 - 0
messages/pl/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Powrót do użytkowników",
+    "never": "Nigdy",
+    "lastLogin": "Ostatnie logowanie",
+    "organizations": "Organizacje",
+    "noOrganizations": "Nie jest członkiem żadnej organizacji",
+    "role": "Rola"
+  },
+  "orgDetails": {
+    "back": "Powrót",
+    "vehicles": "Pojazdy",
+    "workOrders": "Zlecenia pracy",
+    "customers": "Klienci",
+    "quotes": "Wyceny",
+    "inspections": "Inspekcje",
+    "inventoryParts": "Części magazynowe",
+    "members": "Członkowie",
+    "owner": "Właściciel",
+    "noOwner": "Brak właściciela",
+    "subscription": "Subskrypcja",
+    "noSubscription": "Brak aktywnej subskrypcji",
+    "renews": "Odnawia się",
+    "cancelsAt": "Kończy się"
   }
 }

+ 24 - 0
messages/pt-BR/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Voltar para usuários",
+    "never": "Nunca",
+    "lastLogin": "Último login",
+    "organizations": "Organizações",
+    "noOrganizations": "Não é membro de nenhuma organização",
+    "role": "Função"
+  },
+  "orgDetails": {
+    "back": "Voltar",
+    "vehicles": "Veículos",
+    "workOrders": "Ordens de serviço",
+    "customers": "Clientes",
+    "quotes": "Orçamentos",
+    "inspections": "Inspeções",
+    "inventoryParts": "Peças de estoque",
+    "members": "Membros",
+    "owner": "Proprietário",
+    "noOwner": "Sem proprietário",
+    "subscription": "Assinatura",
+    "noSubscription": "Sem assinatura ativa",
+    "renews": "Renova",
+    "cancelsAt": "Termina em"
   }
 }

+ 24 - 0
messages/ru/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Назад к пользователям",
+    "never": "Никогда",
+    "lastLogin": "Последний вход",
+    "organizations": "Организации",
+    "noOrganizations": "Не состоит ни в одной организации",
+    "role": "Роль"
+  },
+  "orgDetails": {
+    "back": "Назад",
+    "vehicles": "Транспортные средства",
+    "workOrders": "Рабочие заказы",
+    "customers": "Клиенты",
+    "quotes": "Предложения",
+    "inspections": "Осмотры",
+    "inventoryParts": "Складские детали",
+    "members": "Участники",
+    "owner": "Владелец",
+    "noOwner": "Нет владельца",
+    "subscription": "Подписка",
+    "noSubscription": "Нет активной подписки",
+    "renews": "Продлевается",
+    "cancelsAt": "Заканчивается"
   }
 }

+ 24 - 0
messages/tr/admin.json

@@ -119,5 +119,29 @@
   },
   "common": {
     "openMenu": "Open menu"
+  },
+  "userDetails": {
+    "back": "Kullanıcılara dön",
+    "never": "Hiçbir zaman",
+    "lastLogin": "Son giriş",
+    "organizations": "Organizasyonlar",
+    "noOrganizations": "Hiçbir organizasyonun üyesi değil",
+    "role": "Rol"
+  },
+  "orgDetails": {
+    "back": "Geri",
+    "vehicles": "Araçlar",
+    "workOrders": "İş emirleri",
+    "customers": "Müşteriler",
+    "quotes": "Teklifler",
+    "inspections": "Muayeneler",
+    "inventoryParts": "Stok parçaları",
+    "members": "Üyeler",
+    "owner": "Sahibi",
+    "noOwner": "Sahibi yok",
+    "subscription": "Abonelik",
+    "noSubscription": "Etkin abonelik yok",
+    "renews": "Yenileniyor",
+    "cancelsAt": "Sona eriyor"
   }
 }

+ 18 - 0
src/app/(authenticated)/admin/organizations/[id]/page.tsx

@@ -0,0 +1,18 @@
+import { notFound } from 'next/navigation'
+import { getOrganizationDetails } from '@/features/admin/Actions/getOrganizationDetails'
+import { AdminOrganizationDetails } from '@/features/admin/Components/admin-organization-details'
+
+export default async function AdminOrganizationDetailsPage({
+  params,
+}: {
+  params: Promise<{ id: string }>
+}) {
+  const { id } = await params
+  const result = await getOrganizationDetails(id)
+
+  if (!result.success || !result.data) {
+    notFound()
+  }
+
+  return <AdminOrganizationDetails org={result.data} />
+}

+ 18 - 0
src/app/(authenticated)/admin/users/[id]/page.tsx

@@ -0,0 +1,18 @@
+import { notFound } from 'next/navigation'
+import { getUserDetails } from '@/features/admin/Actions/getUserDetails'
+import { AdminUserDetails } from '@/features/admin/Components/admin-user-details'
+
+export default async function AdminUserDetailsPage({
+  params,
+}: {
+  params: Promise<{ id: string }>
+}) {
+  const { id } = await params
+  const result = await getUserDetails(id)
+
+  if (!result.success || !result.data) {
+    notFound()
+  }
+
+  return <AdminUserDetails user={result.data} />
+}

+ 90 - 0
src/features/admin/Actions/getOrganizationDetails.ts

@@ -0,0 +1,90 @@
+'use server'
+
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+
+export async function getOrganizationDetails(organizationId: string) {
+  return withSuperAdmin(async () => {
+    const org = await db.organization.findUnique({
+      where: { id: organizationId },
+      select: {
+        id: true,
+        name: true,
+        createdAt: true,
+        subscription: {
+          select: {
+            status: true,
+            currentPeriodEnd: true,
+            cancelAtPeriodEnd: true,
+            plan: { select: { name: true } },
+          },
+        },
+        _count: {
+          select: {
+            members: true,
+            vehicles: true,
+            customers: true,
+            quotes: true,
+            inventoryParts: true,
+          },
+        },
+      },
+    })
+    if (!org) return null
+
+    // Service records and inspections are linked via Vehicle.organizationId
+    const [serviceRecordCount, inspectionCount, members] = await Promise.all([
+      db.serviceRecord.count({ where: { vehicle: { organizationId } } }),
+      db.inspection.count({ where: { vehicle: { organizationId } } }),
+      db.organizationMember.findMany({
+        where: { organizationId },
+        select: {
+          role: true,
+          user: {
+            select: { id: true, name: true, email: true, lastSeen: true },
+          },
+        },
+      }),
+    ])
+
+    const owner = members.find((m) => m.role === 'owner') ?? members[0] ?? null
+
+    return {
+      id: org.id,
+      name: org.name,
+      createdAt: org.createdAt.toISOString(),
+      subscription: org.subscription
+        ? {
+            status: org.subscription.status,
+            planName: org.subscription.plan.name,
+            currentPeriodEnd: org.subscription.currentPeriodEnd?.toISOString() ?? null,
+            cancelAtPeriodEnd: org.subscription.cancelAtPeriodEnd,
+          }
+        : null,
+      stats: {
+        members: org._count.members,
+        vehicles: org._count.vehicles,
+        customers: org._count.customers,
+        workOrders: serviceRecordCount,
+        quotes: org._count.quotes,
+        inspections: inspectionCount,
+        inventoryParts: org._count.inventoryParts,
+      },
+      owner: owner?.user
+        ? {
+            id: owner.user.id,
+            name: owner.user.name,
+            email: owner.user.email,
+            role: owner.role,
+          }
+        : null,
+      members: members.map((m) => ({
+        id: m.user.id,
+        name: m.user.name,
+        email: m.user.email,
+        role: m.role,
+        lastSeen: m.user.lastSeen?.toISOString() ?? null,
+      })),
+    }
+  })
+}

+ 63 - 0
src/features/admin/Actions/getUserDetails.ts

@@ -0,0 +1,63 @@
+'use server'
+
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+
+export async function getUserDetails(userId: string) {
+  return withSuperAdmin(async () => {
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: {
+        id: true,
+        name: true,
+        email: true,
+        emailVerified: true,
+        isSuperAdmin: true,
+        createdAt: true,
+        lastSeen: true,
+        lastLogin: true,
+        image: true,
+      },
+    })
+    if (!user) return null
+
+    const memberships = await db.organizationMember.findMany({
+      where: { userId },
+      select: {
+        role: true,
+        organization: {
+          select: {
+            id: true,
+            name: true,
+            createdAt: true,
+            _count: {
+              select: { members: true, vehicles: true },
+            },
+          },
+        },
+      },
+    })
+
+    return {
+      id: user.id,
+      name: user.name,
+      email: user.email,
+      emailVerified: user.emailVerified,
+      isSuperAdmin: user.isSuperAdmin,
+      image: user.image,
+      createdAt: user.createdAt.toISOString(),
+      lastSeen: user.lastSeen?.toISOString() ?? null,
+      lastLogin: user.lastLogin?.toISOString() ?? null,
+      organizations: memberships
+        .map((m) => ({
+          id: m.organization.id,
+          name: m.organization.name,
+          createdAt: m.organization.createdAt.toISOString(),
+          role: m.role,
+          memberCount: m.organization._count.members,
+          vehicleCount: m.organization._count.vehicles,
+        }))
+        .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
+    }
+  })
+}

+ 205 - 0
src/features/admin/Components/admin-organization-details.tsx

@@ -0,0 +1,205 @@
+'use client'
+
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+  ArrowLeft,
+  Boxes,
+  Building2,
+  Car,
+  ClipboardCheck,
+  CreditCard,
+  FileText,
+  Users,
+  Wrench,
+} from 'lucide-react'
+import { useFormatDate } from '@/lib/use-format-date'
+
+type OrgDetails = {
+  id: string
+  name: string
+  createdAt: string
+  subscription: {
+    status: string
+    planName: string
+    currentPeriodEnd: string | null
+    cancelAtPeriodEnd: boolean
+  } | null
+  stats: {
+    members: number
+    vehicles: number
+    customers: number
+    workOrders: number
+    quotes: number
+    inspections: number
+    inventoryParts: number
+  }
+  owner: {
+    id: string
+    name: string
+    email: string
+    role: string
+  } | null
+  members: Array<{
+    id: string
+    name: string
+    email: string
+    role: string
+    lastSeen: string | null
+  }>
+}
+
+function subscriptionVariant(status: string) {
+  switch (status) {
+    case 'active':
+      return 'default' as const
+    case 'trialing':
+      return 'secondary' as const
+    case 'past_due':
+      return 'destructive' as const
+    case 'canceled':
+      return 'outline' as const
+    default:
+      return 'secondary' as const
+  }
+}
+
+export function AdminOrganizationDetails({ org }: { org: OrgDetails }) {
+  const t = useTranslations('admin')
+  const { formatDate, formatDateTime } = useFormatDate()
+
+  const statCards = [
+    { key: 'vehicles', label: t('orgDetails.vehicles'), value: org.stats.vehicles, icon: Car },
+    {
+      key: 'workOrders',
+      label: t('orgDetails.workOrders'),
+      value: org.stats.workOrders,
+      icon: Wrench,
+    },
+    { key: 'customers', label: t('orgDetails.customers'), value: org.stats.customers, icon: Users },
+    { key: 'quotes', label: t('orgDetails.quotes'), value: org.stats.quotes, icon: FileText },
+    {
+      key: 'inspections',
+      label: t('orgDetails.inspections'),
+      value: org.stats.inspections,
+      icon: ClipboardCheck,
+    },
+    {
+      key: 'inventoryParts',
+      label: t('orgDetails.inventoryParts'),
+      value: org.stats.inventoryParts,
+      icon: Boxes,
+    },
+  ]
+
+  return (
+    <div className="space-y-6">
+      <Link
+        href="/admin/users"
+        className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+      >
+        <ArrowLeft className="h-4 w-4" />
+        {t('orgDetails.back')}
+      </Link>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <Building2 className="h-5 w-5 text-muted-foreground" />
+            {org.name}
+          </CardTitle>
+        </CardHeader>
+        <CardContent className="grid gap-3 text-sm sm:grid-cols-2">
+          <div>
+            <p className="text-muted-foreground">{t('users.created')}</p>
+            <p className="font-medium">{formatDate(org.createdAt)}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('orgDetails.owner')}</p>
+            {org.owner ? (
+              <Link href={`/admin/users/${org.owner.id}`} className="font-medium hover:underline">
+                {org.owner.name} <span className="text-muted-foreground">({org.owner.email})</span>
+              </Link>
+            ) : (
+              <p className="text-muted-foreground">{t('orgDetails.noOwner')}</p>
+            )}
+          </div>
+          <div className="sm:col-span-2">
+            <p className="mb-1 text-muted-foreground">{t('orgDetails.subscription')}</p>
+            {org.subscription ? (
+              <div className="flex flex-wrap items-center gap-2">
+                <Badge variant={subscriptionVariant(org.subscription.status)}>
+                  {org.subscription.status}
+                </Badge>
+                <span className="text-sm font-medium">{org.subscription.planName}</span>
+                {org.subscription.currentPeriodEnd && (
+                  <span className="text-xs text-muted-foreground">
+                    {org.subscription.cancelAtPeriodEnd
+                      ? t('orgDetails.cancelsAt')
+                      : t('orgDetails.renews')}
+                    : {formatDate(org.subscription.currentPeriodEnd)}
+                  </span>
+                )}
+              </div>
+            ) : (
+              <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
+                <CreditCard className="h-3.5 w-3.5" />
+                {t('orgDetails.noSubscription')}
+              </p>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+
+      <div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
+        {statCards.map((s) => (
+          <Card key={s.key}>
+            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+              <CardTitle className="text-xs font-medium text-muted-foreground">{s.label}</CardTitle>
+              <s.icon className="h-4 w-4 text-muted-foreground" />
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">{s.value}</div>
+            </CardContent>
+          </Card>
+        ))}
+      </div>
+
+      <div>
+        <h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
+          <Users className="h-4 w-4 text-muted-foreground" />
+          {t('orgDetails.members')}{' '}
+          <span className="text-sm font-normal text-muted-foreground">({org.stats.members})</span>
+        </h2>
+        <Card>
+          <CardContent className="divide-y p-0">
+            {org.members.map((m) => (
+              <Link
+                key={m.id}
+                href={`/admin/users/${m.id}`}
+                className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-muted/50"
+              >
+                <div>
+                  <p className="font-medium">{m.name}</p>
+                  <p className="text-xs text-muted-foreground">{m.email}</p>
+                </div>
+                <div className="flex items-center gap-2">
+                  {m.lastSeen && (
+                    <span className="hidden text-xs text-muted-foreground sm:inline">
+                      {formatDateTime(m.lastSeen)}
+                    </span>
+                  )}
+                  <Badge variant="outline" className="text-xs">
+                    {m.role}
+                  </Badge>
+                </div>
+              </Link>
+            ))}
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  )
+}

+ 163 - 0
src/features/admin/Components/admin-user-details.tsx

@@ -0,0 +1,163 @@
+'use client'
+
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+  ArrowLeft,
+  BadgeCheck,
+  Building2,
+  Car,
+  ChevronRight,
+  ShieldCheck,
+  Users,
+} from 'lucide-react'
+import { useFormatDate } from '@/lib/use-format-date'
+
+type Organization = {
+  id: string
+  name: string
+  createdAt: string
+  role: string
+  memberCount: number
+  vehicleCount: number
+}
+
+type UserDetails = {
+  id: string
+  name: string
+  email: string
+  emailVerified: boolean
+  isSuperAdmin: boolean
+  image: string | null
+  createdAt: string
+  lastSeen: string | null
+  lastLogin: string | null
+  organizations: Organization[]
+}
+
+function isOnline(lastSeen: string | null) {
+  if (!lastSeen) return false
+  return Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
+}
+
+export function AdminUserDetails({ user }: { user: UserDetails }) {
+  const t = useTranslations('admin')
+  const { formatDate, formatDateTime } = useFormatDate()
+  const online = isOnline(user.lastSeen)
+
+  return (
+    <div className="space-y-6">
+      <Link
+        href="/admin/users"
+        className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+      >
+        <ArrowLeft className="h-4 w-4" />
+        {t('userDetails.back')}
+      </Link>
+
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <CardTitle className="flex items-center gap-2">
+              {user.name}
+              {user.emailVerified && (
+                <span title={t('users.emailVerified')}>
+                  <BadgeCheck className="h-4 w-4 text-blue-500" />
+                </span>
+              )}
+              {online && (
+                <span className="relative flex h-2 w-2" title={t('users.online')}>
+                  <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
+                  <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
+                </span>
+              )}
+            </CardTitle>
+            {user.isSuperAdmin ? (
+              <Badge variant="default" className="gap-1">
+                <ShieldCheck className="h-3 w-3" />
+                {t('users.superAdmin')}
+              </Badge>
+            ) : (
+              <Badge variant="secondary">{t('users.user')}</Badge>
+            )}
+          </div>
+        </CardHeader>
+        <CardContent className="grid gap-3 text-sm sm:grid-cols-2">
+          <div>
+            <p className="text-muted-foreground">{t('users.email')}</p>
+            <p className="font-medium">{user.email}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('users.created')}</p>
+            <p className="font-medium">{formatDate(user.createdAt)}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('users.lastSeen')}</p>
+            <p className="font-medium">
+              {user.lastSeen ? formatDateTime(user.lastSeen) : t('userDetails.never')}
+            </p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">{t('userDetails.lastLogin')}</p>
+            <p className="font-medium">
+              {user.lastLogin ? formatDateTime(user.lastLogin) : t('userDetails.never')}
+            </p>
+          </div>
+        </CardContent>
+      </Card>
+
+      <div>
+        <h2 className="mb-3 text-lg font-semibold">{t('userDetails.organizations')}</h2>
+        {user.organizations.length === 0 ? (
+          <Card>
+            <CardContent className="py-8 text-center text-sm text-muted-foreground">
+              {t('userDetails.noOrganizations')}
+            </CardContent>
+          </Card>
+        ) : (
+          <div className="grid gap-3 sm:grid-cols-2">
+            {user.organizations.map((org) => (
+              <Link key={org.id} href={`/admin/organizations/${org.id}`}>
+                <Card className="h-full cursor-pointer transition-colors hover:bg-muted/50">
+                  <CardHeader className="pb-3">
+                    <div className="flex items-start justify-between gap-2">
+                      <CardTitle className="flex items-center gap-2 text-base">
+                        <Building2 className="h-4 w-4 text-muted-foreground" />
+                        {org.name}
+                      </CardTitle>
+                      <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
+                    </div>
+                  </CardHeader>
+                  <CardContent className="space-y-2 text-sm">
+                    <div className="flex items-center justify-between">
+                      <span className="text-muted-foreground">{t('userDetails.role')}</span>
+                      <Badge variant="outline" className="text-xs">
+                        {org.role}
+                      </Badge>
+                    </div>
+                    <div className="flex items-center justify-between">
+                      <span className="flex items-center gap-1.5 text-muted-foreground">
+                        <Users className="h-3.5 w-3.5" />
+                        {t('orgDetails.members')}
+                      </span>
+                      <span className="font-medium">{org.memberCount}</span>
+                    </div>
+                    <div className="flex items-center justify-between">
+                      <span className="flex items-center gap-1.5 text-muted-foreground">
+                        <Car className="h-3.5 w-3.5" />
+                        {t('orgDetails.vehicles')}
+                      </span>
+                      <span className="font-medium">{org.vehicleCount}</span>
+                    </div>
+                  </CardContent>
+                </Card>
+              </Link>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 6 - 2
src/features/admin/Components/admin-users.tsx

@@ -267,7 +267,11 @@ export function AdminUsers({
               </TableRow>
             ) : (
               data.users.map((user) => (
-                <TableRow key={user.id}>
+                <TableRow
+                  key={user.id}
+                  className="cursor-pointer"
+                  onClick={() => router.push(`/admin/users/${user.id}`)}
+                >
                   <TableCell className="font-medium">{user.name}</TableCell>
                   <TableCell>
                     <span className="flex items-center gap-1.5">
@@ -306,7 +310,7 @@ export function AdminUsers({
                       </span>
                     )}
                   </TableCell>
-                  <TableCell>
+                  <TableCell onClick={(e) => e.stopPropagation()}>
                     <DropdownMenu>
                       <DropdownMenuTrigger asChild>
                         <Button