Skip to main content
ship before you scale

Stall Management and Market Day Scheduling

5 min read Chapter 21 of 42

Stall Management and Market Day Scheduling

The Feature

An organizer defines the stall layout for their market (labeled positions with size categories), creates market day events with specific dates and times, and assigns accepted vendors to stalls for each market day. The organizer can see which stalls are filled and which are available at a glance.

The Decision

The stall layout is a flat list with labels, not a visual drag-and-drop map. A visual map is appealing but adds significant complexity (canvas rendering, coordinate systems, responsive layout) for marginal benefit. An organizer who knows their market’s physical layout does not need a pixel-perfect map. They need a list of stalls with clear labels and assignment status. The visual map is a post-launch enhancement.

The Implementation

Stall CRUD Endpoints

# backend/app/routers/stalls.py
import uuid

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.dependencies import get_market_for_organizer
from app.models.market import Market
from app.models.stall import Stall
from app.schemas.stall import StallCreate, StallRead, StallUpdate

router = APIRouter(
    prefix="/api/markets/{market_id}/stalls",
    tags=["stalls"],
)


@router.get("/", response_model=list[StallRead])
async def list_stalls(
    db: AsyncSession = Depends(get_db),
    market: Market = Depends(get_market_for_organizer),
) -> list[StallRead]:
    result = await db.execute(
        select(Stall)
        .where(Stall.market_id == market.id)
        .order_by(Stall.label)
    )
    return [StallRead.model_validate(s) for s in result.scalars().all()]


@router.post("/", response_model=StallRead, status_code=201)
async def create_stall(
    body: StallCreate,
    db: AsyncSession = Depends(get_db),
    market: Market = Depends(get_market_for_organizer),
) -> StallRead:
    stall = Stall(
        market_id=market.id,
        label=body.label,
        size_category=body.size_category,
        price_per_day_cents=body.price_per_day_cents,
    )
    db.add(stall)
    await db.commit()
    await db.refresh(stall)
    return StallRead.model_validate(stall)


@router.put("/{stall_id}", response_model=StallRead)
async def update_stall(
    stall_id: uuid.UUID,
    body: StallUpdate,
    db: AsyncSession = Depends(get_db),
    market: Market = Depends(get_market_for_organizer),
) -> StallRead:
    result = await db.execute(
        select(Stall).where(Stall.id == stall_id, Stall.market_id == market.id)
    )
    stall = result.scalar_one_or_none()
    if not stall:
        raise HTTPException(status_code=404, detail="Stall not found")

    update_data = body.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(stall, field, value)

    await db.commit()
    await db.refresh(stall)
    return StallRead.model_validate(stall)


@router.delete("/{stall_id}", status_code=204)
async def delete_stall(
    stall_id: uuid.UUID,
    db: AsyncSession = Depends(get_db),
    market: Market = Depends(get_market_for_organizer),
) -> None:
    result = await db.execute(
        select(Stall).where(Stall.id == stall_id, Stall.market_id == market.id)
    )
    stall = result.scalar_one_or_none()
    if not stall:
        raise HTTPException(status_code=404, detail="Stall not found")

    await db.delete(stall)
    await db.commit()

Market Day and Booking Endpoints

# backend/app/routers/market_days.py
import uuid
from datetime import date, time

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.dependencies import get_market_for_organizer, get_organizer_profile
from app.models.booking import Booking
from app.models.market import Market
from app.models.market_day import MarketDay
from app.models.organizer import OrganizerProfile
from app.schemas.market_day import MarketDayCreate, MarketDayRead
from app.schemas.booking import BookingCreate, BookingRead
from app.services.booking_service import create_booking

router = APIRouter(
    prefix="/api/markets/{market_id}/days",
    tags=["market-days"],
)


