diff --git a/apps/admin/src/app/(shell)/utilisateurs/page.tsx b/apps/admin/src/app/(shell)/utilisateurs/page.tsx new file mode 100644 index 00000000..5243f2c5 --- /dev/null +++ b/apps/admin/src/app/(shell)/utilisateurs/page.tsx @@ -0,0 +1,11 @@ +import { UsersContainer } from '@/containers/UsersContainer'; +import { Suspense } from 'react'; + +/** A-06. The state of the list and the open account are read from the address, in the browser. */ +export default function UsersPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/users/UserActions.tsx b/apps/admin/src/components/users/UserActions.tsx new file mode 100644 index 00000000..2aea4679 --- /dev/null +++ b/apps/admin/src/components/users/UserActions.tsx @@ -0,0 +1,60 @@ +import { messages } from '@/messages/fr'; +import type { UserAction } from '@widoo/shared'; +import { Button } from '@widoo/ui'; + +const texts = messages.users.actions; + +export type UserActionName = Exclude | 'suspend' | 'reactivate'; + +type UserActionsProps = { + /** What the role of the member allows on this account (`allowedUserActions`). */ + allowed: readonly UserAction[]; + isSuspended: boolean; + onAct: (action: UserActionName) => void; + isBusy: boolean; +}; + +/** The actions of the card of an account (A-06): only the ones the role of the member allows. */ +export function UserActions({ allowed, isSuspended, onAct, isBusy }: UserActionsProps) { + const has = (action: UserAction) => allowed.includes(action); + return ( + <> + {has('suspend') ? ( + isSuspended ? ( + + ) : ( + + ) + ) : null} + {has('role') ? ( + + ) : null} + {has('gift') ? ( + + ) : null} + {has('certification') ? ( + + ) : null} + {has('export') ? ( + + ) : null} + {has('delete') ? ( + + ) : null} + + ); +} diff --git a/apps/admin/src/components/users/UserChoiceDialog.tsx b/apps/admin/src/components/users/UserChoiceDialog.tsx new file mode 100644 index 00000000..f36a887e --- /dev/null +++ b/apps/admin/src/components/users/UserChoiceDialog.tsx @@ -0,0 +1,55 @@ +import { messages } from '@/messages/fr'; +import { ConfirmDialog, RadioGroup, type RadioOption } from '@widoo/ui'; + +type UserChoiceDialogProps = { + isOpen: boolean; + /** What the window says: its title, what the choice changes, and its button. */ + dialog: { title: string; description: string; confirm: string }; + /** Name of the radio group, and its question. */ + name: string; + legend: string; + options: readonly RadioOption[]; + value: string; + onChange: (value: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** One choice among a few for an account (its role, the badge), confirmed in a window. */ +export function UserChoiceDialog({ + isOpen, + dialog, + name, + legend, + options, + value, + onChange, + onCancel, + onConfirm, + isBusy, +}: UserChoiceDialogProps) { + return ( + + + + ); +} diff --git a/apps/admin/src/components/users/UserGiftDialog.tsx b/apps/admin/src/components/users/UserGiftDialog.tsx new file mode 100644 index 00000000..7cde1604 --- /dev/null +++ b/apps/admin/src/components/users/UserGiftDialog.tsx @@ -0,0 +1,87 @@ +import { messages } from '@/messages/fr'; +import { ConfirmDialog, Field, Input, RadioGroup } from '@widoo/ui'; + +const texts = messages.users.dialogs.gift; + +export type GiftKind = 'until' | 'route'; + +type UserGiftDialogProps = { + isOpen: boolean; + userName: string; + kind: GiftKind; + onKindChange: (kind: GiftKind) => void; + /** Last day of the Premium plan, `AAAA-MM-JJ`. */ + until: string; + onUntilChange: (until: string) => void; + /** First day that may be chosen: tomorrow. */ + minUntil: string; + routeId: string; + onRouteIdChange: (routeId: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** « Offrir Premium »: until a date, or one Premium route, never both. */ +export function UserGiftDialog({ + isOpen, + userName, + kind, + onKindChange, + until, + onUntilChange, + minUntil, + routeId, + onRouteIdChange, + onCancel, + onConfirm, + isBusy, +}: UserGiftDialogProps) { + return ( + + onKindChange(value === 'route' ? 'route' : 'until')} + /> + {kind === 'until' ? ( + + onUntilChange(event.target.value)} + /> + + ) : ( + + onRouteIdChange(event.target.value.trim())} + /> + + )} + + ); +} diff --git a/apps/admin/src/components/users/UserPanelContent.tsx b/apps/admin/src/components/users/UserPanelContent.tsx new file mode 100644 index 00000000..7fd51ba7 --- /dev/null +++ b/apps/admin/src/components/users/UserPanelContent.tsx @@ -0,0 +1,126 @@ +import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge'; +import { formatRelativeDate } from '@/lib/dates'; +import { planLabelOf } from '@/lib/user-display'; +import { messages } from '@/messages/fr'; +import type { AdminUserDetail } from '@widoo/shared'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { UserRoleBadge } from './UserRoleBadge'; + +const texts = messages.users.panel; + +/** A line of the panel: what it is on the left, its value on the right. */ +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** A titled list of objects of the account, or « Aucun ». */ +function Section({ title, children }: { title: string; children: ReactNode[] }) { + return ( +
+

{title}

+ {children.length === 0 ? ( +

{texts.none}

+ ) : ( + + )} +
+ ); +} + +type UserPanelContentProps = { + user: AdminUserDetail; + /** Address of the sheet of a route, to open it from the card. */ + routePathOf: (routeId: string) => string; +}; + +/** The card of an account inside the detail panel (A-06): profile, plan, routes, reports. */ +export function UserPanelContent({ user, routePathOf }: UserPanelContentProps) { + const routeLink = (route: { id: string; title: string }) => ( + + {route.title} + + ); + const reportItem = (report: AdminUserDetail['reportsMade'][number]) => ( +
  • + {routeLink(report.route)} + + {messages.reports.reasons[report.reason]} ·{' '} + {messages.reports.routes.statuses[report.status]} ·{' '} + {formatRelativeDate(new Date(report.createdAt))} + +
  • + ); + return ( +
    +
    + {user.email ?? messages.users.noEmail} + + {user.id} + + + + + {planLabelOf(user.plan)} + {formatRelativeDate(new Date(user.createdAt))} + + {user.isPublic && user.suspendedAt === null ? texts.profilePublic : texts.profileMasked} + + {user.suspensionReason ? ( + {user.suspensionReason} + ) : null} + + + {user.certification.isCertified ? texts.certified : texts.notCertified} + + {texts.certificationDetail( + user.certification.computed, + texts.overrides[user.certification.override], + )} + + + +
    +
    + {user.unlockedRoutes.map((route) => ( +
  • + {routeLink(route)} + {route.source === 'gift' ? ( + · {texts.gift} + ) : null} +
  • + ))} +
    +
    + {user.createdRoutes.map((route) => ( +
  • + {routeLink(route)} + +
  • + ))} +
    +
    {user.reportsMade.map(reportItem)}
    +
    {user.reportsReceived.map(reportItem)}
    +
    {[]}
    +
    {[]}
    +

    {texts.ratingsNotice}

    +
    + {user.supportMessages.map((message) => ( +
  • + {messages.users.topics[message.topic]} ·{' '} + {formatRelativeDate(new Date(message.createdAt))} + + {' '} + · {message.handledAt ? texts.handled : texts.unhandled} + +
  • + ))} +
    +
    + ); +} diff --git a/apps/admin/src/components/users/UserReasonDialog.tsx b/apps/admin/src/components/users/UserReasonDialog.tsx new file mode 100644 index 00000000..dbacc625 --- /dev/null +++ b/apps/admin/src/components/users/UserReasonDialog.tsx @@ -0,0 +1,55 @@ +import { messages } from '@/messages/fr'; +import { ConfirmDialog, Field, Textarea } from '@widoo/ui'; + +const texts = messages.users.dialogs; + +type UserReasonDialogProps = { + isOpen: boolean; + /** What the window says: its question, the effect on the account, and its button. */ + dialog: { title: string; description: string; confirm: string }; + /** Red for a suspension, which refuses an account; blue for lifting it. */ + isDestructive: boolean; + reason: string; + onReasonChange: (reason: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** Suspension or reactivation of an account, with the required reason kept in the journal. */ +export function UserReasonDialog({ + isOpen, + dialog, + isDestructive, + reason, + onReasonChange, + onCancel, + onConfirm, + isBusy, +}: UserReasonDialogProps) { + return ( + + +