142 lines
7.3 KiB
TypeScript
142 lines
7.3 KiB
TypeScript
import type { LoaderFunctionArgs } from "react-router";
|
|
import { useLoaderData } from "react-router";
|
|
import prisma from "../db.server";
|
|
import { capsFor } from "../engine/capacity";
|
|
import { hoursFor } from "../engine/availability";
|
|
import { addDays, localParts } from "../engine/time";
|
|
import { METHODS } from "../engine/types";
|
|
import { adminContext } from "../lib/admin.server";
|
|
import { METHOD_LABEL, niceDate } from "../lib/format";
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const { cfg, features } = await adminContext(request);
|
|
const shopId = cfg.shop.id;
|
|
const today = localParts(Date.now(), cfg.shop.timezone).date;
|
|
const end = addDays(today, 13);
|
|
const [todayCount, weekCount, review, waiting, counters, monthCount] = await Promise.all([
|
|
prisma.booking.count({ where: { shopId, date: today, status: "confirmed" } }),
|
|
prisma.booking.count({ where: { shopId, date: { gt: today, lte: addDays(today, 7) }, status: "confirmed" } }),
|
|
prisma.booking.count({ where: { shopId, OR: [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }] } }),
|
|
prisma.waitlistEntry.count({ where: { shopId, offeredAt: null, date: { gte: today } } }),
|
|
prisma.capacityCounter.findMany({ where: { shopId, slot: "*", date: { gte: today, lte: end } } }),
|
|
prisma.booking.count({ where: { shopId, createdAt: { gte: new Date(`${today.slice(0, 7)}-01T00:00:00Z`) } } }),
|
|
]);
|
|
|
|
const rows = cfg.locations
|
|
.filter((l) => l.enabled)
|
|
.flatMap((l) =>
|
|
METHODS.filter((m) => l.methods[m]?.enabled && m !== "shipping").map((m) => ({
|
|
key: `${l.id}|${m}`,
|
|
label: `${l.name} · ${METHOD_LABEL[m]}`,
|
|
days: Array.from({ length: 14 }, (_, i) => {
|
|
const date = addDays(today, i);
|
|
const open = hoursFor(l, m, date).hours.length > 0;
|
|
const cap = capsFor(l, m, date, "00:00-00:00", features.peakDays ? cfg.peakDays : []).dayCap;
|
|
const used = counters.find((c) => c.locationId === l.id && c.method === m && c.date === date)?.count ?? 0;
|
|
const peak = cfg.peakDays.find((p) => p.dates.includes(date) && (!p.locationIds?.length || p.locationIds.includes(l.id)));
|
|
return { date, open, cap, used, peak: peak?.name ?? null };
|
|
}),
|
|
})),
|
|
);
|
|
const peaks = cfg.peakDays
|
|
.flatMap((p) => p.dates.filter((d) => d >= today).map((d) => ({ name: p.name, date: d, cutoff: p.cutoff })))
|
|
.sort((a, b) => (a.date < b.date ? -1 : 1))
|
|
.slice(0, 3);
|
|
const setup = {
|
|
anyLocation: cfg.locations.some((l) => l.enabled),
|
|
feeProduct: !!cfg.shop.feeProductId,
|
|
};
|
|
return {
|
|
today, todayCount, weekCount, review, waiting, rows, peaks, setup,
|
|
plan: cfg.shop.plan, monthCount, monthlyLimit: features.monthlyBookings,
|
|
themeEditor: `https://${cfg.shop.domain}/admin/themes/current/editor?context=apps&activateAppId=${process.env.SHOPIFY_API_KEY}/datewise-embed`,
|
|
};
|
|
};
|
|
|
|
export default function Dashboard() {
|
|
const d = useLoaderData<typeof loader>();
|
|
const overLimit = d.monthlyLimit != null && d.monthCount >= d.monthlyLimit;
|
|
return (
|
|
<s-page heading={`Today, ${niceDate(d.today, true)}`}>
|
|
<s-button slot="primary-action" href="/app/bookings">View bookings</s-button>
|
|
{!d.setup.anyLocation && (
|
|
<s-banner tone="warning" heading="Finish setup to start taking bookings">
|
|
<s-paragraph>
|
|
1. Review hours and turn on a location in <s-link href="/app/locations">Locations & hours</s-link>. 2. Add the Datewise
|
|
block to your cart page and switch on the app embed in the <s-link href={d.themeEditor} target="_blank">theme editor</s-link>.
|
|
3. Turn on the checkout guard in <s-link href="/app/settings">Settings</s-link>.
|
|
</s-paragraph>
|
|
</s-banner>
|
|
)}
|
|
{overLimit && (
|
|
<s-banner tone="warning" heading="You've reached this month's Starter limit">
|
|
<s-paragraph>
|
|
New orders are still recorded, so no customer is turned away. <s-link href="/app/settings#plan">Upgrade</s-link> to keep reminders and capacity planning running.
|
|
</s-paragraph>
|
|
</s-banner>
|
|
)}
|
|
<s-section>
|
|
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap="base">
|
|
<Kpi label="Bookings today" value={d.todayCount} />
|
|
<Kpi label="Next 7 days" value={d.weekCount} />
|
|
<Kpi label="Needs review" value={d.review} href="/app/bookings?status=review" alert={d.review > 0} />
|
|
<Kpi label="On waitlist" value={d.waiting} href="/app/peak" />
|
|
</s-grid>
|
|
</s-section>
|
|
<s-section heading="Capacity, next 14 days">
|
|
{d.rows.length === 0 && <s-paragraph>No pickup or delivery locations are switched on yet.</s-paragraph>}
|
|
{d.rows.map((r) => (
|
|
<s-stack key={r.key} gap="small-200">
|
|
<s-text type="strong">{r.label}</s-text>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(14, minmax(28px, 1fr))", gap: 4, overflowX: "auto", alignItems: "end" }}>
|
|
{r.days.map((x) => {
|
|
const pct = x.cap ? Math.min(100, Math.round((100 * x.used) / x.cap)) : 0;
|
|
const full = x.cap != null && x.used >= x.cap;
|
|
return (
|
|
<div key={x.date} title={`${niceDate(x.date)}: ${x.open ? `${x.used}${x.cap != null ? ` of ${x.cap}` : ""} booked` : "closed"}${x.peak ? ` · ${x.peak}` : ""}`} style={{ display: "grid", gap: 2, textAlign: "center", fontSize: 11 }}>
|
|
<div style={{ height: 70, background: "var(--s-color-bg-surface-secondary, #f1f1f1)", borderRadius: 6, position: "relative", overflow: "hidden" }}>
|
|
{x.open && <div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: `${x.cap ? pct : Math.min(100, x.used * 8)}%`, background: full ? "#c5280c" : x.peak ? "#b98900" : "#008060" }} />}
|
|
</div>
|
|
<span>{x.open ? (x.cap != null ? `${x.used}/${x.cap}` : x.used) : "—"}</span>
|
|
<span style={{ opacity: 0.7 }}>{x.date.slice(8)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</s-stack>
|
|
))}
|
|
</s-section>
|
|
<s-section heading="Upcoming festivals">
|
|
{d.peaks.length === 0 ? (
|
|
<s-paragraph>
|
|
No peak days set. Add Diwali, Mother's Day or Valentine's in <s-link href="/app/peak">Rush control</s-link> to cap orders, close earlier and open a waitlist.
|
|
</s-paragraph>
|
|
) : (
|
|
<s-unordered-list>
|
|
{d.peaks.map((p) => (
|
|
<s-list-item key={p.name + p.date}>
|
|
{p.name}, {niceDate(p.date, true)}
|
|
{p.cutoff ? `. Orders close ${p.cutoff.daysBefore} day(s) before at ${p.cutoff.time}.` : "."}
|
|
</s-list-item>
|
|
))}
|
|
</s-unordered-list>
|
|
)}
|
|
</s-section>
|
|
</s-page>
|
|
);
|
|
}
|
|
|
|
function Kpi({ label, value, href, alert }: { label: string; value: number; href?: string; alert?: boolean }) {
|
|
const body = (
|
|
<s-box padding="base" border="base" borderRadius="base" background="base">
|
|
<s-stack gap="small-200">
|
|
<s-text color="subdued">{label}</s-text>
|
|
<s-heading>
|
|
<span style={{ fontSize: 28, color: alert ? "#c5280c" : undefined }}>{value}</span>
|
|
</s-heading>
|
|
</s-stack>
|
|
</s-box>
|
|
);
|
|
return href ? <s-clickable href={href}>{body}</s-clickable> : body;
|
|
}
|