PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

@@ -0,0 +1,277 @@
import type { Metadata } from "next";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } 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 { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Calendar" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { date: dateParam, view: viewParam } = await searchParams;
const today = todayIn(staff.business.timezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const [workingHours, breakDays, timeOffRows, appointments] = await Promise.all([
prisma.workingHours.findUnique({
where: { staffId_dayOfWeek: { staffId: staff.id, dayOfWeek: weekday } },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staffId: staff.id } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staffId: staff.id,
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: date,
toDate: date,
businessTimezone: staff.business.timezone,
}),
]);
const wholeDayOff = timeOffRows.find((t) => t.startMinutes === null);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: staff.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const workingWindow =
workingHours &&
!workingHours.isOff &&
workingHours.startMinutes !== null &&
workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDays.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffRows
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const calendarAppointments: CalendarAppointment[] = appointments.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, staff.business.timezone),
endMinutes: minutesFromMidnight(a.endAt, staff.business.timezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
const currentTimeMinutes =
date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null;
const columns: CalendarColumn[] = [
{
id: staff.id,
name: staff.user.name ?? "You",
avatarUrl: staff.user.image,
hoursLabel: workingWindow ? formatRange(workingWindow.startMinutes, workingWindow.endMinutes) : null,
wholeDayOff: false,
appointments: calendarAppointments,
blocks,
workingWindow,
},
];
// Desktop-only: a "Right Now" panel beside the calendar so a wide screen
// does more than center the mobile layout (see docs/phase-5-summary.md,
// "Responsive Design"). Only meaningful when viewing today.
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
return (
<div className="pb-6">
<TopNav
title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2">
<BlockTimeButton date={date} />
<Button asChild size="sm">
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
}
/>
<DateStrip
selectedDate={date}
businessTimezone={staff.business.timezone}
basePath="/staff"
view={view}
/>
<div className="mt-4 px-6">
<ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
</div>
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
<div className="lg:flex-1">
{wholeDayOff ? (
<EmptyState
icon={CalendarX2}
title="You're off this day"
description={wholeDayOff.reason ?? "Approved time off."}
/>
) : view === "day" ? (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
) : calendarAppointments.length === 0 ? (
<div className="px-6 lg:px-0">
<EmptyState
icon={CalendarX2}
title="No appointments today"
description="Your schedule is clear for this day."
/>
</div>
) : (
<div className="space-y-4 px-6 lg:px-0">
{calendarAppointments.map((a) => (
<AppointmentCard
key={a.id}
status={a.status}
serviceName={a.serviceNames}
person={{ id: a.id, name: a.customerName }}
personLabel="for"
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
hasNotes={a.hasNotes}
hasAvailableReward={a.hasAvailableReward}
actions={
<Link
href={`/staff/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
))}
</div>
)}
</div>
{/* Desktop-only "Right Now" panel — a wide screen shows the current
appointment alongside the calendar instead of requiring a
separate tab, since there's room for both at once. */}
<aside className="hidden shrink-0 lg:block lg:w-80">
<div className="rounded-md border-line sticky top-6 border bg-white p-5">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Right Now
</span>
{rightNow ? (
<div className="mt-3 space-y-3">
<AppointmentCard
status={STATUS_MAP[rightNow.status] ?? "confirmed"}
serviceName={rightNow.services.map((s) => s.service.name).join(" + ")}
person={{ id: rightNow.customerId, name: `${rightNow.customer.firstName} ${rightNow.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(rightNow.startAt, staff.business.timezone),
minutesFromMidnight(rightNow.endAt, staff.business.timezone),
)}
/>
<Link
href={`/staff/appointments/${rightNow.id}`}
className="border-line block rounded-lg border px-4 py-2 text-center text-xs font-bold tracking-widest uppercase"
>
Open Details
</Link>
</div>
) : (
<p className="text-muted mt-3 text-sm">Nothing in progress or up next right now.</p>
)}
<Link
href="/staff/current"
className="text-accent mt-4 block text-center text-xs font-bold tracking-widest uppercase underline"
>
Full Current Appointment View
</Link>
</div>
</aside>
</div>
</div>
);
}
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}