Skip to content

Commit 01f9a39

Browse files
committed
feat(booking-service): integrate config-service for tenant-specific configurations
- Added a config client to fetch and cache per-tenant module configurations from the config-service. - Implemented booking policies such as advance booking limits, business hours enforcement, and auto-confirmation based on tenant settings. - Updated booking creation logic to respect tenant-specific configurations and added validation for booking policies. - Enhanced the repository layer to support counting bookings per day and checking for buffer conflicts. - Refactored handler to incorporate configuration checks and enforce business rules during booking creation. - Updated Docker Compose configuration to include the config-service URL and health checks. - Modified management UI pages to utilize React's Suspense for loading states.
1 parent 0c7ba6c commit 01f9a39

10 files changed

Lines changed: 870 additions & 208 deletions

File tree

apps/management-ui/app/tenants/[id]/bookings/page.tsx

Lines changed: 139 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
"use client";
1010

11-
import { use, useState } from "react";
11+
import { use, useState, Suspense } from "react";
1212
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
1313
import { useForm } from "react-hook-form";
1414
import { zodResolver } from "@hookform/resolvers/zod";
@@ -63,11 +63,7 @@ const statusFilterOptions = [
6363
{ value: "no_show", label: "No show" },
6464
];
6565

66-
export default function TenantBookingsPage({
67-
params,
68-
}: {
69-
params: Promise<{ id: string }>;
70-
}) {
66+
function TenantBookingsContent({ params }: { params: Promise<{ id: string }> }) {
7167
const { id } = use(params);
7268
const { toast } = useToast();
7369
const qc = useQueryClient();
@@ -138,135 +134,142 @@ export default function TenantBookingsPage({
138134
const totalPages = Math.ceil(total / limit);
139135

140136
return (
141-
<Shell>
142-
<div className="space-y-5 max-w-6xl">
137+
<div className="space-y-5 max-w-6xl">
138+
<div className="flex items-center gap-3">
139+
<Link href={`/tenants/${id}`}>
140+
<Button variant="ghost" size="sm">
141+
<ArrowLeft className="h-4 w-4" />
142+
{tenantQuery.data?.name ?? "Tenant"}
143+
</Button>
144+
</Link>
145+
</div>
146+
147+
<div className="flex items-center justify-between">
148+
<div>
149+
<h2 className="text-lg font-semibold text-slate-900">Bookings</h2>
150+
<p className="text-sm text-slate-500 mt-0.5">
151+
{total} booking{total !== 1 ? "s" : ""} for{" "}
152+
<span className="font-medium">{tenantQuery.data?.name ?? id}</span>
153+
</p>
154+
</div>
143155
<div className="flex items-center gap-3">
144-
<Link href={`/tenants/${id}`}>
145-
<Button variant="ghost" size="sm">
146-
<ArrowLeft className="h-4 w-4" />
147-
{tenantQuery.data?.name ?? "Tenant"}
148-
</Button>
149-
</Link>
156+
<Select
157+
options={statusFilterOptions}
158+
value={statusFilter}
159+
onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}
160+
className="w-40"
161+
/>
162+
<Button onClick={() => setCreateOpen(true)}>
163+
<Plus className="h-4 w-4" />
164+
New Booking
165+
</Button>
150166
</div>
167+
</div>
151168

152-
<div className="flex items-center justify-between">
153-
<div>
154-
<h2 className="text-lg font-semibold text-slate-900">Bookings</h2>
155-
<p className="text-sm text-slate-500 mt-0.5">
156-
{total} booking{total !== 1 ? "s" : ""} for{" "}
157-
<span className="font-medium">{tenantQuery.data?.name ?? id}</span>
158-
</p>
159-
</div>
160-
<div className="flex items-center gap-3">
161-
<Select
162-
options={statusFilterOptions}
163-
value={statusFilter}
164-
onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}
165-
className="w-40"
169+
<Card>
170+
<CardContent className="p-0">
171+
{bookingsQuery.isLoading ? (
172+
<PageSpinner />
173+
) : bookings.length === 0 ? (
174+
<EmptyState
175+
icon={CalendarDays}
176+
title="No bookings"
177+
description="No bookings found."
178+
action={
179+
<Button onClick={() => setCreateOpen(true)}>
180+
<Plus className="h-4 w-4" />
181+
New Booking
182+
</Button>
183+
}
166184
/>
167-
<Button onClick={() => setCreateOpen(true)}>
168-
<Plus className="h-4 w-4" />
169-
New Booking
170-
</Button>
171-
</div>
172-
</div>
173-
174-
<Card>
175-
<CardContent className="p-0">
176-
{bookingsQuery.isLoading ? (
177-
<PageSpinner />
178-
) : bookings.length === 0 ? (
179-
<EmptyState
180-
icon={CalendarDays}
181-
title="No bookings"
182-
description="No bookings found."
183-
action={
184-
<Button onClick={() => setCreateOpen(true)}>
185-
<Plus className="h-4 w-4" />
186-
New Booking
187-
</Button>
188-
}
189-
/>
190-
) : (
191-
<Table>
192-
<TableHeader>
193-
<TableRow>
194-
<TableHead>Customer Ref</TableHead>
195-
<TableHead>Service Ref</TableHead>
196-
<TableHead>Slot Start</TableHead>
197-
<TableHead>Slot End</TableHead>
198-
<TableHead>Status</TableHead>
199-
<TableHead>Created</TableHead>
200-
<TableHead className="w-20" />
201-
</TableRow>
202-
</TableHeader>
203-
<TableBody>
204-
{bookings.map((b) => {
205-
const nextStatuses = NEXT_STATUSES[b.status];
206-
return (
207-
<TableRow key={b.id}>
208-
<TableCell className="font-medium">{b.customerRef}</TableCell>
209-
<TableCell>{b.serviceRef}</TableCell>
210-
<TableCell className="text-xs">{formatDate(b.slotStart)}</TableCell>
211-
<TableCell className="text-xs">{formatDate(b.slotEnd)}</TableCell>
212-
<TableCell><BookingStatusBadge status={b.status} /></TableCell>
213-
<TableCell className="text-xs text-slate-500">
214-
{formatDate(b.createdAt)}
215-
</TableCell>
216-
<TableCell>
217-
<div className="flex items-center gap-1">
218-
{nextStatuses.length > 0 && (
219-
<Button
220-
variant="ghost"
221-
size="sm"
222-
onClick={() => setUpdateTarget(b)}
223-
>
224-
<RefreshCw className="h-3.5 w-3.5" />
225-
</Button>
226-
)}
227-
{(b.status === "pending" || b.status === "confirmed") && (
228-
<Button
229-
variant="ghost"
230-
size="sm"
231-
className="text-red-500 hover:bg-red-50"
232-
onClick={() => setCancelTarget(b)}
233-
>
234-
<Trash2 className="h-3.5 w-3.5" />
235-
</Button>
236-
)}
237-
</div>
238-
</TableCell>
239-
</TableRow>
240-
);
241-
})}
242-
</TableBody>
243-
</Table>
244-
)}
245-
</CardContent>
185+
) : (
186+
<Table>
187+
<TableHeader>
188+
<TableRow>
189+
<TableHead>Customer Ref</TableHead>
190+
<TableHead>Service Ref</TableHead>
191+
<TableHead>Slot Start</TableHead>
192+
<TableHead>Slot End</TableHead>
193+
<TableHead>Status</TableHead>
194+
<TableHead>Created</TableHead>
195+
<TableHead className="w-20" />
196+
</TableRow>
197+
</TableHeader>
198+
<TableBody>
199+
{bookings.map((b) => {
200+
const nextStatuses = NEXT_STATUSES[b.status];
201+
return (
202+
<TableRow key={b.id}>
203+
<TableCell className="font-medium">{b.customerRef}</TableCell>
204+
<TableCell>{b.serviceRef}</TableCell>
205+
<TableCell className="text-xs">{formatDate(b.slotStart)}</TableCell>
206+
<TableCell className="text-xs">{formatDate(b.slotEnd)}</TableCell>
207+
<TableCell><BookingStatusBadge status={b.status} /></TableCell>
208+
<TableCell className="text-xs text-slate-500">
209+
{formatDate(b.createdAt)}
210+
</TableCell>
211+
<TableCell>
212+
<div className="flex items-center gap-1">
213+
{nextStatuses.length > 0 && (
214+
<Button
215+
variant="ghost"
216+
size="sm"
217+
onClick={() => setUpdateTarget(b)}
218+
>
219+
<RefreshCw className="h-3.5 w-3.5" />
220+
</Button>
221+
)}
222+
{(b.status === "pending" || b.status === "confirmed") && (
223+
<Button
224+
variant="ghost"
225+
size="sm"
226+
className="text-red-500 hover:bg-red-50"
227+
onClick={() => setCancelTarget(b)}
228+
>
229+
<Trash2 className="h-3.5 w-3.5" />
230+
</Button>
231+
)}
232+
</div>
233+
</TableCell>
234+
</TableRow>
235+
);
236+
})}
237+
</TableBody>
238+
</Table>
239+
)}
240+
</CardContent>
246241

247-
{totalPages > 1 && (
248-
<div className="flex items-center justify-between px-5 py-3 border-t border-slate-100">
249-
<span className="text-xs text-slate-500">Page {page + 1} of {totalPages}</span>
250-
<div className="flex gap-2">
251-
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
252-
Previous
253-
</Button>
254-
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages} onClick={() => setPage((p) => p + 1)}>
255-
Next
256-
</Button>
257-
</div>
242+
{totalPages > 1 && (
243+
<div className="flex items-center justify-between px-5 py-3 border-t border-slate-100">
244+
<span className="text-xs text-slate-500">Page {page + 1} of {totalPages}</span>
245+
<div className="flex gap-2">
246+
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
247+
Previous
248+
</Button>
249+
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages} onClick={() => setPage((p) => p + 1)}>
250+
Next
251+
</Button>
258252
</div>
259-
)}
260-
</Card>
261-
</div>
253+
</div>
254+
)}
255+
</Card>
262256

263257
<Dialog
264258
open={createOpen}
265259
onClose={() => { setCreateOpen(false); reset(); }}
266260
title="New Booking"
267261
description="Create a booking for this tenant."
268262
>
269-
<form onSubmit={handleSubmit((v) => createMutation.mutate(v))} className="space-y-4">
263+
<form
264+
onSubmit={handleSubmit((v) =>
265+
createMutation.mutate({
266+
...v,
267+
slotStart: new Date(v.slotStart).toISOString(),
268+
slotEnd: new Date(v.slotEnd).toISOString(),
269+
})
270+
)}
271+
className="space-y-4"
272+
>
270273
<Input label="Customer Reference" placeholder="cust_12345" error={errors.customerRef?.message} {...register("customerRef")} />
271274
<Input label="Service Reference" placeholder="svc_abc" error={errors.serviceRef?.message} {...register("serviceRef")} />
272275
<Input label="Slot Start" type="datetime-local" error={errors.slotStart?.message} {...register("slotStart")} />
@@ -306,6 +309,20 @@ export default function TenantBookingsPage({
306309
destructive
307310
loading={cancelMutation.isPending}
308311
/>
312+
</div>
313+
);
314+
}
315+
316+
export default function TenantBookingsPage({
317+
params,
318+
}: {
319+
params: Promise<{ id: string }>;
320+
}) {
321+
return (
322+
<Shell>
323+
<Suspense fallback={<PageSpinner />}>
324+
<TenantBookingsContent params={params} />
325+
</Suspense>
309326
</Shell>
310327
);
311328
}

apps/management-ui/app/tenants/[id]/config/page.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
"use client";
1010

11-
import { use, useState, useEffect } from "react";
11+
import { use, useState, useEffect, Suspense } from "react";
1212
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
1313
import Link from "next/link";
1414
import { Shell } from "@/components/layout/shell";
@@ -24,7 +24,7 @@ import { SchemaForm } from "@/components/config/schema-form";
2424
import { formatDate } from "@/lib/utils";
2525
import { Settings2, Save, History, Code2, RefreshCw, ArrowLeft } from "lucide-react";
2626

27-
export default function TenantConfigPage({
27+
function TenantConfigContent({
2828
params,
2929
}: {
3030
params: Promise<{ id: string }>;
@@ -94,8 +94,7 @@ export default function TenantConfigPage({
9494
})();
9595

9696
return (
97-
<Shell>
98-
<div className="space-y-5 max-w-3xl">
97+
<div className="space-y-5 max-w-3xl">
9998
<div className="flex items-center gap-3">
10099
<Link href={`/tenants/${id}`}>
101100
<Button variant="ghost" size="sm">
@@ -282,6 +281,19 @@ export default function TenantConfigPage({
282281
</div>
283282
)}
284283
</div>
284+
);
285+
}
286+
287+
export default function TenantConfigPage({
288+
params,
289+
}: {
290+
params: Promise<{ id: string }>;
291+
}) {
292+
return (
293+
<Shell>
294+
<Suspense fallback={<PageSpinner />}>
295+
<TenantConfigContent params={params} />
296+
</Suspense>
285297
</Shell>
286298
);
287299
}

0 commit comments

Comments
 (0)