diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f99ddee..b7e5ebb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,16 +15,18 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+
- uses: actions/setup-node@v4
with:
node-version: "25"
- cache: npm
+ cache: pnpm
- name: Install dependencies
- run: npm ci
+ run: pnpm install --frozen-lockfile
- name: Lint
- run: npm run lint
+ run: pnpm lint
- name: Unit tests
- run: npm run test:ci
+ run: pnpm test:ci
diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx
index 8753ffa..db58ad5 100644
--- a/app/(authenticated)/layout.tsx
+++ b/app/(authenticated)/layout.tsx
@@ -5,34 +5,36 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AppSidebar } from "@/components/app-sidebar";
+async function AuthGate({ children }: { children: React.ReactNode }) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ redirect("/login");
+ }
+
+ return <>{children}>;
+}
+
export default function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
-
return (
-
- {children}
-
- );
-}
-
-async function AuthShell({ children }: { children: React.ReactNode }) {
- const supabase = await createClient();
- const { data: { user } } = await supabase.auth.getUser();
- if (!user) redirect("/login");
-
- return (
-
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+
+
+
);
- }
+}
diff --git a/app/(authenticated)/services/page.tsx b/app/(authenticated)/services/page.tsx
index ac4b257..be7d832 100644
--- a/app/(authenticated)/services/page.tsx
+++ b/app/(authenticated)/services/page.tsx
@@ -1,7 +1,16 @@
-export default function ServicesPage() {
- return (
-
- Services
-
- )
+import { listCoaches, listServices } from "./queries";
+import { ServicesTable } from "./services-table";
+
+export default async function ServicesPage() {
+ const [services, coaches] = await Promise.all([
+ listServices(),
+ listCoaches(),
+ ]);
+
+ return (
+
+ Services
+
+
+ );
}
diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx
new file mode 100644
index 0000000..6fbd022
--- /dev/null
+++ b/app/(authenticated)/services/service-dialog.tsx
@@ -0,0 +1,477 @@
+"use client";
+
+import * as React from "react";
+import { useActionState } from "react";
+import { format } from "date-fns";
+import { CalendarIcon, DollarSign, Plus, X } from "lucide-react";
+import { type DateRange } from "react-day-picker";
+
+import { Button } from "@/components/ui/button";
+import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { centsToMoneyString } from "@/lib/money";
+
+import {
+ createService,
+ updateService,
+ type ProgramSchedule,
+ type ProgramSlot,
+ type ServiceActionState,
+} from "@/app/(authenticated)/services/actions";
+import type {
+ CoachOption,
+ ServiceView,
+} from "@/app/(authenticated)/services/queries";
+
+type Props = { coaches: CoachOption[] } & (
+ | { mode: "add" }
+ | {
+ mode: "edit";
+ service: ServiceView | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ }
+);
+
+const DAY_NAMES = [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+] as const;
+
+function FieldError({ messages }: { messages?: string[] }) {
+ if (!messages?.length) return null;
+ return
{messages[0]}
;
+}
+
+function toISODate(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+function fromISODate(value: string | undefined): Date | undefined {
+ if (!value) return undefined;
+ const [y, m, d] = value.split("-").map(Number);
+ if (!y || !m || !d) return undefined;
+ return new Date(y, m - 1, d);
+}
+
+function isProgramSchedule(value: unknown): value is ProgramSchedule {
+ if (!value || typeof value !== "object") return false;
+ const v = value as Partial;
+ return (
+ typeof v.startDate === "string" &&
+ typeof v.endDate === "string" &&
+ Array.isArray(v.slots) &&
+ v.slots.every(
+ (s) => typeof s?.dayOfWeek === "number" && typeof s?.time === "string",
+ )
+ );
+}
+
+function DateRangePicker({
+ value,
+ onChange,
+}: {
+ value: DateRange | undefined;
+ onChange: (v: DateRange | undefined) => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+function ProgramScheduleFields({
+ initial,
+ errors,
+}: {
+ initial: ProgramSchedule | null;
+ errors?: Record;
+}) {
+ const [range, setRange] = React.useState(() => {
+ const from = fromISODate(initial?.startDate);
+ const to = fromISODate(initial?.endDate);
+ if (!from && !to) return undefined;
+ return { from, to };
+ });
+ const [slots, setSlots] = React.useState(
+ initial?.slots ?? [{ dayOfWeek: 1, time: "" }],
+ );
+
+ const updateSlot = (idx: number, patch: Partial) =>
+ setSlots((prev) =>
+ prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
+ );
+ const addSlot = () =>
+ setSlots((prev) => [...prev, { dayOfWeek: 1, time: "" }]);
+ const removeSlot = (idx: number) =>
+ setSlots((prev) => prev.filter((_, i) => i !== idx));
+
+ const startDate = range?.from ? toISODate(range.from) : "";
+ const endDate = range?.to ? toISODate(range.to) : "";
+
+ return (
+
+
+ Weekly schedule
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {slots.map((slot, idx) => (
+
+
+
+ updateSlot(idx, { time: e.target.value })
+ }
+ className="w-36"
+ />
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export function ServiceDialog(props: Props) {
+ const isEdit = props.mode === "edit";
+ const service = isEdit ? props.service : null;
+ const { coaches } = props;
+
+ const [type, setType] = React.useState<"programs" | "private_lessons">(
+ service?.type ?? "programs",
+ );
+ const [coachId, setCoachId] = React.useState(service?.coachId ?? "");
+ const [state, formAction, pending] = useActionState<
+ ServiceActionState,
+ FormData
+ >(isEdit ? updateService : createService, null);
+
+ React.useEffect(() => {
+ if (service) {
+ setType(service.type);
+ setCoachId(service.coachId ?? "");
+ }
+ }, [service]);
+
+ const closeRef = React.useRef(null);
+ const prevState = React.useRef(null);
+ React.useEffect(() => {
+ if (state === prevState.current) return;
+ prevState.current = state;
+ if (state?.message && !state.errors) {
+ if (isEdit && props.mode === "edit") {
+ props.onOpenChange(false);
+ } else {
+ closeRef.current?.click();
+ setType("programs");
+ setCoachId("");
+ }
+ }
+ }, [state, isEdit, props]);
+
+ const errors = state?.errors;
+
+ const dialogControl = isEdit
+ ? { open: props.open, onOpenChange: props.onOpenChange }
+ : {};
+
+ const showForm = !isEdit || service !== null;
+
+ const initialSchedule =
+ service && isProgramSchedule(service.scheduledAt)
+ ? service.scheduledAt
+ : null;
+
+ return (
+
+ );
+}
diff --git a/app/(authenticated)/services/services-data-table.tsx b/app/(authenticated)/services/services-data-table.tsx
new file mode 100644
index 0000000..3a19f3f
--- /dev/null
+++ b/app/(authenticated)/services/services-data-table.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import * as React from "react";
+import { ColumnDef } from "@tanstack/react-table";
+import { Archive, ArchiveRestore, Ban, Pencil, Power } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { DataTable } from "@/components/data-table";
+import { formatDate } from "@/lib/format";
+import { statusBadgeClass } from "@/lib/service-status";
+
+import {
+ setServiceStatus,
+ type ProgramSchedule,
+} from "@/app/(authenticated)/services/actions";
+import type { ServiceView } from "@/app/(authenticated)/services/queries";
+
+function programSchedule(value: ServiceView["scheduledAt"]): ProgramSchedule | null {
+ if (!value) return null;
+ return value;
+}
+
+export function ServicesDataTable({
+ services,
+ onEdit,
+}: {
+ services: ServiceView[];
+ onEdit: (service: ServiceView) => void;
+}) {
+ const [pending, startTransition] = React.useTransition();
+
+ const runStatus = React.useCallback(
+ (id: string, next: "active" | "disabled" | "archived") => {
+ const fd = new FormData();
+ fd.set("service_id", id);
+ fd.set("status", next);
+ startTransition(() => {
+ setServiceStatus(null, fd);
+ });
+ },
+ [],
+ );
+
+ const columns = React.useMemo[]>(
+ () => [
+ {
+ accessorKey: "title",
+ header: "Program",
+ cell: ({ row }) => (
+ {row.original.title ?? "—"}
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.status}
+
+ ),
+ },
+ {
+ id: "startDate",
+ header: "Start Date",
+ cell: ({ row }) => {
+ const s = programSchedule(row.original.scheduledAt);
+ return s ? formatDate(s.startDate) : "—";
+ },
+ },
+ {
+ id: "endDate",
+ header: "End Date",
+ cell: ({ row }) => {
+ const s = programSchedule(row.original.scheduledAt);
+ return s ? formatDate(s.endDate) : "—";
+ },
+ },
+ {
+ id: "actions",
+ header: () => Actions
,
+ cell: ({ row }) => {
+ const s = row.original;
+ return (
+
+ {(s.status === "active" || s.status === "disabled") && (
+
+
+
+
+ Edit
+
+ )}
+ {s.status === "active" && (
+
+
+
+
+ Disable
+
+ )}
+ {s.status === "disabled" && (
+
+
+
+
+ Re-enable
+
+ )}
+ {(s.status === "active" || s.status === "disabled") && (
+
+
+
+
+ Archive
+
+ )}
+ {s.status === "archived" && (
+
+
+
+
+ Restore
+
+ )}
+
+ );
+ },
+ },
+ ],
+ [pending, runStatus, onEdit],
+ );
+
+ return (
+ `${n} service${n === 1 ? "" : "s"}`}
+ />
+ );
+}
diff --git a/app/(authenticated)/services/services-table.tsx b/app/(authenticated)/services/services-table.tsx
new file mode 100644
index 0000000..e84bbdb
--- /dev/null
+++ b/app/(authenticated)/services/services-table.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+import { ServiceDialog } from "./service-dialog";
+import { ServicesDataTable } from "./services-data-table";
+import type { CoachOption, ServiceView } from "./queries";
+
+type StatusTab = "all" | "active" | "disabled" | "archived";
+
+export function ServicesTable({
+ services,
+ coaches,
+}: {
+ services: ServiceView[];
+ coaches: CoachOption[];
+}) {
+ const [tab, setTab] = React.useState("active");
+ const [editing, setEditing] = React.useState(null);
+ const statusTabs: StatusTab[] = ["all", "active", "disabled", "archived"];
+
+ const filtered = React.useMemo(() => {
+ return tab === "all"
+ ? services
+ : services.filter((service) => service.status === tab);
+ }, [services, tab]);
+
+ return (
+ <>
+ setTab(v as StatusTab)}
+ className="w-full"
+ >
+
+
+ {statusTabs.map((status) => (
+
+ {status.charAt(0).toUpperCase() + status.slice(1)}
+
+ ))}
+
+
+
+
+
+
+
+
+ {
+ if (!v) setEditing(null);
+ }}
+ />
+ >
+ );
+}
diff --git a/components/data-table.tsx b/components/data-table.tsx
new file mode 100644
index 0000000..d9464f9
--- /dev/null
+++ b/components/data-table.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getPaginationRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+interface DataTableProps {
+ columns: ColumnDef[];
+ data: TData[];
+ emptyMessage?: string;
+ rowLabel?: (count: number) => string;
+}
+
+export function DataTable({
+ columns,
+ data,
+ emptyMessage = "No results.",
+ rowLabel = (n) => `${n} row${n === 1 ? "" : "s"}`,
+}: DataTableProps) {
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ return (
+
+
+
+
+ {table.getHeaderGroups().map((hg) => (
+
+ {hg.headers.map((h) => (
+
+ {h.isPlaceholder
+ ? null
+ : flexRender(
+ h.column.columnDef.header,
+ h.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((r) => (
+
+ {r.getVisibleCells().map((c) => (
+
+ {flexRender(
+ c.column.columnDef.cell,
+ c.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {emptyMessage}
+
+
+ )}
+
+
+
+
+
+ {rowLabel(data.length)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
new file mode 100644
index 0000000..692fb07
--- /dev/null
+++ b/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 0000000..66b65f5
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,222 @@
+"use client"
+
+import * as React from "react"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale,
+} from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative rounded-(--cell-radius)",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute inset-0 bg-popover opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "font-medium select-none",
+ captionLayout === "label"
+ ? "text-sm"
+ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] text-muted-foreground select-none",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end
+ ),
+ today: cn(
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: ({ ...props }) => (
+
+ ),
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+