UI Updates
Updated some pages pages to be more consistent.
This commit is contained in:
24 files changed
+901
-616
No files matched your search
@@ -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
@@ -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'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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user