UI Updates

Updated some pages pages to be more consistent.
This commit is contained in:
Stefan Lazic committed 2026-08-06 19:21:31 +01:00
1 parent 90ec663e6e
commit a702320d4e
24 files changed
+901 -616

No files matched your search

+50 -57
View File
@@ -6,6 +6,7 @@ import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
@@ -19,11 +20,15 @@ import {
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { cn } from "@/lib/utils/cn";
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "All-Staff Calendar" };
@@ -64,6 +69,8 @@ export default async function OwnerCalendarPage({
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(context.businessTimezone);
const date: CalendarDate = dateParam ?? today;
@@ -173,23 +180,42 @@ export default async function OwnerCalendarPage({
};
});
// Every active staff member gets a filter card (not just staffToRender,
// which narrows to the current selection) — the filter row needs to show
// everyone to filter *between*.
const staffFilterOptions: StaffFilterOption[] = allStaff.map((s) => {
const workingHours = workingHoursAll.find((h) => h.staffId === s.id);
const hoursLabel =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? formatRange(workingHours.startMinutes, workingHours.endMinutes)
: null;
return { id: s.id, name: s.user.name ?? "Staff", avatarUrl: s.user.image, hoursLabel };
});
const staffFilterHrefFor = (staffId: string | null) =>
`/owner/calendar?date=${date}&view=${view}${staffId ? `&staffId=${staffId}` : ""}`;
return (
<div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12">
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
<div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm">
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
</div>
<TopNav
title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm">
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
}
/>
<DateStrip
selectedDate={date}
@@ -199,49 +225,16 @@ export default async function OwnerCalendarPage({
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
/>
<div className="mt-4 flex flex-wrap items-center gap-2 px-6">
<Link
href={`/owner/calendar?date=${date}&view=${view}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
All Staff
</Link>
{allStaff.map((s) => (
<Link
key={s.id}
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{s.user.name}
</Link>
))}
<div className="mt-4 px-6">
<ViewSwitch
view={view}
dayHref={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
agendaHref={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
/>
</div>
<div className="mt-4 flex gap-2 px-6">
<Link
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Day
</Link>
<Link
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Agenda
</Link>
<div className="mt-4">
<StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
</div>
<div className="mt-4">
+129 -117
View File
@@ -22,12 +22,15 @@ import {
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Business Dashboard" };
export const dynamic = "force-dynamic";
@@ -105,130 +108,139 @@ export default async function OwnerDashboardPage() {
if (!context) redirect("/staff");
const today = todayIn(context.businessTimezone);
const summary = await getDashboardSummary(prisma, {
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
});
const [summary, unreadNotificationCount] = await Promise.all([
getDashboardSummary(prisma, {
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
}),
getUnreadNotificationCount(prisma, session.user.id),
]);
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Manage</h1>
<div className="pb-12">
<TopNav
title="Manage"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-6 px-6 pt-6">
<p className="text-muted text-sm">{context.businessName} — today&apos;s overview</p>
</div>
{summary.warnings.length > 0 && (
<section className="space-y-2">
{summary.warnings.map((w, i) => (
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
</div>
))}
</section>
)}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
</section>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
<StatTile
icon={TrendingUp}
label="Estimated occupancy"
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
/>
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
</h2>
{summary.staffWorkingToday.length === 0 ? (
<p className="text-muted text-sm">No one is scheduled to work today.</p>
) : (
<ul className="space-y-2">
{summary.staffWorkingToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
</li>
))}
</ul>
)}
{summary.staffOnTimeOffToday.length > 0 && (
<ul className="border-line mt-3 space-y-2 border-t pt-3">
{summary.staffOnTimeOffToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">
{s.reason ?? "Time off"}
</span>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
</h2>
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
{summary.popularServices.length === 0 ? (
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
) : (
<ul className="space-y-2">
{summary.popularServices.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted">{s.count}</span>
</li>
))}
</ul>
)}
</Card>
</section>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
</h2>
{summary.recentLoyaltyActivity.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<ul className="space-y-2">
{summary.recentLoyaltyActivity.map((t) => (
<li key={t.id} className="text-muted text-xs">
<span className="text-ink font-bold">
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
</span>{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
{summary.warnings.length > 0 && (
<section className="space-y-2">
{summary.warnings.map((w, i) => (
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
</div>
))}
</ul>
</section>
)}
</Card>
<div className="space-y-5">
{MANAGE_SECTIONS.map((section) => (
<ManageSection key={section.title} {...section} />
))}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
</section>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
<StatTile
icon={TrendingUp}
label="Estimated occupancy"
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
/>
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
</h2>
{summary.staffWorkingToday.length === 0 ? (
<p className="text-muted text-sm">No one is scheduled to work today.</p>
) : (
<ul className="space-y-2">
{summary.staffWorkingToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
</li>
))}
</ul>
)}
{summary.staffOnTimeOffToday.length > 0 && (
<ul className="border-line mt-3 space-y-2 border-t pt-3">
{summary.staffOnTimeOffToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">
{s.reason ?? "Time off"}
</span>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
</h2>
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
{summary.popularServices.length === 0 ? (
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
) : (
<ul className="space-y-2">
{summary.popularServices.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted">{s.count}</span>
</li>
))}
</ul>
)}
</Card>
</section>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
</h2>
{summary.recentLoyaltyActivity.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<ul className="space-y-2">
{summary.recentLoyaltyActivity.map((t) => (
<li key={t.id} className="text-muted text-xs">
<span className="text-ink font-bold">
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
</span>{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
))}
</ul>
)}
</Card>
<div className="space-y-5">
{MANAGE_SECTIONS.map((section) => (
<ManageSection key={section.title} {...section} />
))}
</div>
</div>
</div>
);