feat(dashboard): customizable dashboard with personal widgets
- Drag-and-drop widget arrangement (main two-column zone + sidebar) with per-widget span resize, persisted to Users.preferences.dashboard.layout via saveDashboardLayout (migration included) - New personal widgets: Wallet, Open Negotiations, My Tickets (View all), Recent Notifications (per-row mark as read) - Widgets that are empty or not visible to the viewer are omitted so they never render as empty drag targets - Registers both pending migrations and regenerates payload types/schema
This commit is contained in:
parent
365e8561ec
commit
f0af778713
15 changed files with 27066 additions and 44 deletions
82
src/app/(frontend)/actions.ts
Normal file
82
src/app/(frontend)/actions.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { normalizeDashboardLayout } from "@/lib/dashboard/layout";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function saveDashboardLayout(
|
||||
layout: unknown,
|
||||
): Promise<ActionResult<boolean>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
// Write the full preferences object (same approach as updatePreferences in
|
||||
// account/actions.ts) so sibling preference groups can never be clobbered
|
||||
// by a partial nested update. The layout itself is sanitized server-side;
|
||||
// unknown ids are dropped and missing widgets fall back to defaults.
|
||||
const normalized = normalizeDashboardLayout(layout);
|
||||
const fullUser = await payload.findByID({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const preferences = fullUser.preferences ?? {};
|
||||
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
data: {
|
||||
preferences: {
|
||||
notifications: {
|
||||
mutedAll: preferences.notifications?.mutedAll ?? false,
|
||||
mutedTypes: preferences.notifications?.mutedTypes ?? [],
|
||||
},
|
||||
display: {
|
||||
showCallsign: preferences.display?.showCallsign ?? false,
|
||||
showVersionOverlay: preferences.display?.showVersionOverlay ?? true,
|
||||
showBackgroundTexture: preferences.display?.showBackgroundTexture ?? true,
|
||||
leadOrActual: preferences.display?.leadOrActual ?? "lead",
|
||||
xpDisplayMini: preferences.display?.xpDisplayMini ?? false,
|
||||
},
|
||||
discord: {
|
||||
enabled: preferences.discord?.enabled ?? false,
|
||||
mutedTypes: preferences.discord?.mutedTypes ?? [],
|
||||
},
|
||||
dashboard: { layout: normalized as unknown as Record<string, unknown> },
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return { success: true, data: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,40 @@
|
|||
import config from "@payload-config";
|
||||
import type { ReactNode } from "react";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
|
||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import { getCurrencyConfig } from "@/lib/banking";
|
||||
import { CLOSING_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
|
||||
import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing";
|
||||
import { RecentEvents } from "@/components/frontend/dashboard/RecentEvents";
|
||||
import { QuickStats } from "@/components/frontend/dashboard/QuickStats";
|
||||
import { UpcomingMissions } from "@/components/frontend/dashboard/UpcomingMissions";
|
||||
import { ActiveCampaigns } from "@/components/frontend/dashboard/ActiveCampaigns";
|
||||
import { WalletWidget } from "@/components/frontend/dashboard/WalletWidget";
|
||||
import { OpenNegotiations } from "@/components/frontend/dashboard/OpenNegotiations";
|
||||
import { MyTickets } from "@/components/frontend/dashboard/MyTickets";
|
||||
import { RecentNotifications } from "@/components/frontend/dashboard/RecentNotifications";
|
||||
import {
|
||||
DashboardGrid,
|
||||
type DashboardGridWidget,
|
||||
} from "@/components/frontend/dashboard/DashboardGrid";
|
||||
import {
|
||||
normalizeDashboardLayout,
|
||||
widgetSpan,
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
} from "@/lib/dashboard/layout";
|
||||
|
||||
export default async function HomePage() {
|
||||
const payload = await getPayload({ config });
|
||||
const headers = await nextHeaders();
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) return null;
|
||||
if (!user || !isPayloadUser(user)) return null;
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
|
|
@ -207,6 +225,169 @@ export default async function HomePage() {
|
|||
}),
|
||||
]);
|
||||
|
||||
// Personal widgets: wallet, open negotiations, tickets, notifications.
|
||||
// Negotiations are fetched in a second step so seller-side threads can be
|
||||
// matched against the user's own active listing ids (same filter approach as
|
||||
// the market page, which avoids deep `listing.seller` relationship queries).
|
||||
const [walletRes, activeListingsRes, ticketsRes, notificationsRes, currencyConfig] =
|
||||
await Promise.all([
|
||||
payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: {
|
||||
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: userId } }],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => null),
|
||||
payload
|
||||
.find({
|
||||
collection: "market-listings",
|
||||
where: {
|
||||
and: [{ status: { equals: "active" } }, { seller: { equals: userId } }],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => null),
|
||||
payload
|
||||
.find({
|
||||
collection: "tickets",
|
||||
where: { reporter: { equals: userId } },
|
||||
sort: "-updatedAt",
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => ({ docs: [] })),
|
||||
payload
|
||||
.find({
|
||||
collection: "user-notifications",
|
||||
where: { user: { equals: userId } },
|
||||
sort: "-createdAt",
|
||||
limit: 8,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => ({ docs: [] })),
|
||||
getCurrencyConfig(payload),
|
||||
]);
|
||||
|
||||
const walletAccount = walletRes?.docs[0] ?? null;
|
||||
const myActiveListingIds = (activeListingsRes?.docs ?? []).map((listing) => listing.id);
|
||||
|
||||
const negotiationsRes = await payload
|
||||
.find({
|
||||
collection: "market-negotiations",
|
||||
where: {
|
||||
and: [
|
||||
{ status: { equals: "open" } },
|
||||
{
|
||||
or: [{ buyer: { equals: userId } }, { listing: { in: myActiveListingIds } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
sort: "-updatedAt",
|
||||
limit: 6,
|
||||
depth: 2,
|
||||
})
|
||||
.catch(() => ({ docs: [] }));
|
||||
const negotiations = negotiationsRes.docs.slice(0, 5);
|
||||
|
||||
const openTickets = ticketsRes.docs.filter((t) => !CLOSING_STATUSES.includes(t.status));
|
||||
const recentTickets = [
|
||||
...openTickets,
|
||||
...ticketsRes.docs.filter((t) => CLOSING_STATUSES.includes(t.status)),
|
||||
].slice(0, 5);
|
||||
|
||||
const unreadNotifications = notificationsRes.docs.filter((n) => !n.read);
|
||||
const recentNotifications = [
|
||||
...unreadNotifications,
|
||||
...notificationsRes.docs.filter((n) => n.read),
|
||||
].slice(0, 5);
|
||||
|
||||
// Build the dashboard widgets for the customizable grid. Widgets whose data
|
||||
// is empty (negotiations, notifications) or that the viewer cannot see
|
||||
// (intel-gated) are omitted so they never render as empty drag targets.
|
||||
const widgetNodes = new Map<DashboardWidgetId, ReactNode>();
|
||||
if (profile && profileUser) {
|
||||
widgetNodes.set(
|
||||
"profile-summary",
|
||||
<ProfileSummary
|
||||
displayName={profileUser.displayName}
|
||||
username={profileUser.username}
|
||||
rank={rankData}
|
||||
xpLevel={xpLevel}
|
||||
assignments={assignments}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
widgetNodes.set(
|
||||
"mission-briefing",
|
||||
<MissionBriefing
|
||||
mission={mission}
|
||||
heading={upcomingRes.docs[0] ? "Next Mission" : "Last Mission"}
|
||||
/>,
|
||||
);
|
||||
widgetNodes.set("recent-events", <RecentEvents events={events} currentUserId={userId} />);
|
||||
widgetNodes.set(
|
||||
"quick-stats",
|
||||
<QuickStats
|
||||
activeCampaigns={campaignsRes.totalDocs}
|
||||
totalMissions={missionsCountRes.totalDocs}
|
||||
totalStructures={structuresRes.totalDocs}
|
||||
totalFactions={factionsRes.totalDocs}
|
||||
totalMembers={profilesRes.totalDocs}
|
||||
/>,
|
||||
);
|
||||
widgetNodes.set(
|
||||
"wallet",
|
||||
<WalletWidget account={walletAccount} currencyConfig={currencyConfig} />,
|
||||
);
|
||||
if (negotiations.length > 0) {
|
||||
widgetNodes.set(
|
||||
"negotiations",
|
||||
<OpenNegotiations
|
||||
negotiations={negotiations}
|
||||
currentUserId={userId}
|
||||
currencyConfig={currencyConfig}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
widgetNodes.set(
|
||||
"my-tickets",
|
||||
<MyTickets tickets={recentTickets} openCount={openTickets.length} />,
|
||||
);
|
||||
if (recentNotifications.length > 0) {
|
||||
widgetNodes.set(
|
||||
"notifications",
|
||||
<RecentNotifications
|
||||
notifications={recentNotifications}
|
||||
unreadCount={unreadNotifications.length}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (hasIntelligence) {
|
||||
widgetNodes.set(
|
||||
"upcoming-missions",
|
||||
<UpcomingMissions missions={upcomingMissions} />,
|
||||
);
|
||||
widgetNodes.set("active-campaigns", <ActiveCampaigns campaigns={activeCampaigns} />);
|
||||
}
|
||||
|
||||
const savedLayout = normalizeDashboardLayout(user.preferences?.dashboard?.layout);
|
||||
const availableIds = new Set(widgetNodes.keys());
|
||||
const dashboardLayout: DashboardLayout = {
|
||||
main: savedLayout.main.filter((id) => availableIds.has(id)),
|
||||
sidebar: savedLayout.sidebar.filter((id) => availableIds.has(id)),
|
||||
spans: savedLayout.spans,
|
||||
};
|
||||
const widgets = dashboardLayout.main
|
||||
.concat(dashboardLayout.sidebar)
|
||||
.flatMap<DashboardGridWidget>((id) => {
|
||||
const node = widgetNodes.get(id);
|
||||
return node ? [{ id, span: widgetSpan(id), node }] : [];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
@ -216,39 +397,7 @@ export default async function HomePage() {
|
|||
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* ── Main Content (2/3) ──────────────────────────── */}
|
||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{profile && profileUser && (
|
||||
<ProfileSummary
|
||||
displayName={profileUser.displayName}
|
||||
username={profileUser.username}
|
||||
rank={rankData}
|
||||
xpLevel={xpLevel}
|
||||
assignments={assignments}
|
||||
/>
|
||||
)}
|
||||
<MissionBriefing mission={mission} />
|
||||
</div>
|
||||
|
||||
<RecentEvents events={events} currentUserId={userId} />
|
||||
|
||||
<QuickStats
|
||||
activeCampaigns={campaignsRes.totalDocs}
|
||||
totalMissions={missionsCountRes.totalDocs}
|
||||
totalStructures={structuresRes.totalDocs}
|
||||
totalFactions={factionsRes.totalDocs}
|
||||
totalMembers={profilesRes.totalDocs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{hasIntelligence && <UpcomingMissions missions={upcomingMissions} />}
|
||||
{hasIntelligence && <ActiveCampaigns campaigns={activeCampaigns} />}
|
||||
</div>
|
||||
</div>
|
||||
<DashboardGrid layout={dashboardLayout} widgets={widgets} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,6 +330,18 @@ export const Users: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "dashboard",
|
||||
label: "Dashboard",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "layout",
|
||||
label: "Dashboard layout (widget ids per zone, managed by the dashboard)",
|
||||
type: "json",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
454
src/components/frontend/dashboard/DashboardGrid.tsx
Normal file
454
src/components/frontend/dashboard/DashboardGrid.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCorners,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
CheckIcon,
|
||||
GripVerticalIcon,
|
||||
LayoutGridIcon,
|
||||
Maximize2Icon,
|
||||
Minimize2Icon,
|
||||
RotateCcwIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { saveDashboardLayout } from "@/app/(frontend)/actions";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
WIDGET_LABELS,
|
||||
effectiveSpan,
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
type DashboardZone,
|
||||
} from "@/lib/dashboard/layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DashboardGridWidget {
|
||||
readonly id: DashboardWidgetId;
|
||||
readonly node: ReactNode;
|
||||
}
|
||||
|
||||
interface DashboardGridProps {
|
||||
readonly layout: DashboardLayout;
|
||||
readonly widgets: readonly DashboardGridWidget[];
|
||||
}
|
||||
|
||||
const ZONE_MAIN = "zone:main";
|
||||
const ZONE_SIDEBAR = "zone:sidebar";
|
||||
|
||||
function zoneFromContainerId(id: string): DashboardZone | null {
|
||||
if (id === ZONE_MAIN) return "main";
|
||||
if (id === ZONE_SIDEBAR) return "sidebar";
|
||||
return null;
|
||||
}
|
||||
|
||||
function findZone(layout: DashboardLayout, id: string): DashboardZone | null {
|
||||
if (layout.main.includes(id as DashboardWidgetId)) return "main";
|
||||
if (layout.sidebar.includes(id as DashboardWidgetId)) return "sidebar";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next layout for a drop, or null when the drop changes nothing.
|
||||
* Pure and driven only by the layout as of drag end, so there is no state
|
||||
* mutation during the drag itself (mutating layout in onDragOver re-renders
|
||||
* the grid and re-measures droppables mid-drag, which ping-pongs collision
|
||||
* detection and crashes React with "Maximum update depth exceeded").
|
||||
*/
|
||||
function placementFor(
|
||||
layout: DashboardLayout,
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): DashboardLayout | null {
|
||||
const from = findZone(layout, activeId);
|
||||
if (!from) return null;
|
||||
const toContainer = zoneFromContainerId(overId);
|
||||
const to = toContainer ?? findZone(layout, overId);
|
||||
if (!to) return null;
|
||||
|
||||
if (to === from) {
|
||||
if (toContainer) return null;
|
||||
const items = layout[from];
|
||||
const fromIndex = items.indexOf(activeId as DashboardWidgetId);
|
||||
const toIndex = items.indexOf(overId as DashboardWidgetId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||
return { ...layout, [from]: arrayMove(items, fromIndex, toIndex) };
|
||||
}
|
||||
|
||||
const source = layout[from].filter((id) => id !== (activeId as DashboardWidgetId));
|
||||
const target = [...layout[to]];
|
||||
if (toContainer) {
|
||||
target.push(activeId as DashboardWidgetId);
|
||||
} else {
|
||||
const overIndex = target.indexOf(overId as DashboardWidgetId);
|
||||
target.splice(overIndex >= 0 ? overIndex : target.length, 0, activeId as DashboardWidgetId);
|
||||
}
|
||||
return { ...layout, [from]: source, [to]: target };
|
||||
}
|
||||
|
||||
function SortableWidget({
|
||||
id,
|
||||
span,
|
||||
animate,
|
||||
resizable,
|
||||
onToggleSpan,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
span: 1 | 2;
|
||||
animate: boolean;
|
||||
resizable: boolean;
|
||||
onToggleSpan: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
isOver,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(animate && !isDragging ? transform : null),
|
||||
transition: animate ? transition : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex min-w-0 [&>*]:flex-1 [&>*]:min-w-0",
|
||||
span === 2 && "lg:col-span-2",
|
||||
isDragging && "opacity-40",
|
||||
isOver && "outline-2 outline-dashed outline-primary/70",
|
||||
)}
|
||||
>
|
||||
{isOver && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 -top-1.5 z-20 h-1 rounded bg-primary"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{resizable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSpan}
|
||||
title={span === 1 ? "Widen to full width" : "Shrink to half width"}
|
||||
aria-label={span === 1 ? `Widen widget ${id}` : `Shrink widget ${id}`}
|
||||
className="absolute right-8 top-1 z-20 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
{span === 1 ? (
|
||||
<Maximize2Icon className="size-4" />
|
||||
) : (
|
||||
<Minimize2Icon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`Drag widget ${id}`}
|
||||
className="absolute right-1 top-1 z-20 cursor-grab rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaticWidget({ span, children }: { span: 1 | 2; children: ReactNode }) {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 [&>*]:flex-1 [&>*]:min-w-0", span === 2 && "lg:col-span-2")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MainZone({
|
||||
layout,
|
||||
nodesById,
|
||||
editable,
|
||||
onToggleSpan,
|
||||
}: {
|
||||
layout: DashboardLayout;
|
||||
nodesById: Map<DashboardWidgetId, ReactNode>;
|
||||
editable: boolean;
|
||||
onToggleSpan?: (id: DashboardWidgetId) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: ZONE_MAIN });
|
||||
const items = layout.main;
|
||||
|
||||
const content = editable ? (
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
{items.map((id) => (
|
||||
<SortableWidget
|
||||
key={id}
|
||||
id={id}
|
||||
span={effectiveSpan(layout, id)}
|
||||
animate={false}
|
||||
resizable
|
||||
onToggleSpan={() => onToggleSpan?.(id)}
|
||||
>
|
||||
{nodesById.get(id) ?? null}
|
||||
</SortableWidget>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
items.map((id) => (
|
||||
<StaticWidget key={id} span={effectiveSpan(layout, id)}>
|
||||
{nodesById.get(id) ?? null}
|
||||
</StaticWidget>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editable ? setNodeRef : undefined}
|
||||
aria-label="Main widgets"
|
||||
className={cn(
|
||||
"grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2",
|
||||
editable && "rounded-lg",
|
||||
editable && isOver && "ring-1 ring-primary/50",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarZone({
|
||||
layout,
|
||||
nodesById,
|
||||
editable,
|
||||
animate,
|
||||
}: {
|
||||
layout: DashboardLayout;
|
||||
nodesById: Map<DashboardWidgetId, ReactNode>;
|
||||
editable: boolean;
|
||||
animate: boolean;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: ZONE_SIDEBAR });
|
||||
const items = layout.sidebar;
|
||||
|
||||
const content = editable ? (
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items.map((id) => (
|
||||
<SortableWidget
|
||||
key={id}
|
||||
id={id}
|
||||
span={1}
|
||||
animate={animate}
|
||||
resizable={false}
|
||||
onToggleSpan={() => undefined}
|
||||
>
|
||||
{nodesById.get(id) ?? null}
|
||||
</SortableWidget>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
items.map((id) => (
|
||||
<StaticWidget key={id} span={1}>
|
||||
{nodesById.get(id) ?? null}
|
||||
</StaticWidget>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editable ? setNodeRef : undefined}
|
||||
aria-label="Sidebar widgets"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-4",
|
||||
editable && "rounded-lg",
|
||||
editable && isOver && "ring-1 ring-primary/50",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardGrid({ layout: initialLayout, widgets }: DashboardGridProps) {
|
||||
const [layout, setLayout] = useState<DashboardLayout>(initialLayout);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeDrag, setActiveDrag] = useState<{ id: string; width: number } | null>(null);
|
||||
const layoutRef = useRef(initialLayout);
|
||||
layoutRef.current = layout;
|
||||
|
||||
const nodesById = useMemo(
|
||||
() => new Map(widgets.map((widget) => [widget.id, widget.node])),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const sidebarAnimate =
|
||||
!activeDrag || findZone(layout, activeDrag.id) === "sidebar";
|
||||
|
||||
function commitLayout(next: DashboardLayout) {
|
||||
layoutRef.current = next;
|
||||
setLayout(next);
|
||||
setSaving(true);
|
||||
void saveDashboardLayout(next)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Could not save your dashboard layout.");
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Could not save your dashboard layout.",
|
||||
);
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveDrag({
|
||||
id: String(event.active.id),
|
||||
width: event.active.rect.current.initial?.width ?? 288,
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveDrag(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const next = placementFor(layoutRef.current, String(active.id), String(over.id));
|
||||
if (!next) return;
|
||||
commitLayout(next);
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveDrag(null);
|
||||
}
|
||||
|
||||
function toggleSpan(id: DashboardWidgetId) {
|
||||
const current = layoutRef.current;
|
||||
const spans = { ...(current.spans ?? {}) };
|
||||
spans[id] = effectiveSpan(current, id) === 1 ? 2 : 1;
|
||||
commitLayout({ ...current, spans });
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
commitLayout(DEFAULT_DASHBOARD_LAYOUT);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{editing && (
|
||||
<>
|
||||
{saving && (
|
||||
<span aria-live="polite" className="text-xs text-muted-foreground">
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={resetLayout}>
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
Reset layout
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={editing ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setEditing((value) => !value)}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Done
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayoutGridIcon data-icon="inline-start" />
|
||||
Customize
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<div className="flex min-w-0 flex-col gap-4 xl:col-span-2">
|
||||
<MainZone layout={layout} nodesById={nodesById} editable onToggleSpan={toggleSpan} />
|
||||
</div>
|
||||
<SidebarZone
|
||||
layout={layout}
|
||||
nodesById={nodesById}
|
||||
editable
|
||||
animate={sidebarAnimate}
|
||||
/>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeDrag ? (
|
||||
<div
|
||||
style={{ width: activeDrag.width }}
|
||||
className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2.5 shadow-lg"
|
||||
>
|
||||
<GripVerticalIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{WIDGET_LABELS[activeDrag.id as DashboardWidgetId]}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<div className="flex min-w-0 flex-col gap-4 xl:col-span-2">
|
||||
<MainZone layout={layout} nodesById={nodesById} editable={false} />
|
||||
</div>
|
||||
<SidebarZone
|
||||
layout={layout}
|
||||
nodesById={nodesById}
|
||||
editable={false}
|
||||
animate={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drag cards by the handle to rearrange. Changes save automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { markNotificationsRead } from "@/app/(frontend)/notifications/actions";
|
||||
|
||||
interface MarkNotificationReadButtonProps {
|
||||
readonly notificationId: number;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "mark as read" affordance for dashboard notification rows. Calls the
|
||||
* shared mark-read server action (scoped to the signed-in user's own
|
||||
* notifications) and refreshes the server-rendered widget afterwards so the
|
||||
* unread badge and row styling update.
|
||||
*/
|
||||
export function MarkNotificationReadButton({
|
||||
notificationId,
|
||||
title,
|
||||
}: MarkNotificationReadButtonProps) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function markRead() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await markNotificationsRead([notificationId]);
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Failed to update notifications.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update notifications.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="Mark as read"
|
||||
aria-label={`Mark "${title}" as read`}
|
||||
disabled={pending}
|
||||
onClick={() => void markRead()}
|
||||
className="mt-1 shrink-0 rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
79
src/components/frontend/dashboard/MyTickets.tsx
Normal file
79
src/components/frontend/dashboard/MyTickets.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import Link from "next/link";
|
||||
import type { Ticket } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ChevronRightIcon, LifeBuoyIcon } from "lucide-react";
|
||||
import {
|
||||
CLOSING_STATUSES,
|
||||
STATUS_STYLES,
|
||||
statusLabel,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
|
||||
interface MyTicketsProps {
|
||||
readonly tickets: Ticket[];
|
||||
readonly openCount: number;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function MyTickets({ tickets, openCount }: MyTicketsProps) {
|
||||
return (
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoyIcon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>My Tickets</ItemTitle>
|
||||
{openCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{openCount} open</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/helpdesk"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">No tickets yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/helpdesk/${ticket.id}`}
|
||||
className="group flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${STATUS_STYLES[ticket.status]}`}
|
||||
>
|
||||
{statusLabel(ticket.status)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium transition-colors group-hover:text-primary">
|
||||
{ticket.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Updated {timeAgo(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
95
src/components/frontend/dashboard/OpenNegotiations.tsx
Normal file
95
src/components/frontend/dashboard/OpenNegotiations.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import Link from "next/link";
|
||||
import type { MarketListing, MarketNegotiation } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ChevronRightIcon, HandshakeIcon } from "lucide-react";
|
||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||
|
||||
interface OpenNegotiationsProps {
|
||||
readonly negotiations: MarketNegotiation[];
|
||||
readonly currentUserId: number;
|
||||
readonly currencyConfig: CurrencyConfig;
|
||||
}
|
||||
|
||||
function listingOf(negotiation: MarketNegotiation): MarketListing | null {
|
||||
return typeof negotiation.listing === "object" && negotiation.listing
|
||||
? (negotiation.listing as MarketListing)
|
||||
: null;
|
||||
}
|
||||
|
||||
function assetNameOf(listing: MarketListing | null): string {
|
||||
const asset = listing?.asset;
|
||||
return typeof asset === "object" && asset ? asset.name : "Unknown item";
|
||||
}
|
||||
|
||||
function buyerIdOf(negotiation: MarketNegotiation): number | null {
|
||||
return typeof negotiation.buyer === "object" && negotiation.buyer
|
||||
? negotiation.buyer.id
|
||||
: negotiation.buyer;
|
||||
}
|
||||
|
||||
export function OpenNegotiations({
|
||||
negotiations,
|
||||
currentUserId,
|
||||
currencyConfig,
|
||||
}: OpenNegotiationsProps) {
|
||||
// Deliberately hidden when there is nothing on the table (same convention as
|
||||
// NegotiationPanel on the market page).
|
||||
if (negotiations.length === 0) return null;
|
||||
|
||||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
||||
|
||||
return (
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<HandshakeIcon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>Negotiations</ItemTitle>
|
||||
<span className="text-xs text-muted-foreground">{negotiations.length} open</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/logistics/market"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
Market
|
||||
</Link>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
{negotiations.map((negotiation) => {
|
||||
const listing = listingOf(negotiation);
|
||||
const role = buyerIdOf(negotiation) === currentUserId ? "buyer" : "seller";
|
||||
const theirProposal =
|
||||
(role === "buyer" && negotiation.proposedBy === "seller") ||
|
||||
(role === "seller" && negotiation.proposedBy === "buyer");
|
||||
const quantity = negotiation.quantity ?? listing?.quantity ?? 1;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={negotiation.id}
|
||||
href="/logistics/market"
|
||||
className="group flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<HandshakeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium transition-colors group-hover:text-primary">
|
||||
{assetNameOf(listing)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{role === "buyer" ? "Buying" : "Selling"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{fmt(negotiation.amount ?? 0)} each · {quantity}× ·{" "}
|
||||
{theirProposal ? "their offer" : "your offer"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
99
src/components/frontend/dashboard/RecentNotifications.tsx
Normal file
99
src/components/frontend/dashboard/RecentNotifications.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import Link from "next/link";
|
||||
import type { UserNotification } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { MarkNotificationReadButton } from "@/components/frontend/dashboard/MarkNotificationReadButton";
|
||||
|
||||
interface RecentNotificationsProps {
|
||||
readonly notifications: UserNotification[];
|
||||
readonly unreadCount: number;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function RecentNotifications({
|
||||
notifications,
|
||||
unreadCount,
|
||||
}: RecentNotificationsProps) {
|
||||
// Hidden entirely when the inbox is empty; an empty notifications box adds
|
||||
// nothing to the dashboard.
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>Notifications</ItemTitle>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
Inbox
|
||||
</Link>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
{notifications.map((notification) => {
|
||||
const unread = !notification.read;
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="group flex items-start gap-1 rounded-md p-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Link
|
||||
href={notification.link ?? "/notifications"}
|
||||
className="flex min-w-0 flex-1 items-start gap-3"
|
||||
>
|
||||
<span
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${
|
||||
unread ? "bg-sky-400" : "border border-border bg-transparent"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`truncate text-sm ${unread ? "font-medium transition-colors group-hover:text-primary" : "text-muted-foreground"}`}
|
||||
>
|
||||
{notification.title}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{timeAgo(notification.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{notification.message && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{notification.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{unread && (
|
||||
<MarkNotificationReadButton
|
||||
notificationId={notification.id}
|
||||
title={notification.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
53
src/components/frontend/dashboard/WalletWidget.tsx
Normal file
53
src/components/frontend/dashboard/WalletWidget.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import Link from "next/link";
|
||||
import type { BankAccount } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { WalletIcon } from "lucide-react";
|
||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||
import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard";
|
||||
|
||||
interface WalletWidgetProps {
|
||||
readonly account: BankAccount | null;
|
||||
readonly currencyConfig: CurrencyConfig;
|
||||
}
|
||||
|
||||
export function WalletWidget({ account, currencyConfig }: WalletWidgetProps) {
|
||||
if (!account) {
|
||||
return <MyWalletCard />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>Wallet</ItemTitle>
|
||||
</div>
|
||||
<Link
|
||||
href="/logistics/banking"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
Banking
|
||||
</Link>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<Link
|
||||
href={`/logistics/banking/${account.id}`}
|
||||
className="group flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium transition-colors group-hover:text-primary">
|
||||
{account.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Personal account
|
||||
{account.status && account.status !== "open" ? ` · ${account.status}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-xl font-semibold tabular-nums">
|
||||
{formatAmount(account.balance ?? 0, currencyConfig)}
|
||||
</span>
|
||||
</Link>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
136
src/lib/dashboard/layout.ts
Normal file
136
src/lib/dashboard/layout.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Dashboard widget registry and layout vocabulary. Pure module (no Payload
|
||||
* import) so both server pages and client components can use it.
|
||||
*/
|
||||
|
||||
export const DASHBOARD_WIDGET_IDS = [
|
||||
"profile-summary",
|
||||
"mission-briefing",
|
||||
"recent-events",
|
||||
"quick-stats",
|
||||
"wallet",
|
||||
"negotiations",
|
||||
"my-tickets",
|
||||
"notifications",
|
||||
"upcoming-missions",
|
||||
"active-campaigns",
|
||||
] as const;
|
||||
|
||||
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
|
||||
|
||||
export type DashboardZone = "main" | "sidebar";
|
||||
|
||||
export interface DashboardLayout {
|
||||
main: DashboardWidgetId[];
|
||||
sidebar: DashboardWidgetId[];
|
||||
/** Per-widget main-grid span overrides (1 = half width, 2 = full width). */
|
||||
spans?: Partial<Record<DashboardWidgetId, 1 | 2>>;
|
||||
}
|
||||
|
||||
export const WIDGET_LABELS: Record<DashboardWidgetId, string> = {
|
||||
"profile-summary": "Service Summary",
|
||||
"mission-briefing": "Mission Briefing",
|
||||
"recent-events": "Recent Events",
|
||||
"quick-stats": "Quick Stats",
|
||||
wallet: "Wallet",
|
||||
negotiations: "Negotiations",
|
||||
"my-tickets": "My Tickets",
|
||||
notifications: "Notifications",
|
||||
"upcoming-missions": "Upcoming Missions",
|
||||
"active-campaigns": "Active Campaigns",
|
||||
};
|
||||
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
main: ["profile-summary", "mission-briefing", "recent-events", "quick-stats"],
|
||||
sidebar: [
|
||||
"wallet",
|
||||
"negotiations",
|
||||
"my-tickets",
|
||||
"notifications",
|
||||
"upcoming-missions",
|
||||
"active-campaigns",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Column span of each widget inside the main zone's two-column grid. The
|
||||
* sidebar ignores spans (single column). New widgets must be added here;
|
||||
* normalizer defaults missing entries to 1.
|
||||
*/
|
||||
export const WIDGET_SPANS: Partial<Record<DashboardWidgetId, 1 | 2>> = {
|
||||
"recent-events": 2,
|
||||
"quick-stats": 2,
|
||||
};
|
||||
|
||||
export function widgetSpan(id: DashboardWidgetId): 1 | 2 {
|
||||
return WIDGET_SPANS[id] ?? 1;
|
||||
}
|
||||
|
||||
export function effectiveSpan(layout: DashboardLayout, id: DashboardWidgetId): 1 | 2 {
|
||||
return layout.spans?.[id] ?? widgetSpan(id);
|
||||
}
|
||||
|
||||
export function defaultZoneOf(id: DashboardWidgetId): DashboardZone {
|
||||
return DEFAULT_DASHBOARD_LAYOUT.sidebar.includes(id) ? "sidebar" : "main";
|
||||
}
|
||||
|
||||
const WIDGET_ID_STRINGS: readonly string[] = DASHBOARD_WIDGET_IDS;
|
||||
|
||||
/**
|
||||
* Sanitizes an arbitrary stored layout into a valid DashboardLayout: drops
|
||||
* unknown/duplicate ids, keeps known ones in the user's order, and appends
|
||||
* any known widget the saved layout does not mention to its default zone, so
|
||||
* newly added widgets appear without a migration.
|
||||
*/
|
||||
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
|
||||
const seen = new Set<DashboardWidgetId>();
|
||||
const pickIds = (value: unknown): DashboardWidgetId[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids: DashboardWidgetId[] = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string" || !WIDGET_ID_STRINGS.includes(entry)) continue;
|
||||
const id = entry as DashboardWidgetId;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
const source = (raw ?? null) as {
|
||||
main?: unknown;
|
||||
sidebar?: unknown;
|
||||
spans?: unknown;
|
||||
} | null;
|
||||
const main = pickIds(source?.main);
|
||||
const sidebar = pickIds(source?.sidebar);
|
||||
|
||||
let spans: Partial<Record<DashboardWidgetId, 1 | 2>> | undefined;
|
||||
if (source?.spans && typeof source.spans === "object" && !Array.isArray(source.spans)) {
|
||||
const cleaned: Partial<Record<DashboardWidgetId, 1 | 2>> = {};
|
||||
for (const [key, value] of Object.entries(source.spans as Record<string, unknown>)) {
|
||||
if (!WIDGET_ID_STRINGS.includes(key)) continue;
|
||||
if (value !== 1 && value !== 2) continue;
|
||||
cleaned[key as DashboardWidgetId] = value;
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) spans = cleaned;
|
||||
}
|
||||
|
||||
for (const id of DASHBOARD_WIDGET_IDS) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
if (defaultZoneOf(id) === "main") main.push(id);
|
||||
else sidebar.push(id);
|
||||
}
|
||||
|
||||
return { main, sidebar, spans };
|
||||
}
|
||||
|
||||
export function zoneIdOfWidget(
|
||||
layout: DashboardLayout,
|
||||
id: DashboardWidgetId,
|
||||
): DashboardZone | null {
|
||||
if (layout.main.includes(id)) return "main";
|
||||
if (layout.sidebar.includes(id)) return "sidebar";
|
||||
return null;
|
||||
}
|
||||
24494
src/migrations/20260910_222518_add_dashboard_layout_preference.json
Normal file
24494
src/migrations/20260910_222518_add_dashboard_layout_preference.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "users" ADD COLUMN "preferences_dashboard_layout" jsonb;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "users" DROP COLUMN "preferences_dashboard_layout";`)
|
||||
}
|
||||
|
|
@ -50,6 +50,8 @@ import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908
|
|||
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
|
||||
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
|
||||
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
|
||||
import * as migration_20260910_221018_make_missions_codename_optional from './20260910_221018_make_missions_codename_optional';
|
||||
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -310,6 +312,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260909_223955_add_voice_script_draft_status.up,
|
||||
down: migration_20260909_223955_add_voice_script_draft_status.down,
|
||||
name: '20260909_223955_add_voice_script_draft_status'
|
||||
name: '20260909_223955_add_voice_script_draft_status',
|
||||
},
|
||||
{
|
||||
up: migration_20260910_221018_make_missions_codename_optional.up,
|
||||
down: migration_20260910_221018_make_missions_codename_optional.down,
|
||||
name: '20260910_221018_make_missions_codename_optional',
|
||||
},
|
||||
{
|
||||
up: migration_20260910_222518_add_dashboard_layout_preference.up,
|
||||
down: migration_20260910_222518_add_dashboard_layout_preference.down,
|
||||
name: '20260910_222518_add_dashboard_layout_preference'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1392,6 +1392,17 @@ export interface User {
|
|||
)[]
|
||||
| null;
|
||||
};
|
||||
dashboard?: {
|
||||
layout?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
};
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -2388,9 +2399,9 @@ export interface Mission {
|
|||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
* A "code name" provides a unique way to reference this mission beyond just the mission's name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK".
|
||||
* A "code name" provides a unique way to reference this mission beyond just the mission's name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK". Optional, but must be unique when set.
|
||||
*/
|
||||
codeName: string;
|
||||
codeName?: string | null;
|
||||
/**
|
||||
* A summary of the mission. This is not the same as a briefing; instead, it describes the mission's theme and concept to the unit as a whole.
|
||||
*/
|
||||
|
|
@ -4433,6 +4444,11 @@ export interface UsersSelect<T extends boolean = true> {
|
|||
enabled?: T;
|
||||
mutedTypes?: T;
|
||||
};
|
||||
dashboard?:
|
||||
| T
|
||||
| {
|
||||
layout?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
Loading…
Reference in a new issue