feat(admin): add v2 layout with AdminShell and offline dispatcher
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline mutation dispatcher.
|
||||||
|
*
|
||||||
|
* The v2 mobile admin (PR 3) introduces an IndexedDB-backed mutation
|
||||||
|
* queue (src/lib/offline/queue.ts) and a sync engine
|
||||||
|
* (src/lib/offline/sync.ts) that replays queued actions when the
|
||||||
|
* device comes back online. The client side enqueues an action with a
|
||||||
|
* name + JSON payload + idempotency key (clientActionId), then calls
|
||||||
|
* `syncPending` which invokes this dispatcher for each pending action.
|
||||||
|
*
|
||||||
|
* The dispatcher is the single server-side bridge between the offline
|
||||||
|
* queue and our SECURITY DEFINER server actions. To add a new offline-
|
||||||
|
* capable action, append an entry to `listClientActions()` with:
|
||||||
|
* - a stable name (kebab- or camelCase, the client enqueues by name)
|
||||||
|
* - a handler that accepts the JSON payload and returns
|
||||||
|
* { success: true } | { success: false; error: string }
|
||||||
|
*
|
||||||
|
* The action names referenced by the plan (markOrderReady,
|
||||||
|
* markOrderPickedUp, updateStopStatus, adjustProductStock) do not yet
|
||||||
|
* exist as server actions in the v1 codebase. They're added in
|
||||||
|
* follow-up PRs. For now the dispatcher returns `{ ok: false, error:
|
||||||
|
* "Unknown action" }` for them, which surfaces in the OfflineBanner
|
||||||
|
* conflict count. This keeps the structure in place without coupling
|
||||||
|
* PR 3 to a server-side action surface it doesn't own.
|
||||||
|
*
|
||||||
|
* Idempotency: each enqueue carries a clientActionId (uuid v4). Real
|
||||||
|
* idempotency requires a dedup table on the server — that's also a
|
||||||
|
* follow-up. Until then, replays of the same action will re-execute,
|
||||||
|
* but last-write-wins on the underlying tables is safe for the
|
||||||
|
* mutations we ship (status flips, stock adjust).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ClientAction {
|
||||||
|
name: string;
|
||||||
|
// The handler signature is intentionally loose — payload shapes vary
|
||||||
|
// per action. The dispatcher in `sync.ts` invokes this with the raw
|
||||||
|
// payload from IndexedDB.
|
||||||
|
handler: (payload: unknown, clientActionId: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listClientActions(): Promise<ClientAction[]> {
|
||||||
|
// Wire real server actions here as they land:
|
||||||
|
// { name: "markOrderReady", handler: markOrderReady },
|
||||||
|
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
||||||
|
// { name: "updateStopStatus", handler: updateStopStatus },
|
||||||
|
// { name: "adjustProductStock", handler: adjustProductStock },
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchClientAction(
|
||||||
|
actionName: string,
|
||||||
|
payload: unknown,
|
||||||
|
clientActionId: string,
|
||||||
|
): Promise<
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; conflict: string }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
const actions = await listClientActions();
|
||||||
|
const action = actions.find((a) => a.name === actionName);
|
||||||
|
if (!action) {
|
||||||
|
return { ok: false, error: "Unknown action" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await action.handler(payload, clientActionId);
|
||||||
|
if (result.success === false) {
|
||||||
|
const message = result.error ?? "Action failed";
|
||||||
|
if (/conflict|stale/i.test(message)) {
|
||||||
|
return { ok: false, conflict: message };
|
||||||
|
}
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/v2/orders");
|
||||||
|
revalidatePath("/admin/v2/orders/[id]", "page");
|
||||||
|
revalidatePath("/admin/v2/stops");
|
||||||
|
revalidatePath("/admin/v2/products");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { listBrandsForAdmin } from "@/actions/brands";
|
||||||
|
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||||
|
import { ToastProvider } from "@/components/admin/Toast";
|
||||||
|
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||||
|
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||||
|
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{children}
|
||||||
|
<ToastContainer />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminV2Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
let adminUser = null;
|
||||||
|
try {
|
||||||
|
adminUser = await getAdminUser();
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
<ToastProviderWrapper>
|
||||||
|
<AdminAccessDenied message="Failed to verify authentication." />
|
||||||
|
</ToastProviderWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
return (
|
||||||
|
<ToastProviderWrapper>
|
||||||
|
<AdminAccessDenied message="You don't have admin access." />
|
||||||
|
</ToastProviderWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.must_change_password) {
|
||||||
|
redirect("/change-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve brand + brands + addons with graceful fallbacks. A failure in
|
||||||
|
// any of these must not crash the whole shell — the AdminShell renders
|
||||||
|
// empty state (no brands) rather than a 500.
|
||||||
|
let activeBrandId: string | null = null;
|
||||||
|
try {
|
||||||
|
activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/v2/layout] getActiveBrandId failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||||
|
try {
|
||||||
|
brands = await listBrandsForAdmin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/v2/layout] listBrandsForAdmin failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabledAddons: Record<string, boolean> = {};
|
||||||
|
if (activeBrandId) {
|
||||||
|
try {
|
||||||
|
enabledAddons = await getEnabledAddons(activeBrandId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/v2/layout] getEnabledAddons failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProviderWrapper>
|
||||||
|
<AdminShell
|
||||||
|
userRole={adminUser.role}
|
||||||
|
brandIds={adminUser.brand_ids}
|
||||||
|
activeBrandId={activeBrandId}
|
||||||
|
brands={brands}
|
||||||
|
enabledAddons={enabledAddons}
|
||||||
|
>
|
||||||
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
<RouteAnnouncer />
|
||||||
|
</AdminShell>
|
||||||
|
</ToastProviderWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user