Skip to content

Commit 08724bb

Browse files
Kikolatorclaude
andauthored
fix: booking flow double-selection + admin rate editing (#88)
## Summary - **Booking flow**: Merged resource type selection and room selection into a single `/book` page. Non-desk resource types now show individual rooms inline (grouped by type), each linking directly to the slot picker (`/book/room/[resourceId]`). Eliminates the redundant intermediate `/book/room` page. That URL now redirects to `/book` for backward compat. - **Admin rate editing**: The hourly rate field was hidden in edit mode for resource types despite the server action existing. Now visible in both create and edit mode, with `updateResourceType` upserting the rate config and `updateRate` renamed to `upsertRate` to handle both insert and update. ## Test plan - [ ] Navigate to `/book` — should show desk card + non-desk rooms grouped by type - [ ] Click a room → goes directly to slot picker (no intermediate page) - [ ] Visit `/book/room` directly → redirects to `/book` - [ ] Admin: edit a billable resource type → rate field visible with current value - [ ] Admin: change rate → save → verify `rate_config` updated in DB - [ ] Admin: toggle non-billable type to billable → rate field appears, save creates `rate_config` row 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf57523 commit 08724bb

File tree

7 files changed

+179
-174
lines changed

7 files changed

+179
-174
lines changed

apps/web/app/(app)/admin/resources/actions.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,17 @@ export async function updateResourceType(resourceTypeId: string, input: unknown)
198198

199199
if (error) return { success: false as const, error: error.message };
200200

201+
// Upsert rate config if billable and rate provided
202+
if (parsed.data.billable && parsed.data.defaultRateCents !== undefined) {
203+
const rateResult = await upsertRate(resourceTypeId, parsed.data.defaultRateCents);
204+
if (!rateResult.success) return rateResult;
205+
}
206+
201207
revalidatePath("/admin/resources");
202208
return { success: true as const };
203209
}
204210

