Compare commits
10 Commits
c0647fe512
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bd69dbceaf | |||
| 5a890ac253 | |||
| b94e205787 | |||
| 7ff599aae0 | |||
| 9d8ef03fea | |||
| a4a95e8649 | |||
| c7f6ad43c4 | |||
| a4ad1d8645 | |||
|
|
ff6de00988 | ||
|
|
459289df2d |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env.local
|
||||||
64
.gitea/workflows/docker-build.yaml
Normal file
64
.gitea/workflows/docker-build.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# docker-build.yaml
|
||||||
|
|
||||||
|
name: Docker Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract tag name
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: echo "GIT_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
if: secrets.REGISTRY != '' && secrets.REGISTRY_USER != '' && secrets.REGISTRY_PASSWORD != ''
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Check if SHA image exists
|
||||||
|
if: env.GIT_TAG != '' && secrets.REGISTRY != ''
|
||||||
|
id: check_image
|
||||||
|
run: |
|
||||||
|
if docker manifest inspect ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }} > /dev/null 2>&1; then
|
||||||
|
echo "image_exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "image_exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Pull existing image
|
||||||
|
if: env.GIT_TAG != '' && steps.check_image.outputs.image_exists == 'true'
|
||||||
|
run: docker pull ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
if: env.GIT_TAG == '' || steps.check_image.outputs.image_exists == 'false'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ secrets.REGISTRY != '' }}
|
||||||
|
tags: ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Tag and push with git tag
|
||||||
|
if: env.GIT_TAG != '' && secrets.REGISTRY != ''
|
||||||
|
run: |
|
||||||
|
docker tag ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ github.sha }} ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ env.GIT_TAG }}
|
||||||
|
docker push ${{ secrets.REGISTRY }}5mdt/vitrify-me:${{ env.GIT_TAG }}
|
||||||
16
.pre-commit-config.yaml
Normal file
16
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# .pre-commit-config.yaml
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: detect-private-key
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: check-ast
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-xml
|
||||||
70
Dockerfile
Normal file
70
Dockerfile
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@9.0.0
|
||||||
|
|
||||||
|
# Copy all package.json files for proper dependency resolution
|
||||||
|
COPY package.json pnpm-lock.yaml turbo.json pnpm-workspace.yaml ./
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY apps/pocketbase/package.json ./apps/pocketbase/
|
||||||
|
COPY packages/*/package.json ./packages/
|
||||||
|
|
||||||
|
# Create the directory structure that might be missing
|
||||||
|
RUN mkdir -p packages/ui packages/eslint-config packages/typescript-config
|
||||||
|
|
||||||
|
# Copy package.json files to their correct locations
|
||||||
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
|
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||||
|
COPY packages/typescript-config/package.json ./packages/typescript-config/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Stage 2: Builder
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@9.0.0
|
||||||
|
|
||||||
|
# Copy dependencies and source
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/pnpm-lock.yaml ./
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build only the web app
|
||||||
|
RUN pnpm turbo build --filter=web
|
||||||
|
|
||||||
|
# Stage 3: Runtime
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@9.0.0
|
||||||
|
|
||||||
|
# Copy package files for production install
|
||||||
|
COPY package.json pnpm-lock.yaml turbo.json ./
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY packages/*/package.json ./packages/
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy built Next.js application
|
||||||
|
COPY --from=builder /app/apps/web/.next ./apps/web/.next
|
||||||
|
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=builder /app/apps/web/next.config.js ./apps/web/
|
||||||
|
COPY --from=builder /app/apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
|
# Copy other necessary files
|
||||||
|
COPY --from=builder /app/apps/web/lib ./apps/web/lib
|
||||||
|
COPY --from=builder /app/apps/web/messages ./apps/web/messages
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of the app directory
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Use Next.js start command
|
||||||
|
CMD ["pnpm", "--filter=web", "start"]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,38 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_586073990")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3301820327",
|
||||||
|
"max": null,
|
||||||
|
"min": 1,
|
||||||
|
"name": "max_booking_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_586073990")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3301820327",
|
||||||
|
"max": null,
|
||||||
|
"min": 1,
|
||||||
|
"name": "max_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_43114331")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(9, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1421793101",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "min_participants_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3301820327",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "max_participants_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_43114331")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(9, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1421793101",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "min_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3301820327",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "max_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_586073990")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number3301820327")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_586073990")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3301820327",
|
||||||
|
"max": null,
|
||||||
|
"min": 1,
|
||||||
|
"name": "max_booking_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_43114331")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(12, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2396794873",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "booking_capacity",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_43114331")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number2396794873")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
22
apps/pocketbase/pb_migrations/1756243147_updated_bookings.js
Normal file
22
apps/pocketbase/pb_migrations/1756243147_updated_bookings.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_986407980")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"listRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_986407980")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"listRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_43114331")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(13, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2363381545",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"workshop",
|
||||||
|
"rental"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_43114331")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("select2363381545")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
70
apps/web/app/api/booking-types/[id]/available-dates/route.ts
Normal file
70
apps/web/app/api/booking-types/[id]/available-dates/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { bookingApi } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: bookingTypeId } = await params;
|
||||||
|
|
||||||
|
// Get booking type details to get the actual capacity
|
||||||
|
const bookingTypes = await bookingApi.getBookingTypes();
|
||||||
|
const bookingType = bookingTypes.find(bt => bt.id === bookingTypeId);
|
||||||
|
|
||||||
|
if (!bookingType) {
|
||||||
|
return NextResponse.json({ error: 'Booking type not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingCapacity = bookingType.booking_capacity;
|
||||||
|
|
||||||
|
// Get time slots for this booking type
|
||||||
|
const timeSlots = await bookingApi.getTimeSlotsForBookingType(bookingTypeId);
|
||||||
|
|
||||||
|
// Generate all available dates based on recurrence patterns
|
||||||
|
const availableSlotsByDate = await bookingApi.generateAvailableTimeSlots(timeSlots);
|
||||||
|
|
||||||
|
// Get all dates and check capacity for each
|
||||||
|
const availableDates: string[] = [];
|
||||||
|
|
||||||
|
for (const date of Object.keys(availableSlotsByDate)) {
|
||||||
|
// Get bookings for this date
|
||||||
|
const bookings = await bookingApi.getBookingsForDate(date, [bookingTypeId]);
|
||||||
|
|
||||||
|
// Check if any time slots have capacity available
|
||||||
|
const slotsForDate = availableSlotsByDate[date];
|
||||||
|
const hasAvailableSlots = slotsForDate.some(slot => {
|
||||||
|
const slotStart = new Date(slot.start_time);
|
||||||
|
const slotEnd = new Date(slot.end_time);
|
||||||
|
|
||||||
|
const overlappingBookings = bookings.filter(booking => {
|
||||||
|
const bookingStart = new Date(booking.start_time);
|
||||||
|
const bookingEnd = new Date(booking.end_time);
|
||||||
|
return bookingStart < slotEnd && bookingEnd > slotStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalParticipants = overlappingBookings.reduce((sum, booking) =>
|
||||||
|
sum + (booking.participants_count || 0), 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return totalParticipants < bookingCapacity;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasAvailableSlots) {
|
||||||
|
availableDates.push(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
bookingTypeId,
|
||||||
|
availableDates: availableDates.sort()
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching available dates:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch available dates' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/web/app/api/booking-types/[id]/time-slots/route.ts
Normal file
102
apps/web/app/api/booking-types/[id]/time-slots/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { bookingApi } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: bookingTypeId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const date = searchParams.get('date');
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return NextResponse.json({ error: 'Date parameter required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get booking type details to get the actual capacity
|
||||||
|
const bookingTypes = await bookingApi.getBookingTypes();
|
||||||
|
const bookingType = bookingTypes.find(bt => bt.id === bookingTypeId);
|
||||||
|
|
||||||
|
if (!bookingType) {
|
||||||
|
return NextResponse.json({ error: 'Booking type not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingCapacity = bookingType.booking_capacity;
|
||||||
|
|
||||||
|
// Get time slots for this booking type
|
||||||
|
const timeSlots = await bookingApi.getTimeSlotsForBookingType(bookingTypeId);
|
||||||
|
|
||||||
|
// Generate available slots for all dates
|
||||||
|
const availableSlotsByDate = await bookingApi.generateAvailableTimeSlots(timeSlots);
|
||||||
|
const slotsForDate = availableSlotsByDate[date] || [];
|
||||||
|
|
||||||
|
if (slotsForDate.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
date,
|
||||||
|
bookingTypeId,
|
||||||
|
timeSlots: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing bookings for this date
|
||||||
|
const bookings = await bookingApi.getBookingsForDate(date, [bookingTypeId]);
|
||||||
|
console.log(`\n=== Bookings for ${date} and booking type ${bookingTypeId} ===`);
|
||||||
|
console.log('Number of bookings found:', bookings.length);
|
||||||
|
bookings.forEach((booking, i) => {
|
||||||
|
console.log(`Booking ${i+1}: ${booking.start_time} - ${booking.end_time}, participants: ${booking.participants_count}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate capacity for each slot
|
||||||
|
const slotsWithCapacity = slotsForDate.map((slot, index) => {
|
||||||
|
const slotStart = new Date(slot.start_time);
|
||||||
|
const slotEnd = new Date(slot.end_time);
|
||||||
|
|
||||||
|
console.log(`\n=== Checking slot ${slot.start_time} - ${slot.end_time} ===`);
|
||||||
|
console.log('Available bookings for date:', bookings.length);
|
||||||
|
|
||||||
|
const overlappingBookings = bookings.filter(booking => {
|
||||||
|
const bookingStart = new Date(booking.start_time);
|
||||||
|
const bookingEnd = new Date(booking.end_time);
|
||||||
|
const overlaps = bookingStart < slotEnd && bookingEnd > slotStart;
|
||||||
|
|
||||||
|
console.log(`Booking ${booking.start_time} - ${booking.end_time}: overlaps = ${overlaps}`);
|
||||||
|
return overlaps;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalParticipants = overlappingBookings.reduce((sum, booking) =>
|
||||||
|
sum + (booking.participants_count || 0), 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableCapacity = Math.max(0, bookingCapacity - totalParticipants);
|
||||||
|
|
||||||
|
console.log(`Total participants: ${totalParticipants}, Capacity: ${bookingCapacity}, Available: ${availableCapacity}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `slot-${date}-${index}`,
|
||||||
|
start_time: slot.start_time,
|
||||||
|
end_time: slot.end_time,
|
||||||
|
availableCapacity,
|
||||||
|
totalBookings: totalParticipants,
|
||||||
|
maxCapacity: bookingCapacity,
|
||||||
|
is_active: availableCapacity > 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out fully booked slots
|
||||||
|
const availableTimeSlots = slotsWithCapacity.filter(slot => slot.availableCapacity > 0);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
date,
|
||||||
|
bookingTypeId,
|
||||||
|
timeSlots: availableTimeSlots
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching time slots:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch time slots' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { BookingService } from '@/lib/bookingService';
|
import { BookingService } from '@/lib/bookingService';
|
||||||
import { bookingApi } from '@/lib/pocketbase';
|
|
||||||
import { BookingType, TimeSlot } from '@/types/bookings';
|
import { BookingType, TimeSlot } from '@/types/bookings';
|
||||||
import BookingTypeSelector from './BookingTypeSelector';
|
import BookingTypeSelector from './BookingTypeSelector';
|
||||||
import DateSelector from './DateSelector';
|
import DateSelector from './DateSelector';
|
||||||
@@ -27,7 +26,6 @@ const BookingInterface = () => {
|
|||||||
const [bookingTypes, setBookingTypes] = useState<BookingType[]>([]);
|
const [bookingTypes, setBookingTypes] = useState<BookingType[]>([]);
|
||||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||||
const [availableDates, setAvailableDates] = useState<string[]>([]);
|
const [availableDates, setAvailableDates] = useState<string[]>([]);
|
||||||
const [availableSlotsByDate, setAvailableSlotsByDate] = useState<{ [date: string]: { start_time: string; end_time: string }[] }>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -100,49 +98,61 @@ const BookingInterface = () => {
|
|||||||
// Set participants count to booking type's minimum capacity
|
// Set participants count to booking type's minimum capacity
|
||||||
const bookingType = bookingTypes.find(bt => bt.id === typeId);
|
const bookingType = bookingTypes.find(bt => bt.id === typeId);
|
||||||
if (bookingType) {
|
if (bookingType) {
|
||||||
setParticipantsCount(bookingType.min_capacity);
|
setParticipantsCount(bookingType.min_participants_capacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch time slots for the selected booking type
|
// Fetch available dates from server API (with capacity pre-calculated)
|
||||||
try {
|
try {
|
||||||
const timeSlots = await bookingApi.getTimeSlotsForBookingType(typeId);
|
const response = await fetch(`/api/booking-types/${typeId}/available-dates`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Generate available time slots grouped by date
|
if (response.ok) {
|
||||||
const generatedSlotsByDate = bookingApi.generateAvailableTimeSlots(timeSlots);
|
setAvailableDates(data.availableDates);
|
||||||
const availableDatesFromAPI = Object.keys(generatedSlotsByDate).sort();
|
} else {
|
||||||
|
console.error('Error fetching available dates:', data.error);
|
||||||
// Update available dates and slots data for the date selector
|
setAvailableDates([]);
|
||||||
setAvailableDates(availableDatesFromAPI);
|
}
|
||||||
setAvailableSlotsByDate(generatedSlotsByDate);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching time slots:', error);
|
console.error('Error fetching available dates:', error);
|
||||||
|
setAvailableDates([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateChange = (date: string) => {
|
const handleDateChange = async (date: string) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setSelectedTimeSlot('');
|
setSelectedTimeSlot('');
|
||||||
|
|
||||||
// Get time slots for the selected date from availableSlotsByDate
|
// Fetch time slots with capacity from server API
|
||||||
const slotsForDate = availableSlotsByDate[date] || [];
|
try {
|
||||||
console.log(`Time slots for ${date}:`, slotsForDate);
|
const response = await fetch(`/api/booking-types/${selectedBookingType}/time-slots?date=${date}`);
|
||||||
|
const data = await response.json();
|
||||||
// Convert to TimeSlot format for TimeSlotSelector
|
|
||||||
const formattedTimeSlots: TimeSlot[] = slotsForDate.map((slot, index) => ({
|
if (response.ok) {
|
||||||
id: `slot-${date}-${index}`,
|
// Convert server response to TimeSlot format
|
||||||
start_time: slot.start_time,
|
const formattedTimeSlots: TimeSlot[] = data.timeSlots.map((slot: any) => ({
|
||||||
end_time: slot.end_time,
|
id: slot.id,
|
||||||
is_active: true,
|
start_time: slot.start_time,
|
||||||
max_capacity: 8,
|
end_time: slot.end_time,
|
||||||
booking_types: [selectedBookingType],
|
is_active: slot.is_active,
|
||||||
is_reccuring: false,
|
booking_capacity: slot.maxCapacity,
|
||||||
recurrence_pattern: undefined,
|
booking_types: [selectedBookingType],
|
||||||
resources: [],
|
is_reccuring: false,
|
||||||
created: new Date().toISOString(),
|
recurrence_pattern: undefined,
|
||||||
updated: new Date().toISOString()
|
resources: [],
|
||||||
}));
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
setTimeSlots(formattedTimeSlots);
|
availableCapacity: slot.availableCapacity // Add capacity info for UI
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeSlots(formattedTimeSlots);
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching time slots:', data.error);
|
||||||
|
setTimeSlots([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching time slots:', error);
|
||||||
|
setTimeSlots([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const CustomerDetails: React.FC<CustomerDetailsProps> = ({
|
|||||||
const t = useTranslations('bookingForm');
|
const t = useTranslations('bookingForm');
|
||||||
|
|
||||||
// Calculate min and max based on booking type
|
// Calculate min and max based on booking type
|
||||||
const minParticipants = bookingType.min_capacity;
|
const minParticipants = bookingType.min_participants_capacity;
|
||||||
const maxParticipants = bookingType.max_capacity;
|
const maxParticipants = bookingType.max_participants_capacity;
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
date.setDate(today.getDate() + i);
|
date.setDate(today.getDate() + i);
|
||||||
const dateString = date.toISOString().split('T')[0] || '';
|
const dateString = date.toISOString().split('T')[0] || '';
|
||||||
|
|
||||||
|
// Create date object from the ISO string to avoid timezone issues
|
||||||
|
const displayDate = new Date(dateString + 'T12:00:00.000Z');
|
||||||
|
|
||||||
days.push({
|
days.push({
|
||||||
date: dateString,
|
date: dateString,
|
||||||
day: date.getDate(),
|
day: parseInt(dateString.split('-')[2]),
|
||||||
month: date.toLocaleDateString('en', { month: 'short' }),
|
month: displayDate.toLocaleDateString('en', { month: 'short', timeZone: 'UTC' }),
|
||||||
dayName: date.toLocaleDateString('en', { weekday: 'short' }),
|
dayName: displayDate.toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' }),
|
||||||
available: availableDates.includes(dateString)
|
available: availableDates.includes(dateString)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { TimeSlot } from '@/types/bookings';
|
import { TimeSlot } from '@/types/bookings';
|
||||||
|
|
||||||
interface TimeSlotSelectorProps {
|
interface TimeSlotSelectorProps {
|
||||||
timeSlots: TimeSlot[];
|
timeSlots: (TimeSlot & { availableCapacity?: number })[];
|
||||||
selectedTimeSlot: string;
|
selectedTimeSlot: string;
|
||||||
onTimeSlotChange: (slotId: string) => void;
|
onTimeSlotChange: (slotId: string) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -85,6 +85,11 @@ const TimeSlotSelector: React.FC<TimeSlotSelectorProps> = ({
|
|||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</div>
|
</div>
|
||||||
|
{slot.availableCapacity !== undefined && slot.availableCapacity > 0 && (
|
||||||
|
<div className="text-xs mt-1 text-green-600">
|
||||||
|
{slot.availableCapacity} spots left
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isAvailable && (
|
{!isAvailable && (
|
||||||
<div className="text-xs mt-1">Unavailable</div>
|
<div className="text-xs mt-1">Unavailable</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
import { BookingType, TimeSlot } from '@/types/bookings';
|
import { BookingType, TimeSlot, Booking } from '@/types/bookings';
|
||||||
|
|
||||||
// Initialize PocketBase client
|
// Initialize PocketBase client
|
||||||
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090');
|
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090');
|
||||||
@@ -122,4 +122,26 @@ export const bookingApi = {
|
|||||||
|
|
||||||
return availableSlotsByDate;
|
return availableSlotsByDate;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get all bookings for a specific date filtered by booking type IDs
|
||||||
|
async getBookingsForDate(
|
||||||
|
date: string,
|
||||||
|
bookingTypeIds: string[]
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
try {
|
||||||
|
// Create filter for booking type IDs
|
||||||
|
const bookingTypeFilter = bookingTypeIds.map(id => `booking_type = "${id}"`).join(' || ');
|
||||||
|
|
||||||
|
const bookings = await pb.collection('bookings').getFullList<Booking>({
|
||||||
|
filter: `status = "confirmed" && start_time ~ "${date}" && (${bookingTypeFilter})`,
|
||||||
|
sort: 'start_time'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Bookings for ${date}:`, bookings);
|
||||||
|
return bookings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings for date:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -9,13 +9,14 @@ export interface BaseRecord {
|
|||||||
export interface Resource extends BaseRecord {
|
export interface Resource extends BaseRecord {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'wheel' | 'workstation';
|
type: 'wheel' | 'workstation';
|
||||||
capacity: number;
|
usage_capacity: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Booking Type entity
|
// Booking Type entity
|
||||||
export interface BookingType extends BaseRecord {
|
export interface BookingType extends BaseRecord {
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'workshop' | 'rental';
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
requires_payment?: boolean;
|
requires_payment?: boolean;
|
||||||
@@ -23,9 +24,10 @@ export interface BookingType extends BaseRecord {
|
|||||||
min_duration: number;
|
min_duration: number;
|
||||||
price_per_person: number;
|
price_per_person: number;
|
||||||
resources: string; // relation to Resource
|
resources: string; // relation to Resource
|
||||||
min_capacity: number;
|
min_participants_capacity: number;
|
||||||
max_capacity: number;
|
max_participants_capacity: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
booking_capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time Slot entity
|
// Time Slot entity
|
||||||
@@ -36,7 +38,6 @@ export interface TimeSlot extends BaseRecord {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_reccuring?: boolean;
|
is_reccuring?: boolean;
|
||||||
recurrence_pattern?: RecurrencePattern;
|
recurrence_pattern?: RecurrencePattern;
|
||||||
max_capacity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurrence pattern structure
|
// Recurrence pattern structure
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -1,3 +1,5 @@
|
|||||||
|
# pnpm-lock.yaml
|
||||||
|
|
||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# pnpm-workspace.yaml
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
|||||||
Reference in New Issue
Block a user