@router.post("/", response_model=MarketDayRead, status_code=201)
async def create_market_day(
    body: MarketDayCreate,
    db: AsyncSession = Depends(get_db),
    market: Market = Depends(get_market_for_organizer),
) -> MarketDayRead:
    market_day = MarketDay(
        market_id=market.id,
        date=body.date,
        start_time=body.start_time,
        end_time=body.end_time,
    )
    db.add(market_day)
    await db.commit()
    await db.refresh(market_day)
    return MarketDayRead.model_validate(market_day)


@router.post("/{day_id}/bookings", response_model=BookingRead, status_code=201)
async def assign_vendor(
    day_id: uuid.UUID,
    body: BookingCreate,
    db: AsyncSession = Depends(get_db),
    market: Market = Depends(get_market_for_organizer),
    organizer: OrganizerProfile = Depends(get_organizer_profile),
) -> BookingRead:
    # Verify market day belongs to this market
    result = await db.execute(
        select(MarketDay).where(
            MarketDay.id == day_id,
            MarketDay.market_id == market.id,
        )
    )
    market_day = result.scalar_one_or_none()
    if not market_day:
        raise HTTPException(status_code=404, detail="Market day not found")

    booking = await create_booking(
        db=db,
        market_id=market.id,
        stall_id=body.stall_id,
        vendor_id=body.vendor_id,
        market_day_id=day_id,
        organizer=organizer,
    )
    return BookingRead.model_validate(booking)

Stall Grid Component

// frontend/src/components/market/StallGrid.tsx
import { StallRead, BookingRead } from "@/api/generated";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";

interface StallGridProps {
  stalls: StallRead[];
  bookings: BookingRead[];
  onStallClick: (stall: StallRead) => void;
}

export function StallGrid({ stalls, bookings, onStallClick }: StallGridProps) {
  const bookingByStall = new Map(
    bookings.map((b) => [b.stall_id, b])
  );

  return (
    <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
      {stalls.map((stall) => {
        const booking = bookingByStall.get(stall.id);
        const isBooked = Boolean(booking);

        return (
          <Card
            key={stall.id}
            className={`cursor-pointer transition-colors ${
              isBooked
                ? "border-green-500 bg-green-50 dark:bg-green-950"
                : "border-dashed hover:border-primary"
            }`}
            onClick={() => onStallClick(stall)}
          >
            <CardContent className="p-3 text-center">
              <p className="font-mono font-bold">{stall.label}</p>
              <Badge variant="outline" className="mt-1 text-xs">
                {stall.size_category}
              </Badge>
              {isBooked && (
                <p className="text-xs text-muted-foreground mt-1 truncate">
                  {booking.vendor_business_name}
                </p>
              )}
              {!isBooked && (
                <p className="text-xs text-muted-foreground mt-1">
                  Available
                </p>
              )}
            </CardContent>
          </Card>
        );
      })}
    </div>
  );
}

The stall grid uses color coding: green border for booked stalls, dashed border for available. Clicking an available stall opens the vendor assignment dialog. Clicking a booked stall shows the booking details. The grid is responsive: 2 columns on mobile, 4 on tablet, 6 on desktop.

The Trap

# TRAP: Not checking that the vendor has an accepted application
@router.post("/{day_id}/bookings")
async def assign_vendor(day_id: uuid.UUID, body: BookingCreate, ...):
    booking = Booking(
        stall_id=body.stall_id,
        vendor_id=body.vendor_id,  # Any UUID works
        market_day_id=day_id,
    )
    # An organizer could assign a vendor who was rejected or never applied

The create_booking service function (from Chapter 4) validates that the vendor has an accepted application for this market. The endpoint delegates to the service, not to a raw model instantiation. Business rules live in the service layer.

The Cost

FeatureBackend Endpoint CountFrontend Component Count
Stall CRUD4 endpoints3 components
Market day creation2 endpoints2 components
Booking assignment1 endpoint2 components
Total7 endpoints7 components

This is the core of Marketflow’s functionality. Seven endpoints and seven components deliver the product’s primary value: vendor and stall management. Each endpoint took 20-40 minutes to build using the AI-assisted workflow from Chapter 3 and the review checklist. Total implementation time for this chapter’s features: approximately one day.