Skip to content
4 changes: 3 additions & 1 deletion app/api/bookings/classes/crud-with-plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ const ClassContentInputSchema = ClassContentSchema.omit({
const PostClassWithPlanBodySchema = ClassPlanSchema.omit({
planType: true,
consultantProfile: true,
startDate: true,
// The form's Date-valued field; this endpoint takes an ISO `startDate`
// string, re-declared below.
schedulingStartDate: true,
endDate: true,
topics: true,
classContents: true, // Omit to override with input schema
Expand Down
5 changes: 4 additions & 1 deletion app/api/plans/consultations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { ConsultationPlanSchema } from "@/schemas/plans";
import { findOrCreateTopics, transformTopicsToStrings } from "@/lib/topics";
import { marketplaceVisibilityWhere } from "@/lib/api/plans/visibility";
import { faqCreateNested } from "@/lib/api/plans/content";
import { faqCreateNested, planContentInclude } from "@/lib/api/plans/content";
import * as Sentry from "@sentry/nextjs";
import { getSession } from "@/lib/auth-server";
export async function GET(request: NextRequest) {
Expand All @@ -26,6 +26,9 @@ export async function GET(request: NextRequest) {
include: {
consultantProfile: true,
topics: true,
// The offering editor hydrates from this list and PUTs the whole FAQ
// array back, so a list that omits them saves an empty set over them.
...planContentInclude,
},
skip,
take: limit,
Expand Down
4 changes: 4 additions & 0 deletions app/api/plans/subscriptions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SubscriptionPlanSchema } from "@/schemas/plans";
import {
curriculumCreateNested,
faqCreateNested,
planContentInclude,
} from "@/lib/api/plans/content";
import { findOrCreateTopics, transformTopicsToStrings } from "@/lib/topics";
import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService";
Expand Down Expand Up @@ -35,6 +36,9 @@ export async function GET(request: NextRequest) {
subscriptionContents: {
orderBy: { order: "asc" },
},
// The offering editor hydrates from this list and PUTs the whole FAQ
// array back, so a list that omits them saves an empty set over them.
...planContentInclude,
},
skip,
take: limit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function RescheduleClient({

return (
<SlotPicker
className="min-h-[70vh] flex-1"
className="min-h-0 flex-1"
policy={policy}
subject={subject}
isSubmitting={actions.isLoading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cache } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { DashboardViewportFill } from "@/components/dashboard/DashboardViewportFill";
import { PanelHeader } from "@/components/dashboard/PageScaffold";
import { readAppointmentDetail } from "@/lib/data/appointment-detail";
import { resolvePlanOwnerIds } from "@/lib/booking/plan-owners";
Expand Down Expand Up @@ -71,16 +72,18 @@ export default async function ConsultantReschedulePage({
const backHref = `/dashboard/consultant/${consultantId}/appointments`;

return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<DashboardViewportFill className="gap-4">
{/* The BOOKING now lives in the breadcrumb (RescheduleClient sets it
via useSetBreadcrumbLabel) — see the consultee's twin (#1064). */}
<PanelHeader
description={
resolved.consulteeName
? `Propose a new time for ${resolved.consulteeName}`
: "Propose a new time"
}
/>
<div className="shrink-0">
<PanelHeader
description={
resolved.consulteeName
? `Propose a new time for ${resolved.consulteeName}`
: "Propose a new time"
}
/>
</div>

<RescheduleClient
consultantId={consultantId}
Expand All @@ -90,6 +93,6 @@ export default async function ConsultantReschedulePage({
subject={resolved.subject}
backHref={backHref}
/>
</div>
</DashboardViewportFill>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function ManageTimingsClient({

return (
<SlotPicker
className="min-h-[70vh] flex-1"
className="min-h-0 flex-1"
policy={policy}
subject={subject}
onCancel={goBack}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cache } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { DashboardViewportFill } from "@/components/dashboard/DashboardViewportFill";
import { PanelHeader } from "@/components/dashboard/PageScaffold";
import { Badge } from "@/components/ui/badge";
import { allowsManageTimings, upcomingSlots } from "@/lib/appointments/slots";
Expand Down Expand Up @@ -121,39 +122,41 @@ export default async function ManageTimingsPage({
const backHref = `/dashboard/consultant/${consultantId}/appointments`;

return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<DashboardViewportFill className="gap-4">
{/* The OFFERING now lives in the breadcrumb (ManageTimingsClient sets it
via useSetBreadcrumbLabel) — see the reschedule/allocate pages
(#1064). */}
<PanelHeader description={resolved.description} />

{resolved.classInfo && (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">Plan: {resolved.classInfo.planType}</Badge>
<span>
{resolved.classInfo.sessionsPerWeek} meetings/week ·{" "}
{resolved.classInfo.durationInMonths} month
{resolved.classInfo.durationInMonths !== 1 ? "s" : ""} ·{" "}
{resolved.classInfo.durationInHours}h/session
</span>
</div>
)}

{resolved.classInfo && (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
Tip: Each class is{" "}
{Math.ceil(resolved.classInfo.durationInHours / 0.5)} consecutive
30-min slots. Complete an in-progress class before starting another.
Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly limit
applies.
</div>
)}
<div className="shrink-0 space-y-4">
<PanelHeader description={resolved.description} />

{resolved.classInfo && (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">Plan: {resolved.classInfo.planType}</Badge>
<span>
{resolved.classInfo.sessionsPerWeek} meetings/week ·{" "}
{resolved.classInfo.durationInMonths} month
{resolved.classInfo.durationInMonths !== 1 ? "s" : ""} ·{" "}
{resolved.classInfo.durationInHours}h/session
</span>
</div>
)}

{resolved.classInfo && (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
Tip: Each class is{" "}
{Math.ceil(resolved.classInfo.durationInHours / 0.5)} consecutive
30-min slots. Complete an in-progress class before starting another.
Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly
limit applies.
</div>
)}
</div>

<ManageTimingsClient
subject={resolved.subject}
backHref={backHref}
title={resolved.title}
/>
</div>
</DashboardViewportFill>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,109 @@
} from "@/components/dashboard/PageScaffold";
import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary";
import { PlannerSkeleton } from "@/components/dashboard/DashboardSkeletons";
import { createConsultantQueries } from "@/lib/dashboard-queries";
import { OfferingEditorContainer } from "@/components/offerings/editor/OfferingEditorContainer";
import { OFFERING_MANIFESTS } from "@/components/offerings/editor/manifests";
import type { OfferingType } from "@/components/offerings/editor/manifest";

const PLAN_PATH: Record<OfferingType, (id: string) => string> = {
consultation: (id) => `/api/plans/consultations/${id}`,
subscription: (id) => `/api/plans/subscriptions/${id}`,
webinar: (id) => `/api/plans/webinars/${id}`,
class: (id) => `/api/plans/classes/${id}`,
};

/**
* Adapters expect a planner-shaped event wrapper, not the bare plan row.
* Class start date lives on the Class instance (`schedulingPeriodStartsAt`),
* so it is lifted onto the wrapper the same way the planner list does.
*/
function wrapPlanAsEvent(
type: OfferingType,
plan: Record<string, unknown>,
): Record<string, unknown> {
const id = String(plan.id ?? "");

Check warning on line 31 in app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'plan.id ?? ""' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ-9epSUVL7xxLuowBUV&open=AZ-9epSUVL7xxLuowBUV&pullRequest=1088
if (type === "consultation") {
return { type, id, consultationPlan: plan };
}
if (type === "subscription") {
return { type, id, subscriptionPlan: plan };
}
if (type === "webinar") {
// Same derivation the planner list uses: first slot start on the webinar's
// appointment. The plan row itself has no scheduledAt column.
const webinars = plan.webinars as
| Array<{
appointment?: {
slotsOfAppointment?: Array<{ startsAt?: string | Date | null }>;
} | null;
}>
| undefined;
const scheduledAt =
webinars?.[0]?.appointment?.slotsOfAppointment?.[0]?.startsAt ?? null;
return {
type,
id,
webinarPlan: {
...plan,
scheduledAt,
},
};
}
const classes = plan.classes as
| Array<{ schedulingPeriodStartsAt?: string | Date | null }>
| undefined;
const start =
classes?.find(
(row) =>
row.schedulingPeriodStartsAt !== null &&
row.schedulingPeriodStartsAt !== undefined,
)?.schedulingPeriodStartsAt ??
classes?.[0]?.schedulingPeriodStartsAt ??
null;
return {
type,
id,
classPlan: plan,
schedulingPeriodStartsAt: start,
};
}

/**
* Editing reuses the planner's own query rather than inventing a per-offering
* fetch: the planner already loads every offering this consultant owns, so the
* row is usually in cache and the editor opens without a spinner.
* Load the one offering being edited by id. A paginated list lookup (plus
* marketplaceVisibilityWhere on those list routes) 404'd valid plans that were
* past page one or marked ORG_ONLY — the owner's own edit URL must not depend
* on marketplace visibility or list pagination.
*/
function useOfferingEvent(type: OfferingType, offeringId: string) {
return useQuery({
queryKey: ["offering-edit", type, offeringId],
enabled: !!OFFERING_MANIFESTS[type] && !!offeringId,
queryFn: async () => {
const response = await fetch(PLAN_PATH[type](offeringId));
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Failed to load ${type} plan (${response.status})`);
}
const body = (await response.json()) as {
data?: Record<string, unknown>;
};
if (!body.data) return null;
return wrapPlanAsEvent(type, body.data);
},
});
}

export default function EditOfferingPage() {
const params = useParams();
const consultantId = params.consultantId as string;
const type = params.type as OfferingType;
const offeringId = params.offeringId as string;

const plannerQuery = createConsultantQueries(consultantId).planner;
const { data, isLoading } = useQuery(plannerQuery);

if (!OFFERING_MANIFESTS[type]) notFound();

if (isLoading) {
const offering = useOfferingEvent(type, offeringId);

if (offering.isLoading) {
return (
<>
<DashboardHeader title="Edit offering" />
Expand All @@ -40,18 +121,11 @@
);
}

const events = [
...((data as { consultationPlans?: unknown[] })?.consultationPlans ?? []),
...((data as { subscriptionPlans?: unknown[] })?.subscriptionPlans ?? []),
...((data as { webinars?: unknown[] })?.webinars ?? []),
...((data as { classes?: unknown[] })?.classes ?? []),
];

const initialEvent = events.find(
(event) => (event as { id?: string })?.id === offeringId,
);
// Query failures are real errors (network / 500), not missing rows — let the
// dashboard error boundary render them instead of pretending the plan is gone.
if (offering.isError) throw offering.error;

if (!initialEvent) notFound();
if (!offering.data) notFound();

return (
<>
Expand All @@ -64,7 +138,7 @@
<OfferingEditorContainer
type={type}
consultantId={consultantId}
initialEvent={initialEvent}
initialEvent={offering.data}
/>
</DashboardErrorBoundary>
</DashboardContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function AllocateClient({

return (
<SlotPicker
className="min-h-[70vh] flex-1"
className="min-h-0 flex-1"
policy={policy}
subject={subject}
onCancel={goBack}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cache } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { DashboardViewportFill } from "@/components/dashboard/DashboardViewportFill";
import { PanelHeader } from "@/components/dashboard/PageScaffold";
import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access";
import { ALLOCATION_APPROVABLE_FROM } from "@/lib/booking/transitions";
Expand Down Expand Up @@ -89,18 +90,20 @@ export default async function AllocateSlotsPage({
const backHref = `/dashboard/consultant/${consultantId}/requests`;

return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<DashboardViewportFill className="gap-4">
{/* The BOOKING now lives in the breadcrumb itself (AllocateClient sets
it via useSetBreadcrumbLabel) — the back link is the breadcrumb's
own parent crumb. This line keeps the one thing the breadcrumb
can't say: who the task is for (#1064). */}
<PanelHeader
description={
request.consulteeName
? `Allocate slots for ${request.consulteeName}`
: "Allocate slots"
}
/>
<div className="shrink-0">
<PanelHeader
description={
request.consulteeName
? `Allocate slots for ${request.consulteeName}`
: "Allocate slots"
}
/>
</div>

<AllocateClient
backHref={backHref}
Expand All @@ -122,6 +125,6 @@ export default async function AllocateSlotsPage({
slots: request.slots,
}}
/>
</div>
</DashboardViewportFill>
);
}
Loading
Loading