Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions apps/web/app/(app)/admin/resources/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,17 @@ export async function updateResourceType(resourceTypeId: string, input: unknown)

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

// Upsert rate config if billable and rate provided
if (parsed.data.billable && parsed.data.defaultRateCents !== undefined) {
const rateResult = await upsertRate(resourceTypeId, parsed.data.defaultRateCents);
if (!rateResult.success) return rateResult;
}

revalidatePath("/admin/resources");
return { success: true as const };
}

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

const { error } = await supabase
.from("rate_config")
.update({ rate_cents: rateCents })
.eq("resource_type_id", resourceTypeId)
.eq("space_id", spaceId);
.upsert(
{
space_id: spaceId,
resource_type_id: resourceTypeId,
rate_cents: rateCents,
},
{ onConflict: "space_id,resource_type_id" },
);

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

Expand Down
11 changes: 10 additions & 1 deletion apps/web/app/(app)/admin/resources/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default async function Page() {
} = await supabase.auth.getUser();
const spaceId = (user?.app_metadata?.space_id as string) ?? "";

const [{ data: resourceTypes }, { data: resources }] = await Promise.all([
const [{ data: resourceTypes }, { data: resources }, { data: rates }] = await Promise.all([
supabase
.from("resource_types")
.select("id, name, slug, bookable, billable")
Expand All @@ -17,13 +17,22 @@ export default async function Page() {
.from("resources")
.select("id, name, status, capacity, floor, sort_order, resource_type_id, image_url")
.order("sort_order", { ascending: true }),
supabase
.from("rate_config")
.select("resource_type_id, rate_cents"),
]);

const rateMap: Record<string, { rate_cents: number }> = {};
for (const r of rates ?? []) {
rateMap[r.resource_type_id] = { rate_cents: r.rate_cents };
}

return (
<ResourcesPage
resourceTypes={resourceTypes ?? []}
resources={resources ?? []}
spaceId={spaceId}
rateMap={rateMap}
/>
);
}
4 changes: 3 additions & 1 deletion apps/web/app/(app)/admin/resources/resource-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useTransition } from "react";
import { MoreHorizontal, Pencil, Trash2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";

Check warning on line 6 in apps/web/app/(app)/admin/resources/resource-group.tsx

View workflow job for this annotation

GitHub Actions / Lint

'Badge' is defined but never used
import {
Table,
TableBody,
Expand Down Expand Up @@ -76,9 +76,10 @@
resourceType: ResourceTypeData;
resources: Resource[];
spaceId: string;
currentRate?: { rate_cents: number };
}

export function ResourceGroup({ resourceType, resources, spaceId }: ResourceGroupProps) {
export function ResourceGroup({ resourceType, resources, spaceId, currentRate }: ResourceGroupProps) {
const [addOpen, setAddOpen] = useState(false);
const [editResource, setEditResource] = useState<Resource | null>(null);
const [editTypeOpen, setEditTypeOpen] = useState(false);
Expand Down Expand Up @@ -254,6 +255,7 @@
open={editTypeOpen}
onOpenChange={setEditTypeOpen}
resourceType={resourceType}
currentRate={currentRate}
/>

{/* Delete resource confirmation */}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/app/(app)/admin/resources/resource-type-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ interface ResourceTypeFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
resourceType?: ResourceTypeData;
currentRate?: { rate_cents: number };
}

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

export function ResourceTypeForm({ open, onOpenChange, resourceType }: ResourceTypeFormProps) {
export function ResourceTypeForm({ open, onOpenChange, resourceType, currentRate }: ResourceTypeFormProps) {
const [isPending, startTransition] = useTransition();
const [serverError, setServerError] = useState<string | null>(null);
const isEdit = !!resourceType;
Expand All @@ -54,7 +55,7 @@ export function ResourceTypeForm({ open, onOpenChange, resourceType }: ResourceT
slug: resourceType?.slug ?? "",
bookable: resourceType?.bookable ?? true,
billable: resourceType?.billable ?? true,
defaultRateCents: 0,
defaultRateCents: currentRate?.rate_cents ?? 0,
},
});

Expand Down Expand Up @@ -133,7 +134,7 @@ export function ResourceTypeForm({ open, onOpenChange, resourceType }: ResourceT
</label>
</div>

{!isEdit && watchBillable && (
{watchBillable && (
<div className="space-y-1.5">
<Label htmlFor="rt-rate">Default hourly rate (cents)</Label>
<Input
Expand Down
4 changes: 3 additions & 1 deletion apps/web/app/(app)/admin/resources/resources-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ interface ResourcesPageProps {
resourceTypes: ResourceType[];
resources: Resource[];
spaceId: string;
rateMap: Record<string, { rate_cents: number }>;
}

export function ResourcesPage({ resourceTypes, resources, spaceId }: ResourcesPageProps) {
export function ResourcesPage({ resourceTypes, resources, spaceId, rateMap }: ResourcesPageProps) {
const [addTypeOpen, setAddTypeOpen] = useState(false);

const grouped = resourceTypes.map((rt) => ({
Expand Down Expand Up @@ -76,6 +77,7 @@ export function ResourcesPage({ resourceTypes, resources, spaceId }: ResourcesPa
resourceType={resourceType}
resources={groupResources}
spaceId={spaceId}
currentRate={rateMap[resourceType.id]}
/>
))
)}
Expand Down
186 changes: 141 additions & 45 deletions apps/web/app/(app)/book/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { CalendarDays, Clock, ArrowRight } from "lucide-react";
import { CalendarDays, Clock, ArrowRight, Users } from "lucide-react";

export default async function BookPage() {
const supabase = await createClient();
Expand Down Expand Up @@ -28,59 +28,155 @@ export default async function BookPage() {

const deskRow = deskAvail?.[0];

// Fetch non-desk bookable resources for inline display
const { data: rooms } = spaceId
? await supabase
.from("resources")
.select(
"id, name, capacity, floor, image_url, resource_type_id, resource_type:resource_types!inner(id, slug, name, bookable)",
)
.eq("space_id", spaceId)
.eq("resource_types.bookable", true)
.neq("resource_types.slug", "desk")
.eq("status", "available")
.order("sort_order", { ascending: true })
: { data: null };

// Fetch rates for display
const { data: rates } = spaceId
? await supabase
.from("rate_config")
.select("resource_type_id, rate_cents, currency")
.eq("space_id", spaceId)
: { data: null };

const rateMap = new Map(
(rates ?? []).map((r) => [r.resource_type_id, r]),
);

// Group rooms by resource type
const roomsByType = new Map<string, typeof rooms>();
for (const room of rooms ?? []) {
const rtId = room.resource_type_id;
if (!roomsByType.has(rtId)) roomsByType.set(rtId, []);
roomsByType.get(rtId)!.push(room);
}

const deskType = (resourceTypes ?? []).find((rt) => rt.slug === "desk");
const nonDeskTypes = (resourceTypes ?? []).filter((rt) => rt.slug !== "desk");

return (
<div>
<h1 className="text-2xl font-semibold tracking-tight">Book a Resource</h1>
<p className="mt-1 text-sm text-muted-foreground">
Select a resource type to make a booking.
Select a resource to make a booking.
</p>

<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(resourceTypes ?? []).map((rt) => {
const isDesk = rt.slug === "desk";
const href = isDesk ? "/book/desk" : "/book/room";
const count = (rt.resources as unknown as { count: number }[])?.[0]?.count ?? 0;

return (
<Link
key={rt.id}
href={href}
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"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
{isDesk ? (
<CalendarDays className="h-5 w-5 text-primary" />
) : (
<Clock className="h-5 w-5 text-primary" />
)}
</div>
{/* Desk card */}
{deskType && (
<div className="mt-8">
<Link
href="/book/desk"
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"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<CalendarDays className="h-5 w-5 text-primary" />
</div>
<h3 className="mt-4 text-lg font-semibold">{deskType.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{deskRow ? (
<>
{deskRow.available_desks}/{deskRow.total_desks} available today
</>
) : (
<>
{(deskType.resources as unknown as { count: number }[])?.[0]?.count ?? 0} desks
</>
)}
</p>
<div className="mt-4 flex items-center text-sm font-medium text-primary">
Book a Desk
<ArrowRight className="ml-1.5 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</div>
</Link>
</div>
)}

<h3 className="mt-4 text-lg font-semibold">{rt.name}</h3>

<p className="mt-1 text-sm text-muted-foreground">
{isDesk ? (
deskRow ? (
<>
{deskRow.available_desks}/{deskRow.total_desks} available today
</>
) : (
<>{count} desks</>
)
) : (
<>
{count} {count === 1 ? "room" : "rooms"}
</>
)}
</p>
{/* Non-desk types with individual rooms */}
{nonDeskTypes.map((rt) => {
const typeRooms = roomsByType.get(rt.id) ?? [];
const rate = rateMap.get(rt.id);
const pricePerHour = rate
? new Intl.NumberFormat("en", {
style: "currency",
currency: (rate.currency ?? "eur").toUpperCase(),
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(rate.rate_cents / 100)
: null;

return (
<div key={rt.id} className="mt-8">
<h2 className="text-lg font-semibold">{rt.name}</h2>

{typeRooms.length > 0 ? (
<div className="mt-3 grid gap-4 sm:grid-cols-2">
{typeRooms.map((room) => (
<Link
key={room.id}
href={`/book/room/${room.id}`}
className="group flex flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors hover:border-primary/30 hover:bg-accent/50"
>
{room.image_url ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={room.image_url}
alt={room.name}
className="aspect-[16/9] w-full object-cover"
/>
) : (
<div className="flex aspect-[16/9] w-full items-center justify-center bg-muted">
<Clock className="h-8 w-8 text-muted-foreground/50" />
</div>
)}

<div className="mt-4 flex items-center text-sm font-medium text-primary">
{isDesk ? "Book a Desk" : "Book a Room"}
<ArrowRight className="ml-1.5 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
<div className="p-6">
<h3 className="text-lg font-semibold">{room.name}</h3>

<div className="mt-1.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{room.capacity && (
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
Capacity: {room.capacity}
</span>
)}
{room.floor !== null && room.floor !== undefined && (
<span>· Floor {room.floor}</span>
)}
</div>

{pricePerHour && (
<p className="mt-1 text-sm text-muted-foreground">
{pricePerHour}/hr or use credits
</p>
)}

<div className="mt-4 flex items-center text-sm font-medium text-primary">
View Availability
<ArrowRight className="ml-1.5 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</Link>
))}
</div>
</Link>
);
})}
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">
No rooms available
</p>
)}
</div>
);
})}

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