@voyant-travel/admin 0.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +285 -0
- package/dist/app/extension-routes.d.ts +99 -0
- package/dist/app/extension-routes.d.ts.map +1 -0
- package/dist/app/extension-routes.js +134 -0
- package/dist/app/index.d.ts +9 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +4 -0
- package/dist/app/root.d.ts +47 -0
- package/dist/app/root.d.ts.map +1 -0
- package/dist/app/root.js +55 -0
- package/dist/app/router.d.ts +30 -0
- package/dist/app/router.d.ts.map +1 -0
- package/dist/app/router.js +51 -0
- package/dist/app/workspace.d.ts +84 -0
- package/dist/app/workspace.d.ts.map +1 -0
- package/dist/app/workspace.js +87 -0
- package/dist/components/admin-breadcrumbs.d.ts +18 -0
- package/dist/components/admin-breadcrumbs.d.ts.map +1 -0
- package/dist/components/admin-breadcrumbs.js +84 -0
- package/dist/components/admin-nav-group.d.ts +11 -0
- package/dist/components/admin-nav-group.d.ts.map +1 -0
- package/dist/components/admin-nav-group.js +49 -0
- package/dist/components/admin-nav-link.d.ts +10 -0
- package/dist/components/admin-nav-link.d.ts.map +1 -0
- package/dist/components/admin-nav-link.js +5 -0
- package/dist/components/admin-page-head.d.ts +17 -0
- package/dist/components/admin-page-head.d.ts.map +1 -0
- package/dist/components/admin-page-head.js +107 -0
- package/dist/components/admin-widget-slot.d.ts +8 -0
- package/dist/components/admin-widget-slot.d.ts.map +1 -0
- package/dist/components/admin-widget-slot.js +19 -0
- package/dist/components/brand/voyant-mark.d.ts +3 -0
- package/dist/components/brand/voyant-mark.d.ts.map +1 -0
- package/dist/components/brand/voyant-mark.js +4 -0
- package/dist/components/brand/voyant-wordmark.d.ts +3 -0
- package/dist/components/brand/voyant-wordmark.d.ts.map +1 -0
- package/dist/components/brand/voyant-wordmark.js +4 -0
- package/dist/components/operator-admin-bootstrap-gate.d.ts +26 -0
- package/dist/components/operator-admin-bootstrap-gate.d.ts.map +1 -0
- package/dist/components/operator-admin-bootstrap-gate.js +22 -0
- package/dist/components/operator-admin-page-shell.d.ts +13 -0
- package/dist/components/operator-admin-page-shell.d.ts.map +1 -0
- package/dist/components/operator-admin-page-shell.js +6 -0
- package/dist/components/operator-admin-sidebar.d.ts +57 -0
- package/dist/components/operator-admin-sidebar.d.ts.map +1 -0
- package/dist/components/operator-admin-sidebar.js +104 -0
- package/dist/components/operator-admin-user-menu.d.ts +10 -0
- package/dist/components/operator-admin-user-menu.d.ts.map +1 -0
- package/dist/components/operator-admin-user-menu.js +19 -0
- package/dist/components/team-settings-page.d.ts +10 -0
- package/dist/components/team-settings-page.d.ts.map +1 -0
- package/dist/components/team-settings-page.js +149 -0
- package/dist/dashboard/dashboard-empty-states.d.ts +67 -0
- package/dist/dashboard/dashboard-empty-states.d.ts.map +1 -0
- package/dist/dashboard/dashboard-empty-states.js +65 -0
- package/dist/dashboard/dashboard-kpi-card.d.ts +13 -0
- package/dist/dashboard/dashboard-kpi-card.d.ts.map +1 -0
- package/dist/dashboard/dashboard-kpi-card.js +12 -0
- package/dist/dashboard/dashboard-page.d.ts +7 -0
- package/dist/dashboard/dashboard-page.d.ts.map +1 -0
- package/dist/dashboard/dashboard-page.js +150 -0
- package/dist/dashboard/dashboard-query-options.d.ts +224 -0
- package/dist/dashboard/dashboard-query-options.d.ts.map +1 -0
- package/dist/dashboard/dashboard-query-options.js +153 -0
- package/dist/dashboard/dashboard-skeleton.d.ts +13 -0
- package/dist/dashboard/dashboard-skeleton.d.ts.map +1 -0
- package/dist/dashboard/dashboard-skeleton.js +28 -0
- package/dist/extensions.d.ts +254 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +139 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/lib/i18n.d.ts +2 -0
- package/dist/lib/i18n.d.ts.map +1 -0
- package/dist/lib/i18n.js +1 -0
- package/dist/lib/initials.d.ts +24 -0
- package/dist/lib/initials.d.ts.map +1 -0
- package/dist/lib/initials.js +45 -0
- package/dist/navigation/destinations.d.ts +83 -0
- package/dist/navigation/destinations.d.ts.map +1 -0
- package/dist/navigation/destinations.js +65 -0
- package/dist/navigation/operator-navigation.d.ts +10 -0
- package/dist/navigation/operator-navigation.d.ts.map +1 -0
- package/dist/navigation/operator-navigation.js +191 -0
- package/dist/providers/admin-extensions.d.ts +9 -0
- package/dist/providers/admin-extensions.d.ts.map +1 -0
- package/dist/providers/admin-extensions.js +10 -0
- package/dist/providers/admin-provider.d.ts +53 -0
- package/dist/providers/admin-provider.d.ts.map +1 -0
- package/dist/providers/admin-provider.js +26 -0
- package/dist/providers/locale-preferences.d.ts +12 -0
- package/dist/providers/locale-preferences.d.ts.map +1 -0
- package/dist/providers/locale-preferences.js +32 -0
- package/dist/providers/locale.d.ts +23 -0
- package/dist/providers/locale.d.ts.map +1 -0
- package/dist/providers/locale.js +98 -0
- package/dist/providers/operator-admin-messages.d.ts +14 -0
- package/dist/providers/operator-admin-messages.d.ts.map +1 -0
- package/dist/providers/operator-admin-messages.js +16 -0
- package/dist/providers/operator-admin-shell.d.ts +35 -0
- package/dist/providers/operator-admin-shell.d.ts.map +1 -0
- package/dist/providers/operator-admin-shell.js +20 -0
- package/dist/providers/query-client.d.ts +19 -0
- package/dist/providers/query-client.d.ts.map +1 -0
- package/dist/providers/query-client.js +34 -0
- package/dist/providers/theme.d.ts +29 -0
- package/dist/providers/theme.d.ts.map +1 -0
- package/dist/providers/theme.js +63 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +222 -0
- package/src/styles.css +11 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useVoyantReactContext } from "@voyant-travel/react";
|
|
5
|
+
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, } from "@voyant-travel/ui/components";
|
|
6
|
+
import { Skeleton } from "@voyant-travel/ui/components/skeleton";
|
|
7
|
+
import { Copy, Loader2, Mail, Trash2, UserPlus } from "lucide-react";
|
|
8
|
+
import { createContext, useContext, useMemo, useState } from "react";
|
|
9
|
+
import { formatMessage } from "../lib/i18n.js";
|
|
10
|
+
import { useLocale } from "../providers/locale.js";
|
|
11
|
+
import { useOperatorAdminMessages } from "../providers/operator-admin-messages.js";
|
|
12
|
+
const QK = ["admin-invitations"];
|
|
13
|
+
const TeamSettingsPageApiContext = createContext(null);
|
|
14
|
+
function joinUrl(baseUrl, path) {
|
|
15
|
+
const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
16
|
+
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
|
|
17
|
+
return `${trimmedBase}${trimmedPath}`;
|
|
18
|
+
}
|
|
19
|
+
async function readJson(response) {
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
let body;
|
|
22
|
+
try {
|
|
23
|
+
body = await response.json();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
body = await response.text().catch(() => undefined);
|
|
27
|
+
}
|
|
28
|
+
const message = typeof body === "object" && body !== null && "error" in body
|
|
29
|
+
? String(body.error)
|
|
30
|
+
: `API error: ${response.status} ${response.statusText}`;
|
|
31
|
+
throw new Error(message);
|
|
32
|
+
}
|
|
33
|
+
if (response.status === 204)
|
|
34
|
+
return undefined;
|
|
35
|
+
return response.json();
|
|
36
|
+
}
|
|
37
|
+
function createTeamSettingsPageApi(baseUrl, fetcher) {
|
|
38
|
+
const request = async (path, init = {}) => {
|
|
39
|
+
const headers = new Headers(init.headers);
|
|
40
|
+
if (init.body !== undefined && !headers.has("Content-Type")) {
|
|
41
|
+
headers.set("Content-Type", "application/json");
|
|
42
|
+
}
|
|
43
|
+
return readJson(await fetcher(joinUrl(baseUrl, path), { ...init, headers }));
|
|
44
|
+
};
|
|
45
|
+
const api = {
|
|
46
|
+
get: (path) => request(path, { method: "GET" }),
|
|
47
|
+
post: (path, body) => request(path, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
50
|
+
}),
|
|
51
|
+
delete: (path) => request(path, { method: "DELETE" }),
|
|
52
|
+
};
|
|
53
|
+
return api;
|
|
54
|
+
}
|
|
55
|
+
function useTeamSettingsPageApi() {
|
|
56
|
+
const api = useContext(TeamSettingsPageApiContext);
|
|
57
|
+
if (!api)
|
|
58
|
+
throw new Error("TeamSettingsPage requires a TeamSettingsPageApiContext provider");
|
|
59
|
+
return api;
|
|
60
|
+
}
|
|
61
|
+
export function TeamSettingsPage({ api: apiProp } = {}) {
|
|
62
|
+
if (apiProp)
|
|
63
|
+
return _jsx(TeamSettingsPageContent, { api: apiProp });
|
|
64
|
+
return _jsx(TeamSettingsPageWithDefaultApi, {});
|
|
65
|
+
}
|
|
66
|
+
function TeamSettingsPageWithDefaultApi() {
|
|
67
|
+
const { baseUrl, fetcher } = useVoyantReactContext();
|
|
68
|
+
const api = useMemo(() => createTeamSettingsPageApi(baseUrl, fetcher), [baseUrl, fetcher]);
|
|
69
|
+
return _jsx(TeamSettingsPageContent, { api: api });
|
|
70
|
+
}
|
|
71
|
+
function TeamSettingsPageContent({ api }) {
|
|
72
|
+
const messages = useOperatorAdminMessages();
|
|
73
|
+
const { resolvedLocale } = useLocale();
|
|
74
|
+
const queryClient = useQueryClient();
|
|
75
|
+
const invitesQuery = useQuery({
|
|
76
|
+
queryKey: QK,
|
|
77
|
+
queryFn: () => api.get("/v1/admin/invitations"),
|
|
78
|
+
});
|
|
79
|
+
const revoke = useMutation({
|
|
80
|
+
mutationFn: (id) => api.delete(`/v1/admin/invitations/${id}`),
|
|
81
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: QK }),
|
|
82
|
+
});
|
|
83
|
+
const invites = invitesQuery.data?.data ?? [];
|
|
84
|
+
const pending = invites.filter((i) => !i.redeemedAt && new Date(i.expiresAt) > new Date());
|
|
85
|
+
const spent = invites.filter((i) => i.redeemedAt || new Date(i.expiresAt) <= new Date());
|
|
86
|
+
return (_jsx(TeamSettingsPageApiContext.Provider, { value: api, children: _jsxs("div", { className: "flex flex-col gap-6 p-6", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: messages.team.title }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: messages.team.description })] }), _jsx(InviteMemberDialog, {})] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.pendingInvitations }), _jsx(CardDescription, { children: pending.length === 0
|
|
87
|
+
? messages.team.noOutstandingInvitations
|
|
88
|
+
: formatMessage(pending.length === 1
|
|
89
|
+
? messages.team.invitationWaitingSingular
|
|
90
|
+
: messages.team.invitationWaitingPlural, { count: pending.length }) })] }), _jsx(CardContent, { children: invitesQuery.isPending ? (_jsx("ul", { className: "flex flex-col divide-y", children: Array.from({ length: 3 }).map((_, i) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Skeleton, { className: "h-4 w-4 rounded" }), _jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-48" }), _jsx(Skeleton, { className: "h-3 w-40" })] }), _jsx(Skeleton, { className: "h-7 w-20" })] }, i))) })) : pending.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.team.nothingHereYet })) : (_jsx("ul", { className: "flex flex-col divide-y", children: pending.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-3", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: invite.email }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(messages.team.expires, {
|
|
91
|
+
date: new Date(invite.expiresAt).toLocaleString(resolvedLocale),
|
|
92
|
+
}) })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive", onClick: () => {
|
|
93
|
+
if (confirm(formatMessage(messages.team.revokeConfirm, {
|
|
94
|
+
email: invite.email,
|
|
95
|
+
}))) {
|
|
96
|
+
revoke.mutate(invite.id);
|
|
97
|
+
}
|
|
98
|
+
}, disabled: revoke.isPending, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), messages.team.revoke] })] }, invite.id))) })) })] }), spent.length > 0 ? (_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.team.history }), _jsx(CardDescription, { children: messages.team.historyDescription })] }), _jsx(CardContent, { children: _jsx("ul", { className: "flex flex-col divide-y text-sm", children: spent.map((invite) => (_jsxs("li", { className: "flex items-center gap-4 py-2.5 text-muted-foreground", children: [_jsx(Mail, { className: "h-4 w-4 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: invite.email }), _jsx("span", { className: "text-xs", children: invite.redeemedAt
|
|
99
|
+
? formatMessage(messages.team.redeemed, {
|
|
100
|
+
date: new Date(invite.redeemedAt).toLocaleDateString(resolvedLocale),
|
|
101
|
+
})
|
|
102
|
+
: formatMessage(messages.team.expired, {
|
|
103
|
+
date: new Date(invite.expiresAt).toLocaleDateString(resolvedLocale),
|
|
104
|
+
}) })] }, invite.id))) }) })] })) : null] }) }));
|
|
105
|
+
}
|
|
106
|
+
function InviteMemberDialog() {
|
|
107
|
+
const messages = useOperatorAdminMessages();
|
|
108
|
+
const api = useTeamSettingsPageApi();
|
|
109
|
+
const queryClient = useQueryClient();
|
|
110
|
+
const [open, setOpen] = useState(false);
|
|
111
|
+
const [email, setEmail] = useState("");
|
|
112
|
+
const [result, setResult] = useState(null);
|
|
113
|
+
const [error, setError] = useState(null);
|
|
114
|
+
const [copied, setCopied] = useState(false);
|
|
115
|
+
const create = useMutation({
|
|
116
|
+
mutationFn: () => api.post("/v1/admin/invitations", { email: email.trim() }),
|
|
117
|
+
onSuccess: (response) => {
|
|
118
|
+
setResult(response.data);
|
|
119
|
+
setError(null);
|
|
120
|
+
void queryClient.invalidateQueries({ queryKey: QK });
|
|
121
|
+
},
|
|
122
|
+
onError: (e) => setError(e instanceof Error ? e.message : messages.team.errorCouldNotSendInvitation),
|
|
123
|
+
});
|
|
124
|
+
const close = () => {
|
|
125
|
+
setOpen(false);
|
|
126
|
+
// Let the dialog close animation finish before resetting state
|
|
127
|
+
window.setTimeout(() => {
|
|
128
|
+
setEmail("");
|
|
129
|
+
setResult(null);
|
|
130
|
+
setError(null);
|
|
131
|
+
setCopied(false);
|
|
132
|
+
}, 200);
|
|
133
|
+
};
|
|
134
|
+
const copyLink = async () => {
|
|
135
|
+
if (!result)
|
|
136
|
+
return;
|
|
137
|
+
await navigator.clipboard.writeText(result.acceptUrl);
|
|
138
|
+
setCopied(true);
|
|
139
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
140
|
+
};
|
|
141
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", onClick: () => setOpen(true), children: [_jsx(UserPlus, { className: "mr-1 h-4 w-4" }), messages.team.inviteMember] }), _jsx(Dialog, { open: open, onOpenChange: (o) => (o ? setOpen(true) : close()), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: messages.team.inviteDialogTitle }), _jsx(DialogDescription, { children: messages.team.inviteDialogDescription })] }), result ? (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "rounded-md border bg-muted/30 p-3 text-sm", children: _jsxs("p", { children: [formatMessage(messages.team.inviteCreated, {
|
|
142
|
+
email: result.email,
|
|
143
|
+
}), " ", result.emailSent
|
|
144
|
+
? messages.team.inviteEmailSentSuffix
|
|
145
|
+
: messages.team.inviteManualShareSuffix] }) }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.team.acceptLink }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: result.acceptUrl, readOnly: true, className: "font-mono text-xs" }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => void copyLink(), children: [_jsx(Copy, { className: "mr-1 h-3.5 w-3.5" }), copied ? messages.team.copied : messages.team.copy] })] })] }), _jsx(DialogFooter, { children: _jsx(Button, { onClick: close, children: messages.team.done }) })] })) : (_jsxs("form", { className: "space-y-4", onSubmit: (e) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
create.mutate();
|
|
148
|
+
}, children: [error && (_jsx("div", { className: "rounded-md bg-destructive/10 p-3 text-sm text-destructive", children: error })), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "invite-email", children: messages.team.emailLabel }), _jsx(Input, { id: "invite-email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: messages.team.emailPlaceholder, required: true, autoComplete: "off", autoFocus: true })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", onClick: close, children: messages.team.cancel }), _jsxs(Button, { type: "submit", disabled: create.isPending, children: [create.isPending && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), messages.team.sendInvitation] })] })] }))] }) })] }));
|
|
149
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { OperatorAdminMessages } from "../providers/operator-admin-messages.js";
|
|
3
|
+
export type DashboardEmptyStateKey = "revenueTrend" | "bookingStatus" | "monthlyBookings" | "upcomingDepartures" | "outstandingInvoices" | "onboarding";
|
|
4
|
+
export interface DashboardEmptyAction {
|
|
5
|
+
href: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
export interface DashboardEmptyStateConfig {
|
|
9
|
+
action?: DashboardEmptyAction | null;
|
|
10
|
+
description?: string | null;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
title?: string | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildDashboardEmptyStates(messages: OperatorAdminMessages, emptyStates: Partial<Record<DashboardEmptyStateKey, DashboardEmptyStateConfig>>): {
|
|
15
|
+
revenueTrend: {
|
|
16
|
+
action: DashboardEmptyAction | null;
|
|
17
|
+
description: string | null;
|
|
18
|
+
icon: string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null;
|
|
19
|
+
title: string | null;
|
|
20
|
+
};
|
|
21
|
+
bookingStatus: {
|
|
22
|
+
action: DashboardEmptyAction | null;
|
|
23
|
+
description: string | null;
|
|
24
|
+
icon: string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null;
|
|
25
|
+
title: string | null;
|
|
26
|
+
};
|
|
27
|
+
monthlyBookings: {
|
|
28
|
+
action: DashboardEmptyAction | null;
|
|
29
|
+
description: string | null;
|
|
30
|
+
icon: string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null;
|
|
31
|
+
title: string | null;
|
|
32
|
+
};
|
|
33
|
+
upcomingDepartures: {
|
|
34
|
+
action: DashboardEmptyAction | null;
|
|
35
|
+
description: string | null;
|
|
36
|
+
icon: string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null;
|
|
37
|
+
title: string | null;
|
|
38
|
+
};
|
|
39
|
+
outstandingInvoices: {
|
|
40
|
+
action: DashboardEmptyAction | null;
|
|
41
|
+
description: string | null;
|
|
42
|
+
icon: string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null;
|
|
43
|
+
title: string | null;
|
|
44
|
+
};
|
|
45
|
+
onboarding: {
|
|
46
|
+
action?: DashboardEmptyAction | null;
|
|
47
|
+
description: string | null;
|
|
48
|
+
icon: string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null;
|
|
49
|
+
title: string | null;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export declare function DashboardEmptyState({ compact, emptyState, }: {
|
|
53
|
+
compact?: boolean;
|
|
54
|
+
emptyState: DashboardEmptyStateConfig;
|
|
55
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
56
|
+
export declare function DashboardOnboardingEmptyState({ children, emptyState, }: {
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
emptyState: DashboardEmptyStateConfig;
|
|
59
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
60
|
+
export declare function OnboardingAction({ actionLabel, description, href, icon, title, }: {
|
|
61
|
+
actionLabel: string;
|
|
62
|
+
description: string;
|
|
63
|
+
href: string;
|
|
64
|
+
icon: ReactNode;
|
|
65
|
+
title: string;
|
|
66
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
67
|
+
//# sourceMappingURL=dashboard-empty-states.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-empty-states.d.ts","sourceRoot":"","sources":["../../src/dashboard/dashboard-empty-states.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAA;AAEpF,MAAM,MAAM,sBAAsB,GAC9B,cAAc,GACd,eAAe,GACf,iBAAiB,GACjB,oBAAoB,GACpB,qBAAqB,GACrB,YAAY,CAAA;AAEhB,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAAA;IACpC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,qBAAqB,EAC/B,WAAW,EAAE,OAAO,CAAC,MAAM,CAAC,sBAAsB,EAAE,yBAAyB,CAAC,CAAC;;gBARtE,oBAAoB,GAAG,IAAI;qBACtB,MAAM,GAAG,IAAI;;eAEnB,MAAM,GAAG,IAAI;;;gBAHZ,oBAAoB,GAAG,IAAI;qBACtB,MAAM,GAAG,IAAI;;eAEnB,MAAM,GAAG,IAAI;;;gBAHZ,oBAAoB,GAAG,IAAI;qBACtB,MAAM,GAAG,IAAI;;eAEnB,MAAM,GAAG,IAAI;;;gBAHZ,oBAAoB,GAAG,IAAI;qBACtB,MAAM,GAAG,IAAI;;eAEnB,MAAM,GAAG,IAAI;;;gBAHZ,oBAAoB,GAAG,IAAI;qBACtB,MAAM,GAAG,IAAI;;eAEnB,MAAM,GAAG,IAAI;;;iBAHZ,oBAAoB,GAAG,IAAI;qBACtB,MAAM,GAAG,IAAI;;eAEnB,MAAM,GAAG,IAAI;;EAqDtB;AAED,wBAAgB,mBAAmB,CAAC,EAClC,OAAe,EACf,UAAU,GACX,EAAE;IACD,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,UAAU,EAAE,yBAAyB,CAAA;CACtC,2CAqBA;AAED,wBAAgB,6BAA6B,CAAC,EAC5C,QAAQ,EACR,UAAU,GACX,EAAE;IACD,QAAQ,EAAE,SAAS,CAAA;IACnB,UAAU,EAAE,yBAAyB,CAAA;CACtC,2CAiBA;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,WAAW,EACX,WAAW,EACX,IAAI,EACJ,IAAI,EACJ,KAAK,GACN,EAAE;IACD,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,SAAS,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;CACd,2CAkBA"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Card, CardContent } from "@voyant-travel/ui/components";
|
|
4
|
+
import { buttonVariants } from "@voyant-travel/ui/components/button";
|
|
5
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@voyant-travel/ui/components/empty";
|
|
6
|
+
import { cn } from "@voyant-travel/ui/lib/utils";
|
|
7
|
+
import { BarChart3, CalendarCheck, ClipboardList, DollarSign, FileText, Sparkles, } from "lucide-react";
|
|
8
|
+
export function buildDashboardEmptyStates(messages, emptyStates) {
|
|
9
|
+
return {
|
|
10
|
+
revenueTrend: {
|
|
11
|
+
title: messages.dashboard.revenueTrendEmptyTitle,
|
|
12
|
+
description: messages.dashboard.revenueTrendEmptyDescription,
|
|
13
|
+
action: { href: "/bookings", label: messages.dashboard.revenueTrendEmptyAction },
|
|
14
|
+
icon: _jsx(DollarSign, { className: "size-5" }),
|
|
15
|
+
...emptyStates.revenueTrend,
|
|
16
|
+
},
|
|
17
|
+
bookingStatus: {
|
|
18
|
+
title: messages.dashboard.bookingStatusEmptyTitle,
|
|
19
|
+
description: messages.dashboard.bookingStatusEmptyDescription,
|
|
20
|
+
action: { href: "/bookings", label: messages.dashboard.bookingStatusEmptyAction },
|
|
21
|
+
icon: _jsx(ClipboardList, { className: "size-5" }),
|
|
22
|
+
...emptyStates.bookingStatus,
|
|
23
|
+
},
|
|
24
|
+
monthlyBookings: {
|
|
25
|
+
title: messages.dashboard.monthlyBookingsEmptyTitle,
|
|
26
|
+
description: messages.dashboard.monthlyBookingsEmptyDescription,
|
|
27
|
+
action: { href: "/bookings", label: messages.dashboard.monthlyBookingsEmptyAction },
|
|
28
|
+
icon: _jsx(BarChart3, { className: "size-5" }),
|
|
29
|
+
...emptyStates.monthlyBookings,
|
|
30
|
+
},
|
|
31
|
+
upcomingDepartures: {
|
|
32
|
+
title: messages.dashboard.noUpcomingDepartures,
|
|
33
|
+
description: messages.dashboard.noUpcomingDeparturesDescription,
|
|
34
|
+
action: null,
|
|
35
|
+
icon: _jsx(CalendarCheck, { className: "size-5" }),
|
|
36
|
+
...emptyStates.upcomingDepartures,
|
|
37
|
+
},
|
|
38
|
+
outstandingInvoices: {
|
|
39
|
+
title: messages.dashboard.outstandingInvoicesEmptyTitle,
|
|
40
|
+
description: messages.dashboard.outstandingInvoicesEmptyDescription,
|
|
41
|
+
action: {
|
|
42
|
+
href: "/finance",
|
|
43
|
+
label: messages.dashboard.outstandingInvoicesEmptyAction,
|
|
44
|
+
},
|
|
45
|
+
icon: _jsx(FileText, { className: "size-5" }),
|
|
46
|
+
...emptyStates.outstandingInvoices,
|
|
47
|
+
},
|
|
48
|
+
onboarding: {
|
|
49
|
+
title: messages.dashboard.onboardingTitle,
|
|
50
|
+
description: messages.dashboard.onboardingDescription,
|
|
51
|
+
icon: _jsx(Sparkles, { className: "size-5" }),
|
|
52
|
+
...emptyStates.onboarding,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function DashboardEmptyState({ compact = false, emptyState, }) {
|
|
57
|
+
const action = emptyState.action ?? null;
|
|
58
|
+
return (_jsxs(Empty, { className: cn("min-h-[250px] border", compact && "min-h-[180px] p-8"), children: [_jsxs(EmptyHeader, { children: [emptyState.icon ? _jsx(EmptyMedia, { variant: "icon", children: emptyState.icon }) : null, emptyState.title ? _jsx(EmptyTitle, { children: emptyState.title }) : null, emptyState.description ? (_jsx(EmptyDescription, { children: emptyState.description })) : null] }), action ? (_jsx(EmptyContent, { children: _jsx("a", { href: action.href, className: cn(buttonVariants({ size: "sm" })), children: action.label }) })) : null] }));
|
|
59
|
+
}
|
|
60
|
+
export function DashboardOnboardingEmptyState({ children, emptyState, }) {
|
|
61
|
+
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsxs(Empty, { className: "border p-6", children: [_jsxs(EmptyHeader, { children: [emptyState.icon ? _jsx(EmptyMedia, { variant: "icon", children: emptyState.icon }) : null, emptyState.title ? _jsx(EmptyTitle, { children: emptyState.title }) : null, emptyState.description ? (_jsx(EmptyDescription, { children: emptyState.description })) : null] }), _jsx("div", { className: "grid w-full gap-3 md:grid-cols-2", children: children })] }) }) }));
|
|
62
|
+
}
|
|
63
|
+
export function OnboardingAction({ actionLabel, description, href, icon, title, }) {
|
|
64
|
+
return (_jsxs("a", { href: href, className: "flex min-w-0 items-start justify-between gap-4 rounded-lg border bg-background p-4 text-left transition-colors hover:bg-muted/50", children: [_jsxs("span", { className: "flex min-w-0 gap-3", children: [_jsx("span", { className: "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground", children: icon }), _jsxs("span", { className: "min-w-0 space-y-1", children: [_jsx("span", { className: "block text-sm font-medium", children: title }), _jsx("span", { className: "block text-sm text-muted-foreground", children: description })] })] }), _jsx("span", { className: "shrink-0 text-sm font-medium text-primary", children: actionLabel })] }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export declare function KpiCard({ title, value, description, icon, trend, trendLabel, empty, emptyLabel, isLoading, }: {
|
|
3
|
+
title: string;
|
|
4
|
+
value: string;
|
|
5
|
+
description: string;
|
|
6
|
+
icon: ReactNode;
|
|
7
|
+
trend?: number;
|
|
8
|
+
trendLabel?: string;
|
|
9
|
+
empty?: boolean;
|
|
10
|
+
emptyLabel?: string;
|
|
11
|
+
isLoading?: boolean;
|
|
12
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=dashboard-kpi-card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-kpi-card.d.ts","sourceRoot":"","sources":["../../src/dashboard/dashboard-kpi-card.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,wBAAgB,OAAO,CAAC,EACtB,KAAK,EACL,KAAK,EACL,WAAW,EACX,IAAI,EACJ,KAAK,EACL,UAAU,EACV,KAAK,EACL,UAAU,EACV,SAAS,GACV,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,SAAS,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,2CA0DA"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@voyant-travel/ui/components";
|
|
4
|
+
import { Skeleton } from "@voyant-travel/ui/components/skeleton";
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@voyant-travel/ui/components/tooltip";
|
|
6
|
+
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
|
|
7
|
+
export function KpiCard({ title, value, description, icon, trend, trendLabel, empty, emptyLabel, isLoading, }) {
|
|
8
|
+
const isPositive = (trend ?? 0) >= 0;
|
|
9
|
+
return (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0 pb-2", children: [_jsx(CardTitle, { className: "text-sm font-medium", children: title }), icon] }), _jsx(CardContent, { children: isLoading ? (_jsxs("div", { className: "space-y-2", children: [_jsx(Skeleton, { className: "h-7 w-28" }), _jsx(Skeleton, { className: "h-3 w-40" }), trendLabel ? _jsx(Skeleton, { className: "mt-3 h-5 w-28 rounded-full" }) : null] })) : (_jsxs(_Fragment, { children: [empty ? (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { className: "text-2xl font-semibold tracking-tight text-muted-foreground", "aria-label": emptyLabel ?? description, children: "-" }), _jsx(TooltipContent, { children: emptyLabel ?? description })] }) })) : (_jsx("div", { className: "text-2xl font-semibold tracking-tight", children: value })), _jsx("p", { className: "text-xs text-muted-foreground", children: description }), !empty && trend != null && trendLabel ? (_jsxs("div", { className: "mt-3 flex items-center gap-1 text-xs text-muted-foreground", children: [_jsxs("span", { className: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 ${isPositive
|
|
10
|
+
? "bg-emerald-500/10 text-emerald-600"
|
|
11
|
+
: "bg-rose-500/10 text-rose-600"}`, children: [isPositive ? (_jsx(ArrowUpRight, { className: "h-3 w-3" })) : (_jsx(ArrowDownRight, { className: "h-3 w-3" })), Math.abs(trend).toFixed(1), "%"] }), _jsx("span", { children: trendLabel })] })) : null] })) })] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DashboardEmptyStateConfig, type DashboardEmptyStateKey } from "./dashboard-empty-states.js";
|
|
2
|
+
export type { DashboardEmptyAction, DashboardEmptyStateConfig, DashboardEmptyStateKey, } from "./dashboard-empty-states.js";
|
|
3
|
+
export interface DashboardPageProps {
|
|
4
|
+
emptyStates?: Partial<Record<DashboardEmptyStateKey, DashboardEmptyStateConfig>>;
|
|
5
|
+
}
|
|
6
|
+
export declare function DashboardPage({ emptyStates }?: DashboardPageProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
//# sourceMappingURL=dashboard-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-page.d.ts","sourceRoot":"","sources":["../../src/dashboard/dashboard-page.tsx"],"names":[],"mappings":"AAsCA,OAAO,EAGL,KAAK,yBAAyB,EAC9B,KAAK,sBAAsB,EAG5B,MAAM,6BAA6B,CAAA;AAoBpC,YAAY,EACV,oBAAoB,EACpB,yBAAyB,EACzB,sBAAsB,GACvB,MAAM,6BAA6B,CAAA;AAEpC,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,sBAAsB,EAAE,yBAAyB,CAAC,CAAC,CAAA;CACjF;AAED,wBAAgB,aAAa,CAAC,EAAE,WAAgB,EAAE,GAAE,kBAAuB,2CAoe1E"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { Link } from "@tanstack/react-router";
|
|
5
|
+
import { useVoyantReactContext } from "@voyant-travel/react";
|
|
6
|
+
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@voyant-travel/ui/components";
|
|
7
|
+
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@voyant-travel/ui/components/chart";
|
|
8
|
+
import { CalendarCheck, CalendarPlus, DollarSign, Package, PackagePlus, Users } from "lucide-react";
|
|
9
|
+
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis, } from "recharts";
|
|
10
|
+
import { AdminWidgetSlotRenderer } from "../components/admin-widget-slot.js";
|
|
11
|
+
import { formatMessage } from "../lib/i18n.js";
|
|
12
|
+
import { useLocale } from "../providers/locale.js";
|
|
13
|
+
import { useOperatorAdminMessages } from "../providers/operator-admin-messages.js";
|
|
14
|
+
import { buildDashboardEmptyStates, DashboardEmptyState, DashboardOnboardingEmptyState, OnboardingAction, } from "./dashboard-empty-states.js";
|
|
15
|
+
import { KpiCard } from "./dashboard-kpi-card.js";
|
|
16
|
+
import { buildMonthSeries, formatCurrency, getDashboardBookingsAggregatesQueryOptions, getDashboardFinanceAggregatesQueryOptions, getDashboardProductsAggregatesQueryOptions, getDashboardSuppliersAggregatesQueryOptions, getStatusColor, pickPrimaryCurrency, } from "./dashboard-query-options.js";
|
|
17
|
+
import { DashboardAreaChartSkeleton, DashboardBarChartSkeleton, DashboardOutstandingInvoicesSkeleton, DashboardPieChartSkeleton, DashboardUpcomingListSkeleton, } from "./dashboard-skeleton.js";
|
|
18
|
+
export function DashboardPage({ emptyStates = {} } = {}) {
|
|
19
|
+
const client = useVoyantReactContext();
|
|
20
|
+
const messages = useOperatorAdminMessages();
|
|
21
|
+
const { resolvedLocale } = useLocale();
|
|
22
|
+
const { data: bookingsAggregates, isPending: bookingsPending } = useQuery(getDashboardBookingsAggregatesQueryOptions(client));
|
|
23
|
+
const { data: productsAggregates, isPending: productsPending } = useQuery(getDashboardProductsAggregatesQueryOptions(client));
|
|
24
|
+
const { data: suppliersAggregates, isPending: suppliersPending } = useQuery(getDashboardSuppliersAggregatesQueryOptions(client));
|
|
25
|
+
const { data: financeAggregates, isPending: financePending } = useQuery(getDashboardFinanceAggregatesQueryOptions(client));
|
|
26
|
+
const bookings = bookingsAggregates?.data;
|
|
27
|
+
const products = productsAggregates?.data;
|
|
28
|
+
const suppliers = suppliersAggregates?.data;
|
|
29
|
+
const finance = financeAggregates?.data;
|
|
30
|
+
const monthSeries = buildMonthSeries();
|
|
31
|
+
const defaultCurrency = pickPrimaryCurrency(bookings?.monthlyRevenue ?? []);
|
|
32
|
+
const monthlyRevenue = monthSeries.map((entry) => {
|
|
33
|
+
const revenue = bookings?.monthlyRevenue
|
|
34
|
+
.filter((row) => row.yearMonth === entry.yearMonth)
|
|
35
|
+
.reduce((sum, row) => sum + row.sellAmountCents, 0) ?? 0;
|
|
36
|
+
const bookingsInMonth = bookings?.monthlyCounts.find((row) => row.yearMonth === entry.yearMonth)?.count ?? 0;
|
|
37
|
+
return { month: entry.month, revenue: revenue / 100, bookings: bookingsInMonth };
|
|
38
|
+
});
|
|
39
|
+
const monthlyBookings = monthSeries.map((entry) => ({
|
|
40
|
+
month: entry.month,
|
|
41
|
+
count: bookings?.monthlyCounts.find((row) => row.yearMonth === entry.yearMonth)?.count ?? 0,
|
|
42
|
+
}));
|
|
43
|
+
const totalRevenueCents = bookings?.monthlyRevenue
|
|
44
|
+
.filter((row) => row.currency === defaultCurrency)
|
|
45
|
+
.reduce((sum, row) => sum + row.sellAmountCents, 0) ?? 0;
|
|
46
|
+
const confirmedBookings = (bookings?.countsByStatus.find((row) => row.status === "confirmed")?.count ?? 0) +
|
|
47
|
+
(bookings?.countsByStatus.find((row) => row.status === "in_progress")?.count ?? 0);
|
|
48
|
+
const totalPax = bookings?.totalPax ?? 0;
|
|
49
|
+
const activeProducts = products?.active ?? 0;
|
|
50
|
+
const totalProducts = products?.total ?? 0;
|
|
51
|
+
const totalSuppliers = suppliers?.total ?? 0;
|
|
52
|
+
const outstandingInvoiceCount = finance?.outstanding.reduce((sum, row) => sum + row.count, 0) ?? 0;
|
|
53
|
+
const outstandingPrimaryCurrency = finance?.outstanding[0]?.currency ?? defaultCurrency;
|
|
54
|
+
const outstandingAmount = finance?.outstanding.find((row) => row.currency === outstandingPrimaryCurrency)
|
|
55
|
+
?.balanceDueCents ?? 0;
|
|
56
|
+
const outstandingTopN = finance?.outstandingTopN ?? [];
|
|
57
|
+
const currentMonthRevenue = monthlyRevenue[monthlyRevenue.length - 1]?.revenue ?? 0;
|
|
58
|
+
const prevMonthRevenue = monthlyRevenue[monthlyRevenue.length - 2]?.revenue ?? 0;
|
|
59
|
+
const revenueTrend = prevMonthRevenue > 0 ? ((currentMonthRevenue - prevMonthRevenue) / prevMonthRevenue) * 100 : 0;
|
|
60
|
+
const currentMonthBookings = monthlyBookings[monthlyBookings.length - 1]?.count ?? 0;
|
|
61
|
+
const prevMonthBookings = monthlyBookings[monthlyBookings.length - 2]?.count ?? 0;
|
|
62
|
+
const bookingTrend = prevMonthBookings > 0
|
|
63
|
+
? ((currentMonthBookings - prevMonthBookings) / prevMonthBookings) * 100
|
|
64
|
+
: 0;
|
|
65
|
+
const revenueChartConfig = {
|
|
66
|
+
revenue: { label: messages.dashboard.chartRevenueLabel, color: "hsl(221 83% 53%)" },
|
|
67
|
+
bookings: { label: messages.dashboard.chartBookingsLabel, color: "hsl(142 71% 45%)" },
|
|
68
|
+
};
|
|
69
|
+
const bookingStatusConfig = {
|
|
70
|
+
confirmed: { label: messages.dashboard.statusConfirmedLabel, color: "hsl(142 71% 45%)" },
|
|
71
|
+
completed: { label: messages.dashboard.statusCompletedLabel, color: "hsl(221 83% 53%)" },
|
|
72
|
+
in_progress: { label: messages.dashboard.statusInProgressLabel, color: "hsl(47 96% 53%)" },
|
|
73
|
+
draft: { label: messages.dashboard.statusDraftLabel, color: "hsl(215 14% 55%)" },
|
|
74
|
+
cancelled: { label: messages.dashboard.statusCancelledLabel, color: "hsl(0 84% 60%)" },
|
|
75
|
+
};
|
|
76
|
+
const monthlyBookingsConfig = {
|
|
77
|
+
count: { label: messages.dashboard.chartBookingsLabel, color: "hsl(221 83% 53%)" },
|
|
78
|
+
};
|
|
79
|
+
const localizedStatusBreakdown = (bookings?.countsByStatus ?? [])
|
|
80
|
+
.filter((entry) => entry.count > 0)
|
|
81
|
+
.map((entry) => ({
|
|
82
|
+
// Keep the raw status key so the chart config (keyed by
|
|
83
|
+
// `confirmed`/`completed`/...) can resolve the localized label
|
|
84
|
+
// for both the legend and the tooltip.
|
|
85
|
+
status: entry.status,
|
|
86
|
+
count: entry.count,
|
|
87
|
+
fill: getStatusColor(entry.status),
|
|
88
|
+
}));
|
|
89
|
+
const upcoming = bookings?.upcomingDepartures.items ?? [];
|
|
90
|
+
const hasRevenueData = monthlyRevenue.some((entry) => entry.revenue > 0);
|
|
91
|
+
const hasMonthlyBookingsData = monthlyBookings.some((entry) => entry.count > 0);
|
|
92
|
+
const hasBookingStatusData = localizedStatusBreakdown.length > 0;
|
|
93
|
+
const hasOutstandingInvoices = outstandingInvoiceCount > 0 || outstandingTopN.length > 0;
|
|
94
|
+
const allAggregatesLoaded = !bookingsPending && !productsPending && !suppliersPending && !financePending;
|
|
95
|
+
const isBrandNewTenant = allAggregatesLoaded &&
|
|
96
|
+
(bookings?.total ?? 0) === 0 &&
|
|
97
|
+
(products?.total ?? 0) === 0 &&
|
|
98
|
+
(suppliers?.total ?? 0) === 0 &&
|
|
99
|
+
(finance?.total ?? 0) === 0;
|
|
100
|
+
const resolvedEmptyStates = buildDashboardEmptyStates(messages, emptyStates);
|
|
101
|
+
const dashboardMetrics = {
|
|
102
|
+
totalRevenueCents,
|
|
103
|
+
confirmedBookings,
|
|
104
|
+
totalPax,
|
|
105
|
+
activeProducts,
|
|
106
|
+
outstandingAmount,
|
|
107
|
+
outstandingInvoiceCount,
|
|
108
|
+
defaultCurrency,
|
|
109
|
+
};
|
|
110
|
+
const widgetProps = {
|
|
111
|
+
bookingsAggregates: bookings ?? null,
|
|
112
|
+
productsAggregates: products ?? null,
|
|
113
|
+
suppliersAggregates: suppliers ?? null,
|
|
114
|
+
financeAggregates: finance ?? null,
|
|
115
|
+
metrics: dashboardMetrics,
|
|
116
|
+
emptyStates: resolvedEmptyStates,
|
|
117
|
+
};
|
|
118
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-6", children: [_jsx(AdminWidgetSlotRenderer, { slot: "dashboard.header", props: widgetProps }), isBrandNewTenant ? (_jsxs(_Fragment, { children: [_jsxs(DashboardOnboardingEmptyState, { emptyState: resolvedEmptyStates.onboarding, children: [_jsx(OnboardingAction, { href: "/products", icon: _jsx(PackagePlus, { className: "size-4" }), title: messages.dashboard.onboardingProductsTitle, description: messages.dashboard.onboardingProductsDescription, actionLabel: messages.dashboard.onboardingProductsAction }), _jsx(OnboardingAction, { href: "/suppliers", icon: _jsx(Users, { className: "size-4" }), title: messages.dashboard.onboardingSuppliersTitle, description: messages.dashboard.onboardingSuppliersDescription, actionLabel: messages.dashboard.onboardingSuppliersAction }), _jsx(OnboardingAction, { href: "/contacts", icon: _jsx(Users, { className: "size-4" }), title: messages.dashboard.onboardingCustomersTitle, description: messages.dashboard.onboardingCustomersDescription, actionLabel: messages.dashboard.onboardingCustomersAction }), _jsx(OnboardingAction, { href: "/bookings", icon: _jsx(CalendarPlus, { className: "size-4" }), title: messages.dashboard.onboardingBookingsTitle, description: messages.dashboard.onboardingBookingsDescription, actionLabel: messages.dashboard.onboardingBookingsAction })] }), _jsx(AdminWidgetSlotRenderer, { slot: "dashboard.footer", props: widgetProps })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: [_jsx(KpiCard, { title: messages.dashboard.totalRevenueTitle, value: formatCurrency(totalRevenueCents, defaultCurrency), description: messages.dashboard.totalRevenueDescription, icon: _jsx(DollarSign, { className: "h-4 w-4 text-muted-foreground" }), trend: revenueTrend, trendLabel: messages.dashboard.trendVsLastMonth, empty: !hasRevenueData, emptyLabel: messages.dashboard.metricUnavailable, isLoading: bookingsPending }), _jsx(KpiCard, { title: messages.dashboard.activeBookingsTitle, value: confirmedBookings.toString(), description: formatMessage(messages.dashboard.activeBookingsDescription, {
|
|
119
|
+
count: bookings?.total ?? 0,
|
|
120
|
+
}), icon: _jsx(CalendarCheck, { className: "h-4 w-4 text-muted-foreground" }), trend: bookingTrend, trendLabel: messages.dashboard.trendVsLastMonth, empty: (bookings?.total ?? 0) === 0, emptyLabel: messages.dashboard.metricUnavailable, isLoading: bookingsPending }), _jsx(KpiCard, { title: messages.dashboard.totalTravelersTitle, value: totalPax.toLocaleString(), description: messages.dashboard.totalTravelersDescription, icon: _jsx(Users, { className: "h-4 w-4 text-muted-foreground" }), empty: totalPax === 0 && (bookings?.total ?? 0) === 0, emptyLabel: messages.dashboard.metricUnavailable, isLoading: bookingsPending }), _jsx(KpiCard, { title: messages.dashboard.activeProductsTitle, value: activeProducts.toString(), description: formatMessage(messages.dashboard.activeProductsDescription, {
|
|
121
|
+
products: totalProducts,
|
|
122
|
+
suppliers: totalSuppliers,
|
|
123
|
+
}), icon: _jsx(Package, { className: "h-4 w-4 text-muted-foreground" }), empty: activeProducts === 0 && totalProducts === 0, emptyLabel: messages.dashboard.metricUnavailable, isLoading: productsPending || suppliersPending })] }), _jsx(AdminWidgetSlotRenderer, { slot: "dashboard.after-kpis", props: widgetProps }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.dashboard.revenueTrendTitle }), _jsx(CardDescription, { children: messages.dashboard.revenueTrendDescription })] }), _jsx(CardContent, { children: bookingsPending ? (_jsx(DashboardAreaChartSkeleton, {})) : !hasRevenueData ? (_jsx(DashboardEmptyState, { emptyState: resolvedEmptyStates.revenueTrend })) : (_jsx(ChartContainer, { config: revenueChartConfig, className: "h-[300px] w-full", children: _jsxs(AreaChart, { data: monthlyRevenue, margin: { top: 10, right: 10, left: 0, bottom: 0 }, children: [_jsx("defs", { children: _jsxs("linearGradient", { id: "fillRevenue", x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx("stop", { offset: "5%", stopColor: "hsl(221 83% 53%)", stopOpacity: 0.3 }), _jsx("stop", { offset: "95%", stopColor: "hsl(221 83% 53%)", stopOpacity: 0 })] }) }), _jsx(CartesianGrid, { vertical: false }), _jsx(XAxis, { dataKey: "month", tickLine: false, axisLine: false, tickMargin: 8 }), _jsx(YAxis, { tickLine: false, axisLine: false, tickMargin: 8, tickFormatter: (value) => `$${(value / 1000).toFixed(0)}k` }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { formatter: (value) => typeof value === "number"
|
|
124
|
+
? formatCurrency(value * 100, defaultCurrency)
|
|
125
|
+
: String(value) }) }), _jsx(Area, { type: "monotone", dataKey: "revenue", stroke: "hsl(221 83% 53%)", fill: "url(#fillRevenue)", strokeWidth: 2 })] }) })) })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-2", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.dashboard.monthlyBookingsTitle }), _jsx(CardDescription, { children: messages.dashboard.monthlyBookingsDescription })] }), _jsx(CardContent, { children: bookingsPending ? (_jsx(DashboardBarChartSkeleton, {})) : !hasMonthlyBookingsData ? (_jsx(DashboardEmptyState, { emptyState: resolvedEmptyStates.monthlyBookings, compact: true })) : (_jsx(ChartContainer, { config: monthlyBookingsConfig, className: "h-[250px] w-full", children: _jsxs(BarChart, { data: monthlyBookings, margin: { top: 10, right: 10, left: 0, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false }), _jsx(XAxis, { dataKey: "month", tickLine: false, axisLine: false, tickMargin: 8 }), _jsx(YAxis, { tickLine: false, axisLine: false, tickMargin: 8, allowDecimals: false }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, {}) }), _jsx(Bar, { dataKey: "count", fill: "hsl(221 83% 53%)", radius: [4, 4, 0, 0] })] }) })) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.dashboard.bookingStatusTitle }), _jsx(CardDescription, { children: messages.dashboard.bookingStatusDescription })] }), _jsx(CardContent, { children: bookingsPending ? (_jsx(DashboardPieChartSkeleton, {})) : !hasBookingStatusData ? (_jsx(DashboardEmptyState, { emptyState: resolvedEmptyStates.bookingStatus })) : (_jsx(ChartContainer, { config: bookingStatusConfig, className: "mx-auto h-[300px] w-full", children: _jsxs(PieChart, { children: [_jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { nameKey: "status", hideLabel: true }) }), _jsx(Pie, { data: localizedStatusBreakdown, dataKey: "count", nameKey: "status", cx: "50%", cy: "50%", innerRadius: 60, outerRadius: 100, paddingAngle: 2, children: localizedStatusBreakdown.map((entry) => (_jsx(Cell, { fill: entry.fill }, entry.status))) }), _jsx(ChartLegend, { content: _jsx(ChartLegendContent, { nameKey: "status" }) })] }) })) })] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-2", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs("div", { children: [_jsx(CardTitle, { children: messages.dashboard.upcomingDeparturesTitle }), _jsx(CardDescription, { children: messages.dashboard.upcomingDeparturesDescription })] }), _jsx(Link, { to: "/bookings", className: "text-sm text-primary hover:underline", children: messages.dashboard.viewAll })] }), _jsx(CardContent, { children: bookingsPending ? (_jsx(DashboardUpcomingListSkeleton, {})) : upcoming.length === 0 ? (_jsx(DashboardEmptyState, { emptyState: resolvedEmptyStates.upcomingDepartures, compact: true })) : (_jsx("div", { className: "space-y-3", children: upcoming.map((booking) => (_jsxs(Link, { to: "/bookings/$id", params: { id: booking.id }, className: "flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50", children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-sm font-medium", children: booking.bookingNumber ?? booking.id.slice(0, 8) }), _jsxs("span", { className: "text-xs text-muted-foreground", children: [booking.startDate
|
|
126
|
+
? new Date(booking.startDate).toLocaleDateString(resolvedLocale, {
|
|
127
|
+
month: "short",
|
|
128
|
+
day: "numeric",
|
|
129
|
+
year: "numeric",
|
|
130
|
+
})
|
|
131
|
+
: messages.dashboard.noDate, booking.pax
|
|
132
|
+
? ` · ${formatMessage(messages.dashboard.paxCount, {
|
|
133
|
+
count: booking.pax,
|
|
134
|
+
})}`
|
|
135
|
+
: ""] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [booking.sellAmountCents != null && (_jsx("span", { className: "text-sm font-medium tabular-nums", children: formatCurrency(booking.sellAmountCents, booking.sellCurrency ?? defaultCurrency) })), _jsx(Badge, { variant: "outline", className: "capitalize", children: booking.status.replace(/_/g, " ") })] })] }, booking.id))) })) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { children: messages.dashboard.outstandingInvoicesTitle }), _jsx(CardDescription, { children: messages.dashboard.outstandingInvoicesDescription })] }), _jsx(CardContent, { children: financePending ? (_jsx(DashboardOutstandingInvoicesSkeleton, {})) : !hasOutstandingInvoices ? (_jsx(DashboardEmptyState, { emptyState: resolvedEmptyStates.outstandingInvoices })) : (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-dashed p-4", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: messages.dashboard.outstandingTotalTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(messages.dashboard.outstandingInvoicesDue, {
|
|
136
|
+
count: outstandingInvoiceCount,
|
|
137
|
+
}) })] }), _jsx("p", { className: "text-lg font-semibold", children: formatCurrency(outstandingAmount, outstandingPrimaryCurrency) })] }), outstandingTopN.map((invoice) => (_jsxs("div", { className: "flex items-center justify-between rounded-lg border p-3", children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-sm font-medium", children: invoice.invoiceNumber ?? invoice.id.slice(0, 8) }), _jsx("span", { className: "text-xs text-muted-foreground", children: invoice.dueDate
|
|
138
|
+
? new Date(invoice.dueDate).toLocaleDateString(resolvedLocale, {
|
|
139
|
+
month: "short",
|
|
140
|
+
day: "numeric",
|
|
141
|
+
year: "numeric",
|
|
142
|
+
})
|
|
143
|
+
: invoice.issueDate
|
|
144
|
+
? new Date(invoice.issueDate).toLocaleDateString(resolvedLocale, {
|
|
145
|
+
month: "short",
|
|
146
|
+
day: "numeric",
|
|
147
|
+
year: "numeric",
|
|
148
|
+
})
|
|
149
|
+
: messages.dashboard.noIssueDate })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: formatCurrency(invoice.balanceDueCents, invoice.currency) }), _jsx(Badge, { variant: "secondary", className: "capitalize", children: invoice.status })] })] }, invoice.id)))] })) })] })] }), _jsx(AdminWidgetSlotRenderer, { slot: "dashboard.footer", props: widgetProps })] }))] }));
|
|
150
|
+
}
|