Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
81 files changed
+8024
-113
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listBreaks } from "@/services/staff/staff.service";
|
||||
import { BreaksList } from "@/features/staff/components/breaks-list";
|
||||
|
||||
export const metadata: Metadata = { title: "Breaks" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StaffBreaksPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const breaks = await listBreaks(prisma, staff.id);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Breaks" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<BreaksList
|
||||
breaks={breaks.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
startMinutes: b.startMinutes,
|
||||
endMinutes: b.endMinutes,
|
||||
days: b.days.map((d) => d.dayOfWeek),
|
||||
}))}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getNotificationPreferences } from "@/services/staff/staff.service";
|
||||
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StaffNotificationsPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const cells = await getNotificationPreferences(prisma, staff.userId);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<NotificationPreferencesForm initial={cells} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
|
||||
{ href: "/staff/settings/breaks", label: "Breaks", icon: CalendarClock },
|
||||
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
|
||||
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
|
||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
||||
] as const;
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Settings" };
|
||||
|
||||
export default async function StaffSettingsPage() {
|
||||
@@ -34,9 +41,17 @@ export default async function StaffSettingsPage() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" className="w-full" disabled>
|
||||
Working Hours, Breaks & Time Off — Phase 3
|
||||
</Button>
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||
<li key={href}>
|
||||
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
|
||||
<Icon className="h-5 w-5 text-muted" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getStaffProfileByUserId } from "@/services/staff/staff.service";
|
||||
import { ProfileForm } from "@/features/staff/components/profile-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Profile" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StaffProfilePage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const profile = await getStaffProfileByUserId(prisma, staff.userId);
|
||||
if (!profile) redirect("/home");
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Profile" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<ProfileForm
|
||||
initial={{
|
||||
name: profile.user.name ?? "",
|
||||
phone: profile.user.phone ?? "",
|
||||
image: profile.user.image ?? "",
|
||||
bio: profile.bio ?? "",
|
||||
isBookableOnline: profile.isBookableOnline,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">
|
||||
Services You Offer
|
||||
</p>
|
||||
{profile.services.length === 0 ? (
|
||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No services assigned yet. Ask the owner to add you to a service.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
{profile.services.map((s) => (
|
||||
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
|
||||
{s.service.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted mt-2 text-xs">
|
||||
Only the business owner can change which services you offer.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listTimeOff } from "@/services/staff/staff.service";
|
||||
import { TimeOffList } from "@/features/staff/components/time-off-list";
|
||||
|
||||
export const metadata: Metadata = { title: "Time Off" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function toCalendarDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function StaffTimeOffPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const { upcoming, past } = await listTimeOff(prisma, staff.id);
|
||||
// Whole-day rows only — partial-day "Block Time" entries are managed from the calendar (see block-time-button.tsx).
|
||||
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
|
||||
|
||||
const toEditable = (rows: typeof upcoming) =>
|
||||
wholeDay(rows).map((t) => ({
|
||||
id: t.id,
|
||||
startDate: toCalendarDate(t.startDate),
|
||||
endDate: toCalendarDate(t.endDate),
|
||||
reason: t.reason,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Time Off" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<TimeOffList upcoming={toEditable(upcoming)} past={toEditable(past)} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getWorkingHours } from "@/services/staff/staff.service";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Working Hours" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function formatMinutes(m: number): string {
|
||||
const h = Math.floor(m / 60);
|
||||
const min = m % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function StaffWorkingHoursPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const [workingHours, businessHours] = await Promise.all([
|
||||
getWorkingHours(prisma, staff.id),
|
||||
prisma.businessHours.findMany({ where: { businessId: staff.businessId } }),
|
||||
]);
|
||||
|
||||
const businessHoursLabel = Object.fromEntries(
|
||||
DAY_ORDER.map((day) => {
|
||||
const hours = businessHours.find((h) => h.dayOfWeek === day);
|
||||
const label = !hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null ? "Closed" : `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
|
||||
return [day, label];
|
||||
}),
|
||||
) as Record<Weekday, string>;
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Working Hours" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<WorkingHoursForm initialRows={workingHours} businessHoursLabel={businessHoursLabel} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user