205-
export async function updateRate(resourceTypeId: string, rateCents: number) {
211+
export async function upsertRate(resourceTypeId: string, rateCents: number) {
206212
if (!Number.isInteger(rateCents) || rateCents < 0) {
207213
return { success: false as const, error: "Rate must be a non-negative integer" };
208214
}
@@ -211,9 +217,14 @@ export async function updateRate(resourceTypeId: string, rateCents: number) {
211217

212218
const { error } = await supabase
213219
.from("rate_config")
214-
.update({ rate_cents: rateCents })
215-
.eq("resource_type_id", resourceTypeId)
216-
.eq("space_id", spaceId);
220+
.upsert(
221+
{
222+
space_id: spaceId,
223+
resource_type_id: resourceTypeId,
224+
rate_cents: rateCents,
225+
},
226+
{ onConflict: "space_id,resource_type_id" },
227+
);
217228

218229
if (error) return { success: false as const, error: error.message };
219230

apps/web/app/(app)/admin/resources/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default async function Page() {
88
} = await supabase.auth.getUser();
99
const spaceId = (user?.app_metadata?.space_id as string) ?? "";
1010

11-
const [{ data: resourceTypes }, { data: resources }] = await Promise.all([
11+
const [{ data: resourceTypes }, { data: resources }, { data: rates }] = await Promise.all([
1212
supabase
1313
.from("resource_types")
1414
.select("id, name, slug, bookable, billable")
@@ -17,13 +17,22 @@ export default async function Page() {
1717
.from("resources")
1818
.select("id, name, status, capacity, floor, sort_order, resource_type_id, image_url")
1919
.order("sort_order", { ascending: true }),
20+
supabase
21+
.from("rate_config")
22+
.select("resource_type_id, rate_cents"),
2023
]);
2124

25+
const rateMap: Record<string, { rate_cents: number }> = {};
26+
for (const r of rates ?? []) {
27+
rateMap[r.resource_type_id] = { rate_cents: r.rate_cents };
28+
}
29+
2230
return (
2331
<ResourcesPage
2432
resourceTypes={resourceTypes ?? []}
2533
resources={resources ?? []}
2634
spaceId={spaceId}
35+
rateMap={rateMap}
2736
/>
2837
);
2938
}

apps/web/app/(app)/admin/resources/resource-group.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,10 @@ interface ResourceGroupProps {
7676
resourceType: ResourceTypeData;
7777
resources: Resource[];
7878
spaceId: string;
79+
currentRate?: { rate_cents: number };
7980
}
8081

81-
export function ResourceGroup({ resourceType, resources, spaceId }: ResourceGroupProps) {
82+
export function ResourceGroup({ resourceType, resources, spaceId, currentRate }: ResourceGroupProps) {
8283
const [addOpen, setAddOpen] = useState(false);
8384
const [editResource, setEditResource] = useState<Resource | null>(null);
8485
const [editTypeOpen, setEditTypeOpen] = useState(false);
@@ -254,6 +255,7 @@ export function ResourceGroup({ resourceType, resources, spaceId }: ResourceGrou
254255
open={editTypeOpen}
255256
onOpenChange={setEditTypeOpen}
256257
resourceType={resourceType}
258+
currentRate={currentRate}
257259
/>
258260

259261
{/* Delete resource confirmation */}

apps/web/app/(app)/admin/resources/resource-type-form.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ interface ResourceTypeFormProps {
3030
open: boolean;
3131
onOpenChange: (open: boolean) => void;
3232
resourceType?: ResourceTypeData;
33+
currentRate?: { rate_cents: number };
3334
}
3435

3536
function slugify(text: string): string {
3637
return text.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
3738
}
3839

39-
export function ResourceTypeForm({ open, onOpenChange, resourceType }: ResourceTypeFormProps) {
40+
export function ResourceTypeForm({ open, onOpenChange, resourceType, currentRate }: ResourceTypeFormProps) {
4041
const [isPending, startTransition] = useTransition();
4142
const [serverError, setServerError] = useState<string | null>(null);
4243
const isEdit = !!resourceType;
@@ -54,7 +55,7 @@ export function ResourceTypeForm({ open, onOpenChange, resourceType }: ResourceT
5455
slug: resourceType?.slug ?? "",
5556
bookable: resourceType?.bookable ?? true,
5657
billable: resourceType?.billable ?? true,
57-
defaultRateCents: 0,
58+
defaultRateCents: currentRate?.rate_cents ?? 0,
5859
},
5960
});
6061

@@ -133,7 +134,7 @@ export function ResourceTypeForm({ open, onOpenChange, resourceType }: ResourceT
133134
</label>
134135
</div>
135136

136-
{!isEdit && watchBillable && (
137+
{watchBillable && (
137138
<div className="space-y-1.5">
138139
<Label htmlFor="rt-rate">Default hourly rate (cents)</Label>
139140
<Input

apps/web/app/(app)/admin/resources/resources-page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ interface ResourcesPageProps {
2929
resourceTypes: ResourceType[];
3030
resources: Resource[];
3131
spaceId: string;
32+
rateMap: Record<string, { rate_cents: number }>;
3233
}
3334

34-
export function ResourcesPage({ resourceTypes, resources, spaceId }: ResourcesPageProps) {
35+
export function ResourcesPage({ resourceTypes, resources, spaceId, rateMap }: ResourcesPageProps) {
3536
const [addTypeOpen, setAddTypeOpen] = useState(false);
3637

3738
const grouped = resourceTypes.map((rt) => ({
@@ -76,6 +77,7 @@ export function ResourcesPage({ resourceTypes, resources, spaceId }: ResourcesPa
7677
resourceType={resourceType}
7778
resources={groupResources}
7879
spaceId={spaceId}
80+
currentRate={rateMap[resourceType.id]}
7981
/>
8082
))
8183
)}

apps/web/app/(app)/book/page.tsx

Lines changed: 141 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Link from "next/link";
22
import { createClient } from "@/lib/supabase/server";
3-
import { CalendarDays, Clock, ArrowRight } from "lucide-react";
3+
import { CalendarDays, Clock, ArrowRight, Users } from "lucide-react";
44

55
export default async function BookPage() {
66
const supabase = await createClient();
@@ -28,59 +28,155 @@ export default async function BookPage() {
2828

2929
const deskRow = deskAvail?.[0];
3030

31+
// Fetch non-desk bookable resources for inline display
32+
const { data: rooms } = spaceId
33+
? await supabase
34+
.from("resources")
35+
.select(
36+
"id, name, capacity, floor, image_url, resource_type_id, resource_type:resource_types!inner(id, slug, name, bookable)",
37+
)
38+
.eq("space_id", spaceId)
39+
.eq("resource_types.bookable", true)
40+
.neq("resource_types.slug", "desk")
41+
.eq("status", "available")
42+
.order("sort_order", { ascending: true })
43+
: { data: null };
44+
45+
// Fetch rates for display
46+
const { data: rates } = spaceId
47+
? await supabase
48+
.from("rate_config")
49+
.select("resource_type_id, rate_cents, currency")
50+
.eq("space_id", spaceId)
51+
: { data: null };
52+
53+
const rateMap = new Map(
54+
(rates ?? []).map((r) => [r.resource_type_id, r]),
55+
);
56+
57+
// Group rooms by resource type
58+
const roomsByType = new Map<string, typeof rooms>();
59+
for (const room of rooms ?? []) {
60+
const rtId = room.resource_type_id;
61+
if (!roomsByType.has(rtId)) roomsByType.set(rtId, []);
62+
roomsByType.get(rtId)!.push(room);
63+
}
64+
65+
const deskType = (resourceTypes ?? []).find((rt) => rt.slug === "desk");
66+
const nonDeskTypes = (resourceTypes ?? []).filter((rt) => rt.slug !== "desk");
67+
3168
return (
3269
<div>
3370
<h1 className="text-2xl font-semibold tracking-tight">Book a Resource</h1>
3471
<p className="mt-1 text-sm text-muted-foreground">
35-
Select a resource type to make a booking.
72+
Select a resource to make a booking.
3673
</p>
3774

38-
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
39-
{(resourceTypes ?? []).map((rt) => {
40-
const isDesk = rt.slug === "desk";
41-
const href = isDesk ? "/book/desk" : "/book/room";
42-
const count = (rt.resources as unknown as { count: number }[])?.[0]?.count ?? 0;
43-
44-
return (
45-
<Link
46-
key={rt.id}
47-
href={href}
48-
className="group relative flex flex-col rounded-xl border border-border bg-card p-6 transition-colors hover:border-primary/30 hover:bg-accent/50"
49-
>
50-
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
51-
{isDesk ? (
52-
<CalendarDays className="h-5 w-5 text-primary" />
53-
) : (
54-
<Clock className="h-5 w-5 text-primary" />
55-
)}
56-
</div>
75+
{/* Desk card */}
76+
{deskType && (
77+
<div className="mt-8">
78+
<Link
79+
href="/book/desk"
80+
className="group flex flex-col rounded-xl border border-border bg-card p-6 transition-colors hover:border-primary/30 hover:bg-accent/50 sm:max-w-sm"
81+
>
82+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
83+
<CalendarDays className="h-5 w-5 text-primary" />
84+
</div>
85+
<h3 className="mt-4 text-lg font-semibold">{deskType.name}</h3>
86+
<p className="mt-1 text-sm text-muted-foreground">
87+
{deskRow ? (
88+
<>
89+
{deskRow.available_desks}/{deskRow.total_desks} available today
90+
</>
91+
) : (
92+
<>
93+
{(deskType.resources as unknown as { count: number }[])?.[0]?.count ?? 0} desks
94+
</>
95+
)}
96+
</p>
97+
<div className="mt-4 flex items-center text-sm font-medium text-primary">
98+
Book a Desk
99+
<ArrowRight className="ml-1.5 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
100+
</div>
101+
</Link>
102+
</div>
103+
)}
57104

58-
<h3 className="mt-4 text-lg font-semibold">{rt.name}</h3>
59-
60-
<p className="mt-1 text-sm text-muted-foreground">
61-
{isDesk ? (
62-
deskRow ? (
63-
<>
64-
{deskRow.available_desks}/{deskRow.total_desks} available today
65-
</>
66-
) : (
67-
<>{count} desks</>
68-
)
69-
) : (
70-
<>
71-
{count} {count === 1 ? "room" : "rooms"}
72-
</>
73-
)}
74-
</p>
105+
{/* Non-desk types with individual rooms */}
106+
{nonDeskTypes.map((rt) => {
107+
const typeRooms = roomsByType.get(rt.id) ?? [];
108+
const rate = rateMap.get(rt.id);
109+
const pricePerHour = rate
110+
? new Intl.NumberFormat("en", {
111+
style: "currency",
112+
currency: (rate.currency ?? "eur").toUpperCase(),
113+
minimumFractionDigits: 0,
114+
maximumFractionDigits: 2,
115+
}).format(rate.rate_cents / 100)
116+
: null;
117+
118+
return (
119+
<div key={rt.id} className="mt-8">
120+
<h2 className="text-lg font-semibold">{rt.name}</h2>
121+
122+
{typeRooms.length > 0 ? (
123+
<div className="mt-3 grid gap-4 sm:grid-cols-2">
124+
{typeRooms.map((room) => (
125+
<Link
126+
key={room.id}
127+
href={`/book/room/${room.id}`}
128+
className="group flex flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors hover:border-primary/30 hover:bg-accent/50"
129+
>
130+
{room.image_url ? (
131+
/* eslint-disable-next-line @next/next/no-img-element */
132+
<img
133+
src={room.image_url}
134+
alt={room.name}
135+
className="aspect-[16/9] w-full object-cover"
136+
/>
137+
) : (
138+
<div className="flex aspect-[16/9] w-full items-center justify-center bg-muted">
139+
<Clock className="h-8 w-8 text-muted-foreground/50" />
140+
</div>
141+
)}
75142

76-
<div className="mt-4 flex items-center text-sm font-medium text-primary">
77-
{isDesk ? "Book a Desk" : "Book a Room"}
78-
<ArrowRight className="ml-1.5 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
143+
<div className="p-6">
144+
<h3 className="text-lg font-semibold">{room.name}</h3>
145+
146+
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
147+
{room.capacity && (
148+
<span className="inline-flex items-center gap-1">
149+
<Users className="h-3.5 w-3.5" />
150+
Capacity: {room.capacity}
151+
</span>
152+
)}
153+
{room.floor !== null && room.floor !== undefined && (
154+
<span>· Floor {room.floor}</span>
155+
)}
156+
</div>
157+
158+
{pricePerHour && (
159+
<p className="mt-1 text-sm text-muted-foreground">
160+
{pricePerHour}/hr or use credits
161+
</p>
162+
)}
163+
164+
<div className="mt-4 flex items-center text-sm font-medium text-primary">
165+
View Availability
166+
<ArrowRight className="ml-1.5 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
167+
</div>
168+
</div>
169+
</Link>
170+
))}
79171
</div>
80-
</Link>
81-
);
82-
})}
83-
</div>
172+
) : (
173+
<p className="mt-3 text-sm text-muted-foreground">
174+
No rooms available
175+
</p>
176+
)}
177+
</div>
178+
);
179+
})}
84180

85181
{(resourceTypes ?? []).length === 0 && (
86182
<div className="mt-8 flex flex-col items-center rounded-xl border border-dashed border-border bg-card px-6 py-14 text-center">

0 commit comments

Comments
 (0)