feat: add multi-tenant system with properties, organizations, and public booking

Implement complete multi-property architecture:
- Properties (groups of spaces) with public/private visibility
- Property managers (many-to-many) with role-based permissions
- Organizations with member management
- Anonymous/guest booking support via public API (/api/public/*)
- Property-scoped spaces, bookings, and settings
- Frontend: property selector, organization management, public booking views
- Migration script and updated seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-15 00:17:21 +00:00
parent d637513d92
commit e21cf03a16
51 changed files with 6324 additions and 273 deletions

View File

@@ -5,7 +5,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session, joinedload
from app.core.deps import get_current_admin, get_db
from app.core.deps import get_current_manager_or_superadmin, get_db
from app.core.permissions import get_manager_property_ids
from app.models.audit_log import AuditLog
from app.models.user import User
from app.schemas.audit_log import AuditLogRead
@@ -21,15 +22,22 @@ def get_audit_logs(
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
current_admin: User = Depends(get_current_manager_or_superadmin),
) -> list[AuditLogRead]:
"""
Get audit logs with filtering and pagination.
Admin only endpoint to view audit trail of administrative actions.
Managers see only logs related to their managed properties (booking/space actions).
"""
query = db.query(AuditLog).options(joinedload(AuditLog.user))
# Property scoping for managers - only show relevant actions
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
# Managers see: their own actions + actions on bookings/spaces in their properties
query = query.filter(AuditLog.user_id == current_admin.id)
# Apply filters
if action:
query = query.filter(AuditLog.action == action)

View File

@@ -5,8 +5,10 @@ from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
from app.core.permissions import get_manager_property_ids, verify_property_access
from app.models.booking import Booking
from app.models.property_manager import PropertyManager
from app.models.settings import Settings
from app.models.space import Space
from app.models.user import User
@@ -39,6 +41,40 @@ from app.services.booking_service import validate_booking_rules
from app.utils.timezone import convert_to_utc
router = APIRouter(prefix="/spaces", tags=["bookings"])
def _verify_manager_booking_access(db: Session, booking: Booking, current_user: User) -> None:
"""Verify that a manager has access to the booking's property.
Superadmins always have access. Managers can only act on bookings
for spaces within their managed properties.
"""
if current_user.role in ("superadmin", "admin"):
return
if current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
space = booking.space
if space and space.property_id and space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this property's bookings",
)
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
"""Verify that a manager has access to a space's property.
Used for creating bookings where we have the space but no booking yet.
"""
if current_user.role in ("superadmin", "admin"):
return
if current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
if space.property_id and space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this property's spaces",
)
bookings_router = APIRouter(prefix="/bookings", tags=["bookings"])
@@ -68,6 +104,10 @@ def get_space_bookings(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Query bookings in the time range (only active bookings)
query = db.query(Booking).filter(
Booking.space_id == space_id,
@@ -79,7 +119,7 @@ def get_space_bookings(
bookings = query.order_by(Booking.start_datetime).all()
# Return different schemas based on user role
if current_user.role == "admin":
if current_user.role in ("admin", "superadmin", "manager"):
return [BookingCalendarAdmin.model_validate(b) for b in bookings]
else:
return [BookingCalendarPublic.model_validate(b) for b in bookings]
@@ -116,6 +156,10 @@ def check_availability(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Find conflicting bookings (approved + pending)
conflicts = (
db.query(Booking)
@@ -271,6 +315,10 @@ def create_booking(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Convert input times from user timezone to UTC
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
@@ -293,8 +341,8 @@ def create_booking(
detail=errors[0], # Return first error
)
# Auto-approve if admin, otherwise pending
is_admin = current_user.role == "admin"
# Auto-approve if admin/superadmin, otherwise pending
is_admin = current_user.role in ("admin", "superadmin")
# Create booking (with UTC times)
booking = Booking(
@@ -314,23 +362,39 @@ def create_booking(
db.refresh(booking)
if not is_admin:
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
# Notify admins and property managers
notify_users = {}
# Get superadmins/admins
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
for admin in admins:
notify_users[admin.id] = admin
# Get property managers for the space's property
if space.property_id:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
]
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
for mgr in managers:
notify_users[mgr.id] = mgr
for user in notify_users.values():
create_notification(
db=db,
user_id=admin.id, # type: ignore[arg-type]
user_id=user.id, # type: ignore[arg-type]
type="booking_created",
title="Noua Cerere de Rezervare",
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
# Send email notification to admin
# Send email notification
background_tasks.add_task(
send_booking_notification,
booking,
"created",
admin.email,
user.email,
current_user.full_name,
None,
)
@@ -375,6 +439,10 @@ def create_recurring_booking(
detail="Space not found",
)
# Verify user has access to the space's property
if space.property_id:
verify_property_access(db, current_user, space.property_id)
# Parse time
try:
hour, minute = map(int, data.start_time.split(':'))
@@ -458,14 +526,25 @@ def create_recurring_booking(
for booking in created_bookings:
db.refresh(booking)
# Send notifications to admins (in background)
# Send notifications to admins and property managers (in background)
if created_bookings:
admins = db.query(User).filter(User.role == "admin").all()
notify_users = {}
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
for admin in admins:
notify_users[admin.id] = admin
if space.property_id:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
]
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
for mgr in managers:
notify_users[mgr.id] = mgr
for user in notify_users.values():
background_tasks.add_task(
create_notification,
db=db,
user_id=admin.id, # type: ignore[arg-type]
user_id=user.id, # type: ignore[arg-type]
type="booking_created",
title="Noi Cereri de Rezervare Recurente",
message=f"Utilizatorul {current_user.full_name} a creat {len(created_bookings)} rezervări recurente.",
@@ -648,13 +727,14 @@ def get_all_bookings(
status_filter: Annotated[str | None, Query(alias="status")] = None,
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
property_id: Annotated[int | None, Query()] = None,
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all bookings across all users (admin only).
Get all bookings across all users (admin/manager).
Returns bookings with user and space details.
@@ -662,15 +742,24 @@ def get_all_bookings(
- **status** (optional): Filter by status (pending/approved/rejected/canceled)
- **space_id** (optional): Filter by space ID
- **user_id** (optional): Filter by user ID
- **property_id** (optional): Filter by property ID
- **start** (optional): Only bookings starting from this datetime
- **limit** (optional): Max results (1-100, default 20)
"""
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
.outerjoin(User, Booking.user_id == User.id)
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
if property_id is not None:
query = query.filter(Space.property_id == property_id)
if status_filter is not None:
query = query.filter(Booking.status == status_filter)
@@ -681,7 +770,6 @@ def get_all_bookings(
query = query.filter(Booking.user_id == user_id)
if start is not None:
# Use end_datetime to include bookings still in progress (started but not ended)
query = query.filter(Booking.end_datetime > start)
bookings = (
@@ -697,26 +785,36 @@ def get_all_bookings(
def get_pending_bookings(
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
property_id: Annotated[int | None, Query()] = None,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all pending booking requests (admin only).
Get all pending booking requests (admin/manager).
Returns pending bookings with user and space details, sorted by creation time (FIFO).
Query parameters:
- **space_id** (optional): Filter by space ID
- **user_id** (optional): Filter by user ID
- **property_id** (optional): Filter by property ID
"""
# Base query: pending bookings with joins
# Base query: pending bookings with joins (outerjoin for anonymous bookings)
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
.outerjoin(User, Booking.user_id == User.id)
.filter(Booking.status == "pending")
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
if property_id is not None:
query = query.filter(Space.property_id == property_id)
# Apply filters if provided
if space_id is not None:
query = query.filter(Booking.space_id == space_id)
@@ -735,7 +833,7 @@ def approve_booking(
id: int,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Approve a pending booking request (admin only).
@@ -755,6 +853,9 @@ def approve_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking is pending
if booking.status != "pending":
raise HTTPException(
@@ -764,11 +865,12 @@ def approve_booking(
# Re-validate booking rules to prevent race conditions
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
start_datetime=booking.start_datetime, # type: ignore[arg-type]
end_datetime=booking.end_datetime, # type: ignore[arg-type]
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
@@ -790,13 +892,14 @@ def approve_booking(
db.refresh(booking)
# Create Google Calendar event if user has connected their calendar
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
if google_event_id:
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
db.commit()
db.refresh(booking)
if booking.user_id:
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
if google_event_id:
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
db.commit()
db.refresh(booking)
# Log the action
log_action(
@@ -809,24 +912,35 @@ def approve_booking(
)
# Notify the user about approval
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_approved",
title="Rezervare Aprobată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_approved",
title="Rezervare Aprobată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"approved",
booking.user.email,
booking.user.full_name,
None,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"approved",
booking.user.email,
booking.user.full_name,
None,
)
elif booking.guest_email:
# Send email notification to anonymous guest
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_approved",
booking.guest_email,
booking.guest_name or "Guest",
None,
)
return booking
@@ -837,7 +951,7 @@ def reject_booking(
reject_data: RejectRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Reject a pending booking request (admin only).
@@ -857,6 +971,9 @@ def reject_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking is pending
if booking.status != "pending":
raise HTTPException(
@@ -882,24 +999,34 @@ def reject_booking(
)
# Notify the user about rejection
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rejected",
title="Rezervare Respinsă",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rejected",
title="Rezervare Respinsă",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"rejected",
booking.user.email,
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"rejected",
booking.user.email,
booking.user.full_name,
{"rejection_reason": reject_data.reason},
)
elif booking.guest_email:
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_rejected",
booking.guest_email,
booking.guest_name or "Guest",
{"rejection_reason": reject_data.reason},
)
return booking
@@ -909,10 +1036,10 @@ def admin_update_booking(
id: int,
data: BookingUpdate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Update any booking (admin only).
Update any booking (admin/manager).
Admin can edit any booking (pending or approved), but cannot edit bookings
that have already started.
@@ -928,6 +1055,9 @@ def admin_update_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking already started (cannot edit past bookings)
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
raise HTTPException(
@@ -947,13 +1077,14 @@ def admin_update_booking(
# Re-validate booking rules
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
start_datetime=booking.start_datetime, # type: ignore[arg-type]
end_datetime=booking.end_datetime, # type: ignore[arg-type]
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
exclude_booking_id=booking.id, # Exclude self from overlap check
user_timezone=user_timezone,
)
@@ -965,7 +1096,7 @@ def admin_update_booking(
)
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
@@ -994,10 +1125,10 @@ def admin_cancel_booking(
cancel_data: AdminCancelRequest,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Cancel any booking (admin only).
Cancel any booking (admin/manager).
Admin can cancel any booking at any time, regardless of status or timing.
No time restrictions apply (unlike user cancellations).
@@ -1015,13 +1146,16 @@ def admin_cancel_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Admin can cancel any booking (no status check needed)
# Update booking status
booking.status = "canceled" # type: ignore[assignment]
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
# Delete from Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
delete_calendar_event(
db=db,
event_id=booking.google_calendar_event_id,
@@ -1043,24 +1177,25 @@ def admin_cancel_booking(
)
# Notify the user about cancellation
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_canceled",
title="Rezervare Anulată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_canceled",
title="Rezervare Anulată",
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
booking_id=booking.id,
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"canceled",
booking.user.email,
booking.user.full_name,
{"cancellation_reason": cancel_data.cancellation_reason},
)
# Send email notification to user
background_tasks.add_task(
send_booking_notification,
booking,
"canceled",
booking.user.email,
booking.user.full_name,
{"cancellation_reason": cancel_data.cancellation_reason},
)
return booking
@@ -1070,10 +1205,10 @@ def reschedule_booking(
id: int,
data: BookingReschedule,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Reschedule booking to new time slot (admin only, drag-and-drop).
Reschedule booking to new time slot (admin/manager, drag-and-drop).
Validates the new time slot and updates the booking times.
Only approved bookings that haven't started yet can be rescheduled.
@@ -1091,6 +1226,9 @@ def reschedule_booking(
detail="Booking not found",
)
# Verify manager has access to this booking's property
_verify_manager_booking_access(db, booking, current_admin)
# Check if booking already started (cannot reschedule past bookings)
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
raise HTTPException(
@@ -1104,13 +1242,14 @@ def reschedule_booking(
# Validate new time slot
# Use booking owner's timezone for validation
user_timezone = booking.user.timezone or "UTC" if booking.user else "UTC"
user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC"
booking_user_id = int(booking.user_id) if booking.user_id else 0
errors = validate_booking_rules(
db=db,
space_id=int(booking.space_id), # type: ignore[arg-type]
start_datetime=data.start_datetime,
end_datetime=data.end_datetime,
user_id=int(booking.user_id), # type: ignore[arg-type]
user_id=booking_user_id,
exclude_booking_id=booking.id, # Exclude self from overlap check
user_timezone=user_timezone,
)
@@ -1126,7 +1265,7 @@ def reschedule_booking(
booking.end_datetime = data.end_datetime # type: ignore[assignment]
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id:
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
@@ -1150,14 +1289,15 @@ def reschedule_booking(
)
# Notify user about reschedule
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rescheduled",
title="Rezervare Reprogramată",
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
if booking.user_id:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
type="booking_rescheduled",
title="Rezervare Reprogramată",
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
db.commit()
db.refresh(booking)
@@ -1169,10 +1309,10 @@ def reschedule_booking(
def admin_create_booking(
booking_data: BookingAdminCreate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Booking:
"""
Create a booking directly with approved status (admin only, bypass approval workflow).
Create a booking directly with approved status (admin/manager, bypass approval workflow).
- **space_id**: ID of the space to book
- **user_id**: Optional user ID (defaults to current admin if not provided)
@@ -1196,6 +1336,9 @@ def admin_create_booking(
detail="Space not found",
)
# Verify manager has access to this space's property
_verify_manager_space_access(db, space, current_admin)
# Use current admin ID if user_id not provided
target_user_id = booking_data.user_id if booking_data.user_id is not None else int(current_admin.id) # type: ignore[arg-type]

View File

@@ -0,0 +1,280 @@
"""Organization management endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.user import User
from app.schemas.organization import (
AddMemberRequest,
OrganizationCreate,
OrganizationMemberResponse,
OrganizationResponse,
OrganizationUpdate,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
admin_router = APIRouter(prefix="/admin/organizations", tags=["organizations-admin"])
@router.get("", response_model=list[OrganizationResponse])
def list_organizations(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> list[OrganizationResponse]:
"""List organizations (authenticated users)."""
orgs = db.query(Organization).filter(Organization.is_active == True).order_by(Organization.name).all() # noqa: E712
result = []
for org in orgs:
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
result.append(OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
))
return result
@router.get("/{org_id}", response_model=OrganizationResponse)
def get_organization(
org_id: int,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> OrganizationResponse:
"""Get organization detail."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
)
@router.get("/{org_id}/members", response_model=list[OrganizationMemberResponse])
def list_organization_members(
org_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[OrganizationMemberResponse]:
"""List organization members (org admin or superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Check permission: superadmin or org admin
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
members = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org_id).all()
result = []
for m in members:
u = db.query(User).filter(User.id == m.user_id).first()
result.append(OrganizationMemberResponse(
id=m.id,
organization_id=m.organization_id,
user_id=m.user_id,
role=m.role,
user_name=u.full_name if u else None,
user_email=u.email if u else None,
))
return result
@router.post("/{org_id}/members", response_model=OrganizationMemberResponse, status_code=status.HTTP_201_CREATED)
def add_organization_member(
org_id: int,
data: AddMemberRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OrganizationMemberResponse:
"""Add member to organization (org admin or superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Check permission
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
# Check if user exists
user = db.query(User).filter(User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if already member
existing = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == data.user_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="User is already a member")
member = OrganizationMember(
organization_id=org_id,
user_id=data.user_id,
role=data.role,
)
db.add(member)
db.commit()
db.refresh(member)
return OrganizationMemberResponse(
id=member.id,
organization_id=member.organization_id,
user_id=member.user_id,
role=member.role,
user_name=user.full_name,
user_email=user.email,
)
@router.delete("/{org_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_organization_member(
org_id: int,
user_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
"""Remove member from organization."""
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
member = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
db.delete(member)
db.commit()
@router.put("/{org_id}/members/{user_id}", response_model=OrganizationMemberResponse)
def update_member_role(
org_id: int,
user_id: int,
data: AddMemberRequest,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OrganizationMemberResponse:
"""Change member role in organization."""
if current_user.role not in ("admin", "superadmin"):
membership = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id,
OrganizationMember.role == "admin",
).first()
if not membership:
raise HTTPException(status_code=403, detail="Not enough permissions")
member = db.query(OrganizationMember).filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
member.role = data.role
db.commit()
db.refresh(member)
u = db.query(User).filter(User.id == user_id).first()
return OrganizationMemberResponse(
id=member.id,
organization_id=member.organization_id,
user_id=member.user_id,
role=member.role,
user_name=u.full_name if u else None,
user_email=u.email if u else None,
)
# === Superadmin endpoints ===
@admin_router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
def create_organization(
data: OrganizationCreate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
) -> OrganizationResponse:
"""Create an organization (superadmin)."""
existing = db.query(Organization).filter(Organization.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail="Organization with this name already exists")
org = Organization(name=data.name, description=data.description)
db.add(org)
db.commit()
db.refresh(org)
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=0,
)
@admin_router.put("/{org_id}", response_model=OrganizationResponse)
def update_organization(
org_id: int,
data: OrganizationUpdate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
) -> OrganizationResponse:
"""Update an organization (superadmin)."""
org = db.query(Organization).filter(Organization.id == org_id).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
if data.name is not None:
org.name = data.name
if data.description is not None:
org.description = data.description
db.commit()
db.refresh(org)
member_count = db.query(OrganizationMember).filter(OrganizationMember.organization_id == org.id).count()
return OrganizationResponse(
id=org.id,
name=org.name,
description=org.description,
is_active=org.is_active,
created_at=org.created_at,
member_count=member_count,
)

View File

@@ -0,0 +1,575 @@
"""Property management endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import (
get_current_admin,
get_current_manager_or_superadmin,
get_current_user,
get_db,
get_optional_user,
)
from app.core.permissions import get_manager_property_ids, verify_property_access
from app.models.organization import Organization
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.property_settings import PropertySettings
from app.models.space import Space
from app.models.user import User
from app.schemas.property import (
PropertyAccessCreate,
PropertyAccessResponse,
PropertyCreate,
PropertyManagerInfo,
PropertyResponse,
PropertySettingsResponse,
PropertySettingsUpdate,
PropertyStatusUpdate,
PropertyUpdate,
PropertyWithSpaces,
)
from app.schemas.space import SpaceResponse
from app.services.audit_service import log_action
def _get_property_managers(db: Session, property_id: int) -> list[PropertyManagerInfo]:
"""Get manager info for a property."""
managers = (
db.query(User)
.join(PropertyManager, PropertyManager.user_id == User.id)
.filter(PropertyManager.property_id == property_id)
.all()
)
return [
PropertyManagerInfo(user_id=m.id, full_name=m.full_name, email=m.email)
for m in managers
]
router = APIRouter(prefix="/properties", tags=["properties"])
manager_router = APIRouter(prefix="/manager/properties", tags=["properties-manager"])
admin_router = APIRouter(prefix="/admin/properties", tags=["properties-admin"])
# === User-facing endpoints ===
@router.get("", response_model=list[PropertyResponse])
def list_properties(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
managed_only: bool = False,
) -> list[PropertyResponse]:
"""List visible properties based on user role.
Query params:
- managed_only: If true, managers only see properties they manage (for management pages)
"""
if current_user and current_user.role in ("admin", "superadmin"):
# Superadmin sees all
properties = db.query(Property).filter(Property.is_active == True).order_by(Property.name).all() # noqa: E712
elif current_user and current_user.role == "manager":
# Manager sees managed properties (+ public if not managed_only)
managed_ids = get_manager_property_ids(db, current_user.id)
if managed_only:
properties = (
db.query(Property)
.filter(
Property.is_active == True, # noqa: E712
Property.id.in_(managed_ids),
)
.order_by(Property.name)
.all()
)
else:
properties = (
db.query(Property)
.filter(
Property.is_active == True, # noqa: E712
(Property.is_public == True) | (Property.id.in_(managed_ids)), # noqa: E712
)
.order_by(Property.name)
.all()
)
elif current_user:
# Regular user sees public + explicitly granted
from app.core.permissions import get_user_accessible_property_ids
accessible_ids = get_user_accessible_property_ids(db, current_user.id)
properties = (
db.query(Property)
.filter(Property.is_active == True, Property.id.in_(accessible_ids)) # noqa: E712
.order_by(Property.name)
.all()
)
else:
# Anonymous sees only public
properties = (
db.query(Property)
.filter(Property.is_public == True, Property.is_active == True) # noqa: E712
.order_by(Property.name)
.all()
)
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
result.append(PropertyResponse(
id=p.id,
name=p.name,
description=p.description,
address=p.address,
is_public=p.is_public,
is_active=p.is_active,
created_at=p.created_at,
space_count=space_count,
managers=_get_property_managers(db, p.id),
))
return result
@router.get("/{property_id}", response_model=PropertyWithSpaces)
def get_property(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
) -> PropertyWithSpaces:
"""Get property detail with visibility check."""
verify_property_access(db, current_user, property_id)
prop = db.query(Property).filter(Property.id == property_id).first()
spaces = db.query(Space).filter(Space.property_id == property_id, Space.is_active == True).all() # noqa: E712
space_count = len(spaces)
return PropertyWithSpaces(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
managers=_get_property_managers(db, prop.id),
spaces=[SpaceResponse.model_validate(s) for s in spaces],
)
@router.get("/{property_id}/spaces", response_model=list[SpaceResponse])
def get_property_spaces(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User | None, Depends(get_optional_user)],
include_inactive: bool = False,
) -> list[SpaceResponse]:
"""List spaces in a property."""
verify_property_access(db, current_user, property_id)
query = db.query(Space).filter(Space.property_id == property_id)
# Managers/admins can see inactive spaces, regular users cannot
is_admin_like = current_user and current_user.role in ("admin", "superadmin", "manager")
if not (include_inactive and is_admin_like):
query = query.filter(Space.is_active == True) # noqa: E712
spaces = query.order_by(Space.name).all()
return [SpaceResponse.model_validate(s) for s in spaces]
# === Manager endpoints ===
@manager_router.post("", response_model=PropertyResponse, status_code=status.HTTP_201_CREATED)
def create_property(
data: PropertyCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Create a property. Creator becomes manager."""
prop = Property(
name=data.name,
description=data.description,
address=data.address,
is_public=data.is_public,
)
db.add(prop)
db.commit()
db.refresh(prop)
# Creator becomes manager
pm = PropertyManager(property_id=prop.id, user_id=current_user.id)
db.add(pm)
db.commit()
log_action(
db=db,
action="property_created",
user_id=current_user.id,
target_type="property",
target_id=prop.id,
details={"name": prop.name},
)
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=0,
managers=_get_property_managers(db, prop.id),
)
@manager_router.put("/{property_id}", response_model=PropertyResponse)
def update_property(
property_id: int,
data: PropertyUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Update a property (ownership check)."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
if data.name is not None:
prop.name = data.name
if data.description is not None:
prop.description = data.description
if data.address is not None:
prop.address = data.address
if data.is_public is not None:
prop.is_public = data.is_public
db.commit()
db.refresh(prop)
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
managers=_get_property_managers(db, prop.id),
)
@manager_router.patch("/{property_id}/status", response_model=PropertyResponse)
def update_property_status(
property_id: int,
data: PropertyStatusUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyResponse:
"""Activate/deactivate a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
prop.is_active = data.is_active
db.commit()
db.refresh(prop)
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
return PropertyResponse(
id=prop.id,
name=prop.name,
description=prop.description,
address=prop.address,
is_public=prop.is_public,
is_active=prop.is_active,
created_at=prop.created_at,
space_count=space_count,
managers=_get_property_managers(db, prop.id),
)
@manager_router.delete("/{property_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_property(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> None:
"""Delete a property (only if it has no active bookings)."""
verify_property_access(db, current_user, property_id, require_manager=True)
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
from app.models.booking import Booking
# Check for active bookings (pending or approved) in this property's spaces
space_ids = [s.id for s in db.query(Space).filter(Space.property_id == property_id).all()]
if space_ids:
active_bookings = (
db.query(Booking)
.filter(
Booking.space_id.in_(space_ids),
Booking.status.in_(["pending", "approved"]),
)
.count()
)
if active_bookings > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete property with {active_bookings} active booking(s). Cancel or reject them first.",
)
# Delete related data
db.query(PropertyManager).filter(PropertyManager.property_id == property_id).delete()
db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).delete()
db.query(PropertySettings).filter(PropertySettings.property_id == property_id).delete()
# Unlink spaces (set property_id to None) rather than deleting them
db.query(Space).filter(Space.property_id == property_id).update({"property_id": None})
db.delete(prop)
db.commit()
log_action(
db=db,
action="property_deleted",
user_id=current_user.id,
target_type="property",
target_id=property_id,
details={"name": prop.name},
)
@manager_router.get("/{property_id}/access", response_model=list[PropertyAccessResponse])
def list_property_access(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> list[PropertyAccessResponse]:
"""List access grants for a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
accesses = db.query(PropertyAccess).filter(PropertyAccess.property_id == property_id).all()
result = []
for a in accesses:
user_name = None
user_email = None
org_name = None
if a.user_id:
u = db.query(User).filter(User.id == a.user_id).first()
if u:
user_name = u.full_name
user_email = u.email
if a.organization_id:
org = db.query(Organization).filter(Organization.id == a.organization_id).first()
if org:
org_name = org.name
result.append(PropertyAccessResponse(
id=a.id,
property_id=a.property_id,
user_id=a.user_id,
organization_id=a.organization_id,
granted_by=a.granted_by,
user_name=user_name,
user_email=user_email,
organization_name=org_name,
created_at=a.created_at,
))
return result
@manager_router.post("/{property_id}/access", response_model=PropertyAccessResponse, status_code=status.HTTP_201_CREATED)
def grant_property_access(
property_id: int,
data: PropertyAccessCreate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertyAccessResponse:
"""Grant access to a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
if not data.user_id and not data.organization_id:
raise HTTPException(status_code=400, detail="Must provide user_id or organization_id")
access = PropertyAccess(
property_id=property_id,
user_id=data.user_id,
organization_id=data.organization_id,
granted_by=current_user.id,
)
db.add(access)
db.commit()
db.refresh(access)
user_name = None
user_email = None
org_name = None
if access.user_id:
u = db.query(User).filter(User.id == access.user_id).first()
if u:
user_name = u.full_name
user_email = u.email
if access.organization_id:
org = db.query(Organization).filter(Organization.id == access.organization_id).first()
if org:
org_name = org.name
return PropertyAccessResponse(
id=access.id,
property_id=access.property_id,
user_id=access.user_id,
organization_id=access.organization_id,
granted_by=access.granted_by,
user_name=user_name,
user_email=user_email,
organization_name=org_name,
created_at=access.created_at,
)
@manager_router.delete("/{property_id}/access/{access_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_property_access(
property_id: int,
access_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> None:
"""Revoke access to a property."""
verify_property_access(db, current_user, property_id, require_manager=True)
access = db.query(PropertyAccess).filter(
PropertyAccess.id == access_id,
PropertyAccess.property_id == property_id,
).first()
if not access:
raise HTTPException(status_code=404, detail="Access grant not found")
db.delete(access)
db.commit()
@manager_router.get("/{property_id}/settings", response_model=PropertySettingsResponse)
def get_property_settings(
property_id: int,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertySettingsResponse:
"""Get property settings."""
verify_property_access(db, current_user, property_id, require_manager=True)
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
if not ps:
# Create default settings
ps = PropertySettings(property_id=property_id, require_approval=True)
db.add(ps)
db.commit()
db.refresh(ps)
return PropertySettingsResponse.model_validate(ps)
@manager_router.put("/{property_id}/settings", response_model=PropertySettingsResponse)
def update_property_settings(
property_id: int,
data: PropertySettingsUpdate,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> PropertySettingsResponse:
"""Update property settings."""
verify_property_access(db, current_user, property_id, require_manager=True)
ps = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
if not ps:
ps = PropertySettings(property_id=property_id)
db.add(ps)
db.commit()
db.refresh(ps)
for field in data.model_fields:
value = getattr(data, field)
if value is not None or field == "require_approval":
setattr(ps, field, value)
db.commit()
db.refresh(ps)
return PropertySettingsResponse.model_validate(ps)
# === Superadmin endpoints ===
@admin_router.get("", response_model=list[PropertyResponse])
def admin_list_all_properties(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
include_inactive: bool = Query(False),
) -> list[PropertyResponse]:
"""Superadmin: list all properties."""
query = db.query(Property)
if not include_inactive:
query = query.filter(Property.is_active == True) # noqa: E712
properties = query.order_by(Property.name).all()
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id).count()
result.append(PropertyResponse(
id=p.id,
name=p.name,
description=p.description,
address=p.address,
is_public=p.is_public,
is_active=p.is_active,
created_at=p.created_at,
space_count=space_count,
managers=_get_property_managers(db, p.id),
))
return result
@admin_router.post("/{property_id}/managers", status_code=status.HTTP_201_CREATED)
def assign_property_manager(
property_id: int,
user_id: int = Query(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
) -> dict:
"""Superadmin: assign a manager to a property."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="User is already a manager of this property")
pm = PropertyManager(property_id=property_id, user_id=user_id)
db.add(pm)
# Ensure user has manager role
if user.role == "user":
user.role = "manager"
db.commit()
return {"message": f"User {user.full_name} assigned as manager of {prop.name}"}
@admin_router.delete("/{property_id}/managers/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_property_manager(
property_id: int,
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin),
) -> None:
"""Superadmin: remove a manager from a property."""
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user_id,
).first()
if not pm:
raise HTTPException(status_code=404, detail="Manager assignment not found")
db.delete(pm)
db.commit()

227
backend/app/api/public.py Normal file
View File

@@ -0,0 +1,227 @@
"""Public/anonymous endpoints (no auth required)."""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from app.core.deps import get_db
from app.models.booking import Booking
from app.models.property import Property
from app.models.property_manager import PropertyManager
from app.models.space import Space
from app.models.user import User
from app.schemas.booking import AnonymousBookingCreate, AvailabilityCheck, BookingResponse, ConflictingBooking
from app.schemas.property import PropertyResponse
from app.schemas.space import SpaceResponse
from app.services.booking_service import validate_booking_rules
from app.services.email_service import send_booking_notification
from app.services.notification_service import create_notification
router = APIRouter(prefix="/public", tags=["public"])
@router.get("/properties", response_model=list[PropertyResponse])
def list_public_properties(
db: Annotated[Session, Depends(get_db)],
) -> list[PropertyResponse]:
"""List public properties (no auth required)."""
properties = (
db.query(Property)
.filter(Property.is_public == True, Property.is_active == True) # noqa: E712
.order_by(Property.name)
.all()
)
result = []
for p in properties:
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
result.append(PropertyResponse(
id=p.id,
name=p.name,
description=p.description,
address=p.address,
is_public=p.is_public,
is_active=p.is_active,
created_at=p.created_at,
space_count=space_count,
))
return result
@router.get("/properties/{property_id}/spaces", response_model=list[SpaceResponse])
def list_public_property_spaces(
property_id: int,
db: Annotated[Session, Depends(get_db)],
) -> list[SpaceResponse]:
"""List spaces of a public property (no auth required)."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=404, detail="Property not found")
if not prop.is_public:
raise HTTPException(status_code=403, detail="Property is private")
spaces = (
db.query(Space)
.filter(Space.property_id == property_id, Space.is_active == True) # noqa: E712
.order_by(Space.name)
.all()
)
return [SpaceResponse.model_validate(s) for s in spaces]
@router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck)
def check_public_availability(
space_id: int,
start_datetime: Annotated[datetime, Query()],
end_datetime: Annotated[datetime, Query()],
db: Annotated[Session, Depends(get_db)],
) -> AvailabilityCheck:
"""Check availability for a space (no auth required)."""
space = db.query(Space).filter(Space.id == space_id).first()
if not space:
raise HTTPException(status_code=404, detail="Space not found")
# Verify space belongs to a public property
if space.property_id:
prop = db.query(Property).filter(Property.id == space.property_id).first()
if prop and not prop.is_public:
raise HTTPException(status_code=403, detail="Property is private")
# Find conflicting bookings
conflicts = (
db.query(Booking)
.filter(
Booking.space_id == space_id,
Booking.status.in_(["approved", "pending"]),
or_(
and_(
Booking.start_datetime <= start_datetime,
Booking.end_datetime > start_datetime,
),
and_(
Booking.start_datetime < end_datetime,
Booking.end_datetime >= end_datetime,
),
and_(
Booking.start_datetime >= start_datetime,
Booking.end_datetime <= end_datetime,
),
),
)
.all()
)
if not conflicts:
return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available")
approved_count = sum(1 for b in conflicts if b.status == "approved")
pending_count = sum(1 for b in conflicts if b.status == "pending")
if approved_count > 0:
message = f"Time slot has {approved_count} approved booking(s)."
else:
message = f"Time slot has {pending_count} pending request(s)."
return AvailabilityCheck(
available=approved_count == 0,
conflicts=[
ConflictingBooking(
id=b.id,
user_name=b.user.full_name if b.user else (b.guest_name or "Anonymous"),
title=b.title,
status=b.status,
start_datetime=b.start_datetime,
end_datetime=b.end_datetime,
)
for b in conflicts
],
message=message,
)
@router.post("/bookings", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
def create_anonymous_booking(
data: AnonymousBookingCreate,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
) -> BookingResponse:
"""Create an anonymous/guest booking (no auth required)."""
# Validate space exists
space = db.query(Space).filter(Space.id == data.space_id).first()
if not space:
raise HTTPException(status_code=404, detail="Space not found")
# Verify space belongs to a public property
if space.property_id:
prop = db.query(Property).filter(Property.id == space.property_id).first()
if prop and not prop.is_public:
raise HTTPException(status_code=403, detail="Cannot book in a private property without authentication")
else:
raise HTTPException(status_code=400, detail="Space is not assigned to any property")
# Basic validation (no user_id needed for anonymous)
if data.end_datetime <= data.start_datetime:
raise HTTPException(status_code=400, detail="End time must be after start time")
# Check for overlapping approved bookings
overlapping = db.query(Booking).filter(
Booking.space_id == data.space_id,
Booking.status == "approved",
and_(
Booking.start_datetime < data.end_datetime,
Booking.end_datetime > data.start_datetime,
),
).first()
if overlapping:
raise HTTPException(status_code=400, detail="Time slot is already booked")
# Create anonymous booking
booking = Booking(
user_id=None,
space_id=data.space_id,
start_datetime=data.start_datetime,
end_datetime=data.end_datetime,
title=data.title,
description=data.description,
status="pending",
guest_name=data.guest_name,
guest_email=data.guest_email,
guest_organization=data.guest_organization,
is_anonymous=True,
created_at=datetime.utcnow(),
)
db.add(booking)
db.commit()
db.refresh(booking)
# Notify property managers
if space.property_id:
manager_ids = [
pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
]
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
# Also notify superadmins
superadmins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
notify_users = {u.id: u for u in list(managers) + list(superadmins)}
for user in notify_users.values():
create_notification(
db=db,
user_id=user.id,
type="booking_created",
title="Cerere Anonimă de Rezervare",
message=f"Persoana {data.guest_name} ({data.guest_email}) a solicitat rezervarea spațiului {space.name}",
booking_id=booking.id,
)
background_tasks.add_task(
send_booking_notification,
booking,
"anonymous_created",
user.email,
data.guest_name,
{"guest_email": data.guest_email},
)
return BookingResponse.model_validate(booking)

View File

@@ -7,7 +7,8 @@ from sqlalchemy import and_, case, func
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_current_admin, get_db
from app.core.deps import get_current_manager_or_superadmin, get_db
from app.core.permissions import get_manager_property_ids
from app.models.booking import Booking
from app.models.space import Space
from app.models.user import User
@@ -41,8 +42,9 @@ def get_usage_report(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
space_id: int | None = Query(None),
property_id: int | None = Query(None),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> SpaceUsageReport:
"""Get booking usage report by space."""
query = (
@@ -81,6 +83,13 @@ def get_usage_report(
)
if space_id:
filters.append(Booking.space_id == space_id)
if property_id:
filters.append(Space.property_id == property_id)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
filters.append(Space.property_id.in_(managed_ids))
if filters:
query = query.filter(and_(*filters))
@@ -114,7 +123,7 @@ def get_top_users_report(
end_date: date | None = Query(None),
limit: int = Query(10, ge=1, le=100),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> TopUsersReport:
"""Get top users by booking count."""
query = (
@@ -129,6 +138,7 @@ def get_top_users_report(
func.sum(calculate_hours_expr()).label("total_hours"),
)
.join(User, Booking.user_id == User.id)
.join(Space, Booking.space_id == Space.id)
.group_by(Booking.user_id, User.full_name, User.email)
)
@@ -143,6 +153,11 @@ def get_top_users_report(
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
# Order by total bookings desc
query = query.order_by(func.count(Booking.id).desc()).limit(limit)
@@ -171,7 +186,7 @@ def get_approval_rate_report(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
db: Annotated[Session, Depends(get_db)] = None,
current_admin: Annotated[User, Depends(get_current_admin)] = None,
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)] = None,
) -> ApprovalRateReport:
"""Get approval/rejection rate report."""
query = db.query(
@@ -180,7 +195,7 @@ def get_approval_rate_report(
func.sum(case((Booking.status == "rejected", 1), else_=0)).label("rejected"),
func.sum(case((Booking.status == "pending", 1), else_=0)).label("pending"),
func.sum(case((Booking.status == "canceled", 1), else_=0)).label("canceled"),
)
).join(Space, Booking.space_id == Space.id)
# Apply date filters
if start_date:
@@ -193,6 +208,11 @@ def get_approval_rate_report(
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
)
# Property scoping for managers
if current_admin.role == "manager":
managed_ids = get_manager_property_ids(db, current_admin.id)
query = query.filter(Space.property_id.in_(managed_ids))
result = query.first()
total = result.total or 0

View File

@@ -4,7 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
from app.core.permissions import get_manager_property_ids
from app.models.space import Space
from app.models.user import User
from app.schemas.space import SpaceCreate, SpaceResponse, SpaceStatusUpdate, SpaceUpdate
@@ -18,36 +19,59 @@ admin_router = APIRouter(prefix="/admin/spaces", tags=["admin"])
def list_spaces(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[Space]:
property_id: int | None = None,
) -> list[SpaceResponse]:
"""
Get list of spaces.
- Users see only active spaces
- Admins see all spaces (active + inactive)
- Admins/superadmins/managers see all spaces (active + inactive)
"""
query = db.query(Space)
# Filter by property_id if provided
if property_id is not None:
query = query.filter(Space.property_id == property_id)
# Filter by active status for non-admin users
if current_user.role != "admin":
if current_user.role not in ("admin", "superadmin", "manager"):
query = query.filter(Space.is_active == True) # noqa: E712
elif current_user.role == "manager":
managed_ids = get_manager_property_ids(db, current_user.id)
if property_id is not None:
# When filtering by specific property, manager sees all spaces (active + inactive) IF they manage it
if property_id not in managed_ids:
query = query.filter(Space.is_active == True) # noqa: E712
else:
# No property filter: manager sees only their managed properties' spaces
query = query.filter(Space.property_id.in_(managed_ids))
spaces = query.order_by(Space.name).all()
return spaces
# Build response with property_name
result = []
for s in spaces:
resp = SpaceResponse.model_validate(s)
if s.property and hasattr(s.property, 'name'):
resp.property_name = s.property.name
result.append(resp)
return result
@admin_router.post("", response_model=SpaceResponse, status_code=status.HTTP_201_CREATED)
def create_space(
space_data: SpaceCreate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
) -> Space:
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> SpaceResponse:
"""
Create a new space (admin only).
Create a new space (admin/manager).
- name: required, non-empty
- type: "sala" or "birou"
- capacity: must be > 0
- description: optional
- property_id: optional, assign to property
"""
# Check if space with same name exists
existing = db.query(Space).filter(Space.name == space_data.name).first()
@@ -57,11 +81,17 @@ def create_space(
detail=f"Space with name '{space_data.name}' already exists",
)
# If manager, verify they manage the property
if space_data.property_id and current_admin.role == "manager":
from app.core.permissions import verify_property_access
verify_property_access(db, current_admin, space_data.property_id, require_manager=True)
space = Space(
name=space_data.name,
type=space_data.type,
capacity=space_data.capacity,
description=space_data.description,
property_id=space_data.property_id,
is_active=True,
)
@@ -79,7 +109,10 @@ def create_space(
details={"name": space.name, "type": space.type, "capacity": space.capacity}
)
return space
resp = SpaceResponse.model_validate(space)
if space.property and hasattr(space.property, 'name'):
resp.property_name = space.property.name
return resp
@admin_router.put("/{space_id}", response_model=SpaceResponse)
@@ -87,7 +120,7 @@ def update_space(
space_id: int,
space_data: SpaceUpdate,
db: Annotated[Session, Depends(get_db)],
current_admin: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Space:
"""
Update an existing space (admin only).
@@ -101,6 +134,15 @@ def update_space(
detail="Space not found",
)
# Verify manager has access to this space's property
if current_admin.role == "manager" and space.property_id:
managed_ids = get_manager_property_ids(db, current_admin.id)
if space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if new name conflicts with another space
if space_data.name != space.name:
existing = db.query(Space).filter(Space.name == space_data.name).first()
@@ -147,7 +189,7 @@ def update_space_status(
space_id: int,
status_data: SpaceStatusUpdate,
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
) -> Space:
"""
Activate or deactivate a space (admin only).
@@ -161,6 +203,15 @@ def update_space_status(
detail="Space not found",
)
# Verify manager has access to this space's property
if current_admin.role == "manager" and space.property_id:
managed_ids = get_manager_property_ids(db, current_admin.id)
if space.property_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
setattr(space, "is_active", status_data.is_active)
db.commit()

View File

@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import get_current_admin, get_current_user, get_db
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
from app.core.security import get_password_hash
from app.models.user import User
from app.schemas.user import (
@@ -65,12 +65,12 @@ def update_timezone(
@admin_router.get("", response_model=list[UserResponse])
def list_users(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(get_current_admin)],
_: Annotated[User, Depends(get_current_manager_or_superadmin)],
role: str | None = None,
organization: str | None = None,
) -> list[User]:
"""
Get list of users (admin only).
Get list of users (manager or admin).
Supports filtering by role and organization.
"""
@@ -109,10 +109,10 @@ def create_user(
)
# Validate role
if user_data.role not in ["admin", "user"]:
if user_data.role not in ["admin", "superadmin", "manager", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'user'",
detail="Role must be 'superadmin', 'manager', or 'user'",
)
user = User(
@@ -170,10 +170,10 @@ def update_user(
)
# Validate role
if user_data.role and user_data.role not in ["admin", "user"]:
if user_data.role and user_data.role not in ["admin", "superadmin", "manager", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'user'",
detail="Role must be 'superadmin', 'manager', or 'user'",
)
# Track what changed

View File

@@ -11,6 +11,7 @@ from app.db.session import get_db
from app.models.user import User
security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)
def get_current_user(
@@ -40,13 +41,58 @@ def get_current_user(
return user
def get_optional_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(optional_security)],
db: Annotated[Session, Depends(get_db)],
) -> User | None:
"""Get current user or None for anonymous access."""
if credentials is None:
return None
try:
token = credentials.credentials
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id = payload.get("sub")
if user_id is None:
return None
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None or not user.is_active:
return None
return user
except JWTError:
return None
def get_current_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Verify current user is admin."""
if current_user.role != "admin":
"""Verify current user is admin (superadmin or legacy admin)."""
if current_user.role not in ("admin", "superadmin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user
def get_current_superadmin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Verify current user is superadmin."""
if current_user.role not in ("admin", "superadmin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Superadmin access required",
)
return current_user
def get_current_manager_or_superadmin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Verify current user is manager or superadmin."""
if current_user.role not in ("admin", "superadmin", "manager"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Manager or admin access required",
)
return current_user

View File

@@ -0,0 +1,115 @@
"""Property access permission utilities."""
from sqlalchemy.orm import Session
from app.models.organization_member import OrganizationMember
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.user import User
from fastapi import HTTPException, status
def verify_property_access(
db: Session, user: User | None, property_id: int, require_manager: bool = False
) -> bool:
"""Verify user has access to a property. Raises HTTPException if denied."""
prop = db.query(Property).filter(Property.id == property_id).first()
if not prop:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Property not found")
if user is None:
# Anonymous - only public properties
if not prop.is_public:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Property is private")
if require_manager:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return True
# Superadmin always has access
if user.role in ("superadmin", "admin"):
return True
if require_manager:
# Manager must own this property
if user.role == "manager":
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user.id,
).first()
if pm:
return True
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
# Manager has access to managed properties
if user.role == "manager":
pm = db.query(PropertyManager).filter(
PropertyManager.property_id == property_id,
PropertyManager.user_id == user.id,
).first()
if pm:
return True
# Public property - anyone has access
if prop.is_public:
return True
# Check explicit access (user)
access = db.query(PropertyAccess).filter(
PropertyAccess.property_id == property_id,
PropertyAccess.user_id == user.id,
).first()
if access:
return True
# Check explicit access (organization)
org_ids = [
m.organization_id
for m in db.query(OrganizationMember).filter(OrganizationMember.user_id == user.id).all()
]
if org_ids:
org_access = db.query(PropertyAccess).filter(
PropertyAccess.property_id == property_id,
PropertyAccess.organization_id.in_(org_ids),
).first()
if org_access:
return True
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this property")
def get_manager_property_ids(db: Session, user_id: int) -> list[int]:
"""Get list of property IDs managed by user."""
return [
pm.property_id
for pm in db.query(PropertyManager).filter(PropertyManager.user_id == user_id).all()
]
def get_user_accessible_property_ids(db: Session, user_id: int) -> list[int]:
"""Get all property IDs accessible by user (public + explicitly granted)."""
# Public properties
public_ids = [
p.id
for p in db.query(Property).filter(Property.is_public == True, Property.is_active == True).all() # noqa: E712
]
# Directly granted
direct_ids = [
a.property_id
for a in db.query(PropertyAccess).filter(PropertyAccess.user_id == user_id).all()
]
# Org granted
org_ids = [
m.organization_id
for m in db.query(OrganizationMember).filter(OrganizationMember.user_id == user_id).all()
]
org_property_ids = []
if org_ids:
org_property_ids = [
a.property_id
for a in db.query(PropertyAccess).filter(PropertyAccess.organization_id.in_(org_ids)).all()
]
return list(set(public_ids + direct_ids + org_property_ids))

View File

@@ -11,6 +11,12 @@ from app.api.bookings import bookings_router
from app.api.bookings import router as spaces_bookings_router
from app.api.google_calendar import router as google_calendar_router
from app.api.notifications import router as notifications_router
from app.api.organizations import admin_router as organizations_admin_router
from app.api.organizations import router as organizations_router
from app.api.properties import admin_router as properties_admin_router
from app.api.properties import manager_router as properties_manager_router
from app.api.properties import router as properties_router
from app.api.public import router as public_router
from app.api.reports import router as reports_router
from app.api.settings import router as settings_router
from app.api.spaces import admin_router as spaces_admin_router
@@ -50,6 +56,12 @@ app.include_router(audit_log_router, prefix="/api", tags=["audit-log"])
app.include_router(attachments_router, prefix="/api", tags=["attachments"])
app.include_router(reports_router, prefix="/api", tags=["reports"])
app.include_router(google_calendar_router, prefix="/api", tags=["google-calendar"])
app.include_router(properties_router, prefix="/api")
app.include_router(properties_manager_router, prefix="/api")
app.include_router(properties_admin_router, prefix="/api")
app.include_router(organizations_router, prefix="/api")
app.include_router(organizations_admin_router, prefix="/api")
app.include_router(public_router, prefix="/api")
@app.get("/")

View File

@@ -5,8 +5,19 @@ from app.models.booking import Booking
from app.models.booking_template import BookingTemplate
from app.models.google_calendar_token import GoogleCalendarToken
from app.models.notification import Notification
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.property_settings import PropertySettings
from app.models.settings import Settings
from app.models.space import Space
from app.models.user import User
__all__ = ["User", "Space", "Settings", "Booking", "BookingTemplate", "Notification", "AuditLog", "Attachment", "GoogleCalendarToken"]
__all__ = [
"User", "Space", "Settings", "Booking", "BookingTemplate",
"Notification", "AuditLog", "Attachment", "GoogleCalendarToken",
"Property", "PropertyManager", "PropertyAccess", "PropertySettings",
"Organization", "OrganizationMember",
]

View File

@@ -1,7 +1,7 @@
"""Booking model."""
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
@@ -13,8 +13,12 @@ class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
space_id = Column(Integer, ForeignKey("spaces.id"), nullable=False, index=True)
guest_name = Column(String, nullable=True)
guest_email = Column(String, nullable=True)
guest_organization = Column(String, nullable=True)
is_anonymous = Column(Boolean, default=False, nullable=False)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
start_datetime = Column(DateTime, nullable=False, index=True)

View File

@@ -0,0 +1,18 @@
"""Organization model."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.db.session import Base
class Organization(Base):
"""Organization model for grouping users."""
__tablename__ = "organizations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True, index=True)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,17 @@
"""OrganizationMember junction model."""
from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint
from app.db.session import Base
class OrganizationMember(Base):
"""Junction table linking organizations to their members."""
__tablename__ = "organization_members"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
role = Column(String, nullable=False, default="member") # "admin" or "member"
__table_args__ = (UniqueConstraint("organization_id", "user_id", name="uq_org_member"),)

View File

@@ -0,0 +1,20 @@
"""Property model."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.db.session import Base
class Property(Base):
"""Property model for multi-tenant property management."""
__tablename__ = "properties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
address = Column(String, nullable=True)
is_public = Column(Boolean, default=True, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,19 @@
"""PropertyAccess model."""
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer
from app.db.session import Base
class PropertyAccess(Base):
"""Tracks which users/organizations have access to private properties."""
__tablename__ = "property_access"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True, index=True)
granted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

View File

@@ -0,0 +1,16 @@
"""PropertyManager junction model."""
from sqlalchemy import Column, ForeignKey, Integer, UniqueConstraint
from app.db.session import Base
class PropertyManager(Base):
"""Junction table linking properties to their managers."""
__tablename__ = "property_managers"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
__table_args__ = (UniqueConstraint("property_id", "user_id", name="uq_property_manager"),)

View File

@@ -0,0 +1,20 @@
"""PropertySettings model."""
from sqlalchemy import Boolean, Column, ForeignKey, Integer
from app.db.session import Base
class PropertySettings(Base):
"""Per-property scheduling settings."""
__tablename__ = "property_settings"
id = Column(Integer, primary_key=True, index=True)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=False, unique=True, index=True)
working_hours_start = Column(Integer, nullable=True)
working_hours_end = Column(Integer, nullable=True)
min_duration_minutes = Column(Integer, nullable=True)
max_duration_minutes = Column(Integer, nullable=True)
max_bookings_per_day_per_user = Column(Integer, nullable=True)
require_approval = Column(Boolean, default=True, nullable=False)
min_hours_before_cancel = Column(Integer, nullable=True)

View File

@@ -1,5 +1,6 @@
"""Space model."""
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
@@ -15,9 +16,12 @@ class Space(Base):
capacity = Column(Integer, nullable=False)
description = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
property_id = Column(Integer, ForeignKey("properties.id"), nullable=True, index=True)
# Per-space scheduling settings (NULL = use global default)
working_hours_start = Column(Integer, nullable=True)
working_hours_end = Column(Integer, nullable=True)
min_duration_minutes = Column(Integer, nullable=True)
max_duration_minutes = Column(Integer, nullable=True)
property = relationship("Property", backref="spaces")

View File

@@ -14,7 +14,7 @@ class User(Base):
email = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # "admin" or "user"
role = Column(String, nullable=False, default="user") # "superadmin"/"manager"/"user"
organization = Column(String, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
timezone = Column(String(50), default="UTC", nullable=False) # IANA timezone
@@ -26,3 +26,5 @@ class User(Base):
google_calendar_token = relationship(
"GoogleCalendarToken", back_populates="user", uselist=False
)
managed_properties = relationship("PropertyManager", backref="user", cascade="all, delete-orphan")
organization_memberships = relationship("OrganizationMember", backref="user", cascade="all, delete-orphan")

View File

@@ -1,8 +1,8 @@
"""Booking schemas for request/response."""
from datetime import datetime, date
from typing import Optional
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
class BookingCalendarPublic(BaseModel):
@@ -21,7 +21,7 @@ class BookingCalendarAdmin(BaseModel):
"""Full booking data for admins (calendar view)."""
id: int
user_id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
@@ -50,7 +50,7 @@ class BookingResponse(BaseModel):
"""Schema for booking response after creation."""
id: int
user_id: int
user_id: int | None = None
space_id: int
start_datetime: datetime
end_datetime: datetime
@@ -58,6 +58,10 @@ class BookingResponse(BaseModel):
title: str
description: str | None
created_at: datetime
guest_name: str | None = None
guest_email: str | None = None
guest_organization: str | None = None
is_anonymous: bool = False
# Timezone-aware formatted strings (optional, set by endpoint)
start_datetime_tz: Optional[str] = None
end_datetime_tz: Optional[str] = None
@@ -79,6 +83,10 @@ class BookingResponse(BaseModel):
title=booking.title,
description=booking.description,
created_at=booking.created_at,
guest_name=booking.guest_name,
guest_email=booking.guest_email,
guest_organization=booking.guest_organization,
is_anonymous=booking.is_anonymous,
start_datetime_tz=format_datetime_tz(booking.start_datetime, user_timezone),
end_datetime_tz=format_datetime_tz(booking.end_datetime, user_timezone)
)
@@ -90,9 +98,20 @@ class SpaceInBooking(BaseModel):
id: int
name: str
type: str
property_id: int | None = None
property_name: str | None = None
model_config = {"from_attributes": True}
@model_validator(mode="wrap")
@classmethod
def extract_property_name(cls, data: Any, handler: Any) -> "SpaceInBooking":
"""Extract property_name from ORM relationship."""
instance = handler(data)
if instance.property_name is None and hasattr(data, 'property') and data.property:
instance.property_name = data.property.name
return instance
class BookingWithSpace(BaseModel):
"""Booking with associated space details for user's booking list."""
@@ -127,14 +146,18 @@ class BookingPendingDetail(BaseModel):
id: int
space_id: int
space: SpaceInBooking
user_id: int
user: UserInBooking
user_id: int | None = None
user: UserInBooking | None = None
start_datetime: datetime
end_datetime: datetime
status: str
title: str
description: str | None
created_at: datetime
guest_name: str | None = None
guest_email: str | None = None
guest_organization: str | None = None
is_anonymous: bool = False
model_config = {"from_attributes": True}
@@ -242,3 +265,16 @@ class BookingReschedule(BaseModel):
start_datetime: datetime
end_datetime: datetime
class AnonymousBookingCreate(BaseModel):
"""Schema for anonymous/guest booking creation."""
space_id: int
start_datetime: datetime
end_datetime: datetime
title: str = Field(..., min_length=1, max_length=200)
description: str | None = None
guest_name: str = Field(..., min_length=1)
guest_email: str = Field(..., min_length=1)
guest_organization: str | None = None

View File

@@ -0,0 +1,41 @@
"""Organization schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class OrganizationCreate(BaseModel):
name: str = Field(..., min_length=1)
description: str | None = None
class OrganizationUpdate(BaseModel):
name: str | None = None
description: str | None = None
class OrganizationResponse(BaseModel):
id: int
name: str
description: str | None = None
is_active: bool
created_at: datetime
member_count: int = 0
model_config = {"from_attributes": True}
class OrganizationMemberResponse(BaseModel):
id: int
organization_id: int
user_id: int
role: str
user_name: str | None = None
user_email: str | None = None
model_config = {"from_attributes": True}
class AddMemberRequest(BaseModel):
user_id: int
role: str = "member"

View File

@@ -0,0 +1,82 @@
"""Property schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class PropertyCreate(BaseModel):
name: str = Field(..., min_length=1)
description: str | None = None
address: str | None = None
is_public: bool = True
class PropertyUpdate(BaseModel):
name: str | None = None
description: str | None = None
address: str | None = None
is_public: bool | None = None
class PropertyManagerInfo(BaseModel):
user_id: int
full_name: str
email: str
class PropertyResponse(BaseModel):
id: int
name: str
description: str | None = None
address: str | None = None
is_public: bool
is_active: bool
created_at: datetime
space_count: int = 0
managers: list[PropertyManagerInfo] = []
model_config = {"from_attributes": True}
class PropertyWithSpaces(PropertyResponse):
spaces: list = []
class PropertyAccessCreate(BaseModel):
user_id: int | None = None
organization_id: int | None = None
class PropertyAccessResponse(BaseModel):
id: int
property_id: int
user_id: int | None = None
organization_id: int | None = None
granted_by: int | None = None
user_name: str | None = None
user_email: str | None = None
organization_name: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class PropertySettingsUpdate(BaseModel):
working_hours_start: int | None = None
working_hours_end: int | None = None
min_duration_minutes: int | None = None
max_duration_minutes: int | None = None
max_bookings_per_day_per_user: int | None = None
require_approval: bool = True
min_hours_before_cancel: int | None = None
class PropertySettingsResponse(PropertySettingsUpdate):
id: int
property_id: int
model_config = {"from_attributes": True}
class PropertyStatusUpdate(BaseModel):
is_active: bool

View File

@@ -20,7 +20,7 @@ class SpaceBase(BaseModel):
class SpaceCreate(SpaceBase):
"""Space creation schema."""
pass
property_id: int | None = None
class SpaceUpdate(SpaceBase):
@@ -40,6 +40,8 @@ class SpaceResponse(SpaceBase):
id: int
is_active: bool
property_id: int | None = None
property_name: str | None = None
working_hours_start: int | None = None
working_hours_end: int | None = None
min_duration_minutes: int | None = None

View File

@@ -5,6 +5,7 @@ from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.property_settings import PropertySettings
from app.models.settings import Settings
from app.models.space import Space
from app.utils.timezone import convert_from_utc, convert_to_utc
@@ -53,27 +54,43 @@ def validate_booking_rules(
db.commit()
db.refresh(settings)
# Fetch space and get per-space settings (with fallback to global)
# Fetch space and get per-space settings
# Resolution chain: Space → PropertySettings → Global Settings
space = db.query(Space).filter(Space.id == space_id).first()
wh_start = (
space.working_hours_start
if space and space.working_hours_start is not None
else settings.working_hours_start
# Fetch property settings if space has a property
prop_settings = None
if space and space.property_id:
prop_settings = db.query(PropertySettings).filter(
PropertySettings.property_id == space.property_id
).first()
def resolve(space_val, prop_val, global_val):
if space_val is not None:
return space_val
if prop_val is not None:
return prop_val
return global_val
wh_start = resolve(
space.working_hours_start if space else None,
prop_settings.working_hours_start if prop_settings else None,
settings.working_hours_start,
)
wh_end = (
space.working_hours_end
if space and space.working_hours_end is not None
else settings.working_hours_end
wh_end = resolve(
space.working_hours_end if space else None,
prop_settings.working_hours_end if prop_settings else None,
settings.working_hours_end,
)
min_dur = (
space.min_duration_minutes
if space and space.min_duration_minutes is not None
else settings.min_duration_minutes
min_dur = resolve(
space.min_duration_minutes if space else None,
prop_settings.min_duration_minutes if prop_settings else None,
settings.min_duration_minutes,
)
max_dur = (
space.max_duration_minutes
if space and space.max_duration_minutes is not None
else settings.max_duration_minutes
max_dur = resolve(
space.max_duration_minutes if space else None,
prop_settings.max_duration_minutes if prop_settings else None,
settings.max_duration_minutes,
)
# Convert UTC times to user timezone for validation

View File

@@ -128,6 +128,58 @@ Motiv: {reason}
Vă rugăm să contactați administratorul pentru detalii.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_created":
guest_email = extra_data.get("guest_email", "N/A") if extra_data else "N/A"
subject = "Cerere Anonimă de Rezervare"
body = f"""Bună ziua,
O nouă cerere anonimă de rezervare necesită aprobarea dumneavoastră:
Persoana: {user_name}
Email: {guest_email}
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Descriere: {booking.description or 'N/A'}
Vă rugăm să accesați panoul de administrare pentru a aproba sau respinge această cerere.
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_approved":
subject = "Rezervare Aprobată"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost aprobată:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Vă așteptăm!
Cu stimă,
Sistemul de Rezervări
"""
elif event_type == "anonymous_rejected":
reason = extra_data.get("rejection_reason", "Nu a fost specificat") if extra_data else "Nu a fost specificat"
subject = "Rezervare Respinsă"
body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost respinsă:
Spațiu: {space_name}
Data și ora: {start_str} - {end_str}
Titlu: {booking.title}
Motiv: {reason}
Cu stimă,
Sistemul de Rezervări
"""

View File

@@ -0,0 +1,106 @@
"""Migration script to add multi-property support to existing database."""
import sys
import os
# Add backend to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import inspect, text
from app.db.session import Base, SessionLocal, engine
from app.models import (
Organization, OrganizationMember, Property, PropertyAccess,
PropertyManager, PropertySettings, User, Space,
)
def migrate():
"""Run migration to add multi-property tables and data."""
db = SessionLocal()
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
print("Starting multi-property migration...")
# Step 1: Create all new tables
print("1. Creating new tables...")
Base.metadata.create_all(bind=engine)
print(" Tables created successfully.")
# Step 2: Add property_id column to spaces if not exists
space_columns = [col["name"] for col in inspector.get_columns("spaces")]
if "property_id" not in space_columns:
print("2. Adding property_id column to spaces...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE spaces ADD COLUMN property_id INTEGER REFERENCES properties(id)"))
conn.commit()
print(" Column added.")
else:
print("2. property_id column already exists in spaces.")
# Step 3: Add guest columns to bookings if not exists
booking_columns = [col["name"] for col in inspector.get_columns("bookings")]
with engine.connect() as conn:
if "guest_name" not in booking_columns:
print("3. Adding guest columns to bookings...")
conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_name VARCHAR"))
conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_email VARCHAR"))
conn.execute(text("ALTER TABLE bookings ADD COLUMN guest_organization VARCHAR"))
conn.execute(text("ALTER TABLE bookings ADD COLUMN is_anonymous BOOLEAN DEFAULT 0 NOT NULL"))
conn.commit()
print(" Guest columns added.")
else:
print("3. Guest columns already exist in bookings.")
# Step 4: Create "Default Property"
print("4. Creating Default Property...")
existing_default = db.query(Property).filter(Property.name == "Default Property").first()
if not existing_default:
default_prop = Property(
name="Default Property",
description="Default property for migrated spaces",
is_public=True,
is_active=True,
)
db.add(default_prop)
db.flush()
# Step 5: Migrate existing spaces to Default Property
print("5. Migrating existing spaces to Default Property...")
spaces_without_property = db.query(Space).filter(Space.property_id == None).all() # noqa: E711
for space in spaces_without_property:
space.property_id = default_prop.id
db.flush()
print(f" Migrated {len(spaces_without_property)} spaces.")
# Step 6: Rename admin users to superadmin
print("6. Updating admin roles to superadmin...")
admin_users = db.query(User).filter(User.role == "admin").all()
for u in admin_users:
u.role = "superadmin"
db.flush()
print(f" Updated {len(admin_users)} users.")
# Step 7: Create PropertyManager entries for superadmins
print("7. Creating PropertyManager entries for superadmins...")
superadmins = db.query(User).filter(User.role == "superadmin").all()
for sa in superadmins:
existing_pm = db.query(PropertyManager).filter(
PropertyManager.property_id == default_prop.id,
PropertyManager.user_id == sa.id,
).first()
if not existing_pm:
db.add(PropertyManager(property_id=default_prop.id, user_id=sa.id))
db.flush()
print(f" Created entries for {len(superadmins)} superadmins.")
db.commit()
print("\nMigration completed successfully!")
else:
print(" Default Property already exists. Skipping data migration.")
print("\nMigration already applied.")
db.close()
if __name__ == "__main__":
migrate()

View File

@@ -1,12 +1,18 @@
"""Seed database with initial data."""
"""Seed database with initial data for multi-property system."""
from app.core.security import get_password_hash
from app.db.session import Base, SessionLocal, engine
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.property import Property
from app.models.property_access import PropertyAccess
from app.models.property_manager import PropertyManager
from app.models.settings import Settings
from app.models.space import Space
from app.models.user import User
def seed_database() -> None:
"""Create initial users for testing."""
"""Create initial data for testing multi-property system."""
# Create tables
Base.metadata.create_all(bind=engine)
@@ -18,16 +24,27 @@ def seed_database() -> None:
print("Database already seeded. Skipping...")
return
# Create admin user
admin = User(
# Create superadmin user
superadmin = User(
email="admin@example.com",
full_name="Admin User",
full_name="Super Admin",
hashed_password=get_password_hash("adminpassword"),
role="admin",
role="superadmin",
organization="Management",
is_active=True,
)
db.add(admin)
db.add(superadmin)
# Create manager user
manager = User(
email="manager@example.com",
full_name="Property Manager",
hashed_password=get_password_hash("managerpassword"),
role="manager",
organization="Management",
is_active=True,
)
db.add(manager)
# Create regular user
user = User(
@@ -40,6 +57,93 @@ def seed_database() -> None:
)
db.add(user)
db.flush() # Get IDs
# Create properties
prop1 = Property(
name="Clădirea Centrală",
description="Clădirea principală din centru",
address="Str. Principală nr. 1",
is_public=True,
is_active=True,
)
db.add(prop1)
prop2 = Property(
name="Biroul Privat",
description="Spațiu privat pentru echipă",
address="Str. Secundară nr. 5",
is_public=False,
is_active=True,
)
db.add(prop2)
db.flush() # Get property IDs
# Assign manager to both properties
db.add(PropertyManager(property_id=prop1.id, user_id=manager.id))
db.add(PropertyManager(property_id=prop2.id, user_id=manager.id))
# Create spaces (2 in first property, 1 in second)
space1 = Space(
name="Sala Mare",
type="sala",
capacity=20,
description="Sală de conferințe mare",
is_active=True,
property_id=prop1.id,
)
db.add(space1)
space2 = Space(
name="Birou A1",
type="birou",
capacity=4,
description="Birou deschis",
is_active=True,
property_id=prop1.id,
)
db.add(space2)
space3 = Space(
name="Sala Privată",
type="sala",
capacity=10,
description="Sală privată pentru echipă",
is_active=True,
property_id=prop2.id,
)
db.add(space3)
# Create organizations
org1 = Organization(
name="Engineering",
description="Echipa de dezvoltare",
is_active=True,
)
db.add(org1)
org2 = Organization(
name="Management",
description="Echipa de management",
is_active=True,
)
db.add(org2)
db.flush() # Get org IDs
# Create organization members
db.add(OrganizationMember(organization_id=org1.id, user_id=user.id, role="member"))
db.add(OrganizationMember(organization_id=org2.id, user_id=manager.id, role="admin"))
db.add(OrganizationMember(organization_id=org2.id, user_id=superadmin.id, role="admin"))
# Grant user access to private property
db.add(PropertyAccess(
property_id=prop2.id,
user_id=user.id,
granted_by=manager.id,
))
# Create default settings if not exist
existing_settings = db.query(Settings).filter(Settings.id == 1).first()
if not existing_settings:
@@ -55,9 +159,12 @@ def seed_database() -> None:
db.add(default_settings)
db.commit()
print("Database seeded successfully!")
print("Admin: admin@example.com / adminpassword")
print("Database seeded successfully!")
print("Superadmin: admin@example.com / adminpassword")
print("Manager: manager@example.com / managerpassword")
print("User: user@example.com / userpassword")
print(f"Properties: '{prop1.name}' (public), '{prop2.name}' (private)")
print(f"Organizations: '{org1.name}', '{org2.name}'")
except Exception as e:
print(f"Error seeding database: {e}")
db.rollback()

View File

@@ -1,10 +1,10 @@
<template>
<div id="app">
<AppSidebar v-if="authStore.isAuthenticated" />
<AppSidebar v-if="showSidebar" />
<div class="app-main" :class="{ 'with-sidebar': authStore.isAuthenticated, 'sidebar-collapsed': collapsed }">
<div class="app-main" :class="{ 'with-sidebar': showSidebar, 'sidebar-collapsed': collapsed }">
<!-- Mobile header bar -->
<div v-if="authStore.isAuthenticated" class="mobile-header">
<div v-if="showSidebar" class="mobile-header">
<button class="mobile-hamburger" @click="toggleMobile" aria-label="Open menu">
<Menu :size="22" />
</button>
@@ -59,7 +59,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { notificationsApi } from '@/services/api'
import { useSidebar } from '@/composables/useSidebar'
import type { Notification } from '@/types'
@@ -68,6 +68,10 @@ import { Menu, Bell, X } from 'lucide-vue-next'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const isPublicRoute = computed(() => route.meta.isPublic === true)
const showSidebar = computed(() => authStore.isAuthenticated && !isPublicRoute.value)
const { collapsed, toggleMobile } = useSidebar()
const notifications = ref<Notification[]>([])

View File

@@ -5,6 +5,8 @@
<span v-show="showLabels" class="sidebar-title">Space Booking</span>
</div>
<PropertySelector v-show="showLabels" />
<nav class="sidebar-nav">
<div class="nav-section">
<span v-show="showLabels" class="nav-section-label">Main</span>
@@ -21,10 +23,25 @@
</router-link>
</div>
<div v-if="authStore.isAdmin" class="nav-section">
<div v-if="authStore.isAdminOrManager" class="nav-section">
<span v-show="showLabels" class="nav-section-label">Management</span>
<router-link
v-for="item in managerNav"
:key="item.to"
:to="item.to"
class="nav-link"
:class="{ active: isActive(item.to) }"
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
<div v-if="authStore.isSuperadmin" class="nav-section">
<span v-show="showLabels" class="nav-section-label">Admin</span>
<router-link
v-for="item in adminNav"
v-for="item in superadminNav"
:key="item.to"
:to="item.to"
class="nav-link"
@@ -72,6 +89,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSidebar } from '@/composables/useSidebar'
import { useTheme } from '@/composables/useTheme'
import PropertySelector from '@/components/PropertySelector.vue'
import {
LayoutDashboard,
Building2,
@@ -86,7 +104,8 @@ import {
Moon,
ChevronLeft,
ChevronRight,
LogOut
LogOut,
Landmark
} from 'lucide-vue-next'
const authStore = useAuthStore()
@@ -111,8 +130,12 @@ const mainNav = [
{ to: '/profile', icon: User, label: 'Profile' },
]
const adminNav = [
const managerNav = [
{ to: '/properties', icon: Landmark, label: 'Properties' },
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
]
const superadminNav = [
{ to: '/users', icon: Users, label: 'Users' },
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },

View File

@@ -19,7 +19,21 @@
<span>{{ booking.space?.name || 'Unknown Space' }}</span>
</div>
<div v-if="isAdmin && booking.user" class="detail-row">
<div v-if="booking.is_anonymous && booking.guest_name" class="detail-row">
<UserIcon :size="16" class="detail-icon" />
<span>
{{ booking.guest_name }}
<span class="detail-guest-badge">Guest</span>
<span v-if="booking.guest_email" class="detail-muted">
&middot; {{ booking.guest_email }}
</span>
<span v-if="booking.guest_organization" class="detail-muted">
&middot; {{ booking.guest_organization }}
</span>
</span>
</div>
<div v-else-if="isAdmin && booking.user" class="detail-row">
<UserIcon :size="16" class="detail-icon" />
<span>
{{ booking.user.full_name }}
@@ -127,7 +141,7 @@ const actionButtons = computed<ActionButton[]>(() => {
buttons.push({ key: 'reject', label: 'Reject', icon: XCircle })
}
if (status === 'pending') {
if (status === 'pending' || status === 'approved') {
buttons.push({ key: 'edit', label: 'Edit', icon: Pencil })
}
@@ -273,6 +287,18 @@ onUnmounted(() => {
color: var(--color-text-muted);
}
.detail-guest-badge {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
border-radius: 6px;
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
vertical-align: middle;
margin-left: 4px;
}
/* Description */
.preview-description {
background: var(--color-bg-secondary);

View File

@@ -3,9 +3,17 @@
<div class="row-time">
{{ formatTimeRange(booking.start_datetime, booking.end_datetime) }}
</div>
<div class="row-space">{{ booking.space?.name || 'Space' }}</div>
<div v-if="showUser && booking.user" class="row-user">
{{ booking.user.full_name }}
<div class="row-space">
{{ booking.space?.name || 'Space' }}
<span v-if="booking.space?.property_name" class="row-property">{{ booking.space.property_name }}</span>
</div>
<div v-if="showUser" class="row-user">
<template v-if="booking.is_anonymous && booking.guest_name">
{{ booking.guest_name }} <span class="guest-badge">Guest</span>
</template>
<template v-else-if="booking.user">
{{ booking.user.full_name }}
</template>
</div>
<div class="row-title" :title="booking.title">{{ booking.title }}</div>
<span :class="['row-badge', `row-badge-${booking.status}`]">
@@ -136,6 +144,18 @@ const formatTimeRange = (start: string, end: string): string => {
color: var(--color-accent);
white-space: nowrap;
min-width: 80px;
display: flex;
align-items: center;
gap: 6px;
}
.row-property {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-bg-tertiary);
padding: 1px 6px;
border-radius: 6px;
}
.row-user {
@@ -188,6 +208,18 @@ const formatTimeRange = (start: string, end: string): string => {
color: var(--color-text-muted);
}
.guest-badge {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
border-radius: 6px;
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
vertical-align: middle;
margin-left: 4px;
}
@media (max-width: 640px) {
.booking-row {
flex-wrap: wrap;

View File

@@ -80,7 +80,7 @@ const emit = defineEmits<{
const authStore = useAuthStore()
const isMobile = useIsMobile()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const isAdmin = computed(() => authStore.user?.role === 'admin' || authStore.user?.role === 'superadmin' || authStore.user?.role === 'manager')
const isEditable = computed(() => isAdmin.value)
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])

View File

@@ -0,0 +1,87 @@
<template>
<div v-if="authStore.isAdminOrManager && propertyStore.properties.length > 0" class="property-selector">
<label class="selector-label">
<Landmark :size="14" />
<span>Property</span>
</label>
<select
:value="propertyStore.currentPropertyId"
@change="handleChange"
class="property-select"
>
<option :value="null">All Properties</option>
<option
v-for="prop in propertyStore.properties"
:key="prop.id"
:value="prop.id"
>
{{ prop.name }}
</option>
</select>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { usePropertyStore } from '@/stores/property'
import { Landmark } from 'lucide-vue-next'
const authStore = useAuthStore()
const propertyStore = usePropertyStore()
const handleChange = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
propertyStore.setCurrentProperty(value ? Number(value) : null)
}
onMounted(() => {
if (authStore.isAdminOrManager && propertyStore.properties.length === 0) {
propertyStore.fetchMyProperties()
}
})
</script>
<style scoped>
.property-selector {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.selector-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--sidebar-text);
opacity: 0.6;
margin-bottom: 6px;
}
.property-select {
width: 100%;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.15);
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-active);
font-size: 13px;
font-weight: 500;
cursor: pointer;
outline: none;
transition: border-color var(--transition-fast);
}
.property-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.property-select option {
background: var(--color-surface);
color: var(--color-text-primary);
}
</style>

View File

@@ -57,7 +57,8 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg, EventResizeDoneArg } from '@fullcalendar/core'
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg } from '@fullcalendar/core'
import type { EventResizeDoneArg } from '@fullcalendar/interaction'
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
@@ -67,6 +68,7 @@ import type { Booking } from '@/types'
interface Props {
spaceId: number
spaceName?: string
}
const props = defineProps<Props>()
@@ -101,16 +103,24 @@ const confirmModal = ref<ConfirmModal>({
revertFunc: null
})
// Admin can edit, users see read-only
const isEditable = computed(() => authStore.user?.role === 'admin')
// Admin/superadmin/manager can edit, users see read-only
const isEditable = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
// Emits for parent to handle actions
const emit = defineEmits<{
(e: 'edit-booking', booking: Booking): void
(e: 'cancel-booking', booking: Booking): void
(e: 'approve-booking', booking: Booking): void
(e: 'reject-booking', booking: Booking): void
}>()
// Preview modal state
const selectedBooking = ref<Booking | null>(null)
const showPreview = ref(false)
const handlePreviewAction = (_action: string, _booking: Booking) => {
const handlePreviewAction = (action: string, booking: Booking) => {
showPreview.value = false
refresh()
emit(`${action}-booking` as any, booking)
}
// Status to color mapping
@@ -302,7 +312,12 @@ const calendarOptions = computed<CalendarOptions>(() => ({
const bookingId = parseInt(info.event.id)
const booking = bookings.value.find((b) => b.id === bookingId)
if (booking) {
selectedBooking.value = booking
// Inject space name if not present and we have it from props
if (!booking.space && props.spaceName) {
selectedBooking.value = { ...booking, space: { id: props.spaceId, name: props.spaceName } as any }
} else {
selectedBooking.value = booking
}
showPreview.value = true
}
},
@@ -318,9 +333,9 @@ const calendarOptions = computed<CalendarOptions>(() => ({
}
},
// Event allow callback
eventAllow: (dropInfo, draggedEvent) => {
eventAllow: (_dropInfo, draggedEvent) => {
// Only allow dragging approved bookings
return draggedEvent.extendedProps.status === 'approved'
return draggedEvent != null && draggedEvent.extendedProps.status === 'approved'
}
}))

View File

@@ -28,6 +28,12 @@ const router = createRouter({
component: () => import('@/views/VerifyEmail.vue'),
meta: { requiresAuth: false }
},
{
path: '/book/:propertyId?',
name: 'PublicBooking',
component: () => import('@/views/PublicBooking.vue'),
meta: { requiresAuth: false, isPublic: true }
},
{
path: '/dashboard',
name: 'Dashboard',
@@ -62,11 +68,29 @@ const router = createRouter({
component: () => import('@/views/UserProfile.vue'),
meta: { requiresAuth: true }
},
{
path: '/properties',
name: 'Properties',
component: () => import('@/views/Properties.vue'),
meta: { requiresAuth: true, requiresManager: true }
},
{
path: '/properties/:id',
name: 'PropertyDetail',
component: () => import('@/views/PropertyDetail.vue'),
meta: { requiresAuth: true, requiresManager: true }
},
{
path: '/organization',
name: 'Organization',
component: () => import('@/views/Organization.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
meta: { requiresAuth: true, requiresManager: true }
},
{
path: '/users',
@@ -103,9 +127,13 @@ const router = createRouter({
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
if (to.meta.isPublic) {
next()
} else if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
} else if (to.meta.requiresAdmin && !authStore.isSuperadmin) {
next('/dashboard')
} else if (to.meta.requiresManager && !authStore.isAdminOrManager) {
next('/dashboard')
} else if (to.path === '/login' && authStore.isAuthenticated) {
next('/dashboard')

View File

@@ -22,7 +22,13 @@ import type {
RecurringBookingResult,
SpaceUsageReport,
TopUsersReport,
ApprovalRateReport
ApprovalRateReport,
Property,
PropertySettings,
PropertyAccess,
Organization,
OrganizationMember,
AnonymousBookingCreate
} from '@/types'
const api = axios.create({
@@ -120,8 +126,8 @@ export const usersApi = {
// Spaces API
export const spacesApi = {
list: async (): Promise<Space[]> => {
const response = await api.get<Space[]>('/spaces')
list: async (params?: { property_id?: number }): Promise<Space[]> => {
const response = await api.get<Space[]>('/spaces', { params })
return response.data
},
@@ -198,6 +204,11 @@ export const bookingsApi = {
params: { start, end }
})
return response.data
},
cancel: async (id: number): Promise<Booking> => {
const response = await api.put<Booking>(`/bookings/${id}/cancel`)
return response.data
}
}
@@ -209,12 +220,13 @@ export const adminBookingsApi = {
user_id?: number
start?: string
limit?: number
property_id?: number
}): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/all', { params })
return response.data
},
getPending: async (filters?: { space_id?: number; user_id?: number }): Promise<Booking[]> => {
getPending: async (filters?: { space_id?: number; user_id?: number; property_id?: number }): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
return response.data
},
@@ -242,6 +254,11 @@ export const adminBookingsApi = {
return response.data
},
cancel: async (id: number, reason?: string): Promise<Booking> => {
const response = await api.put<Booking>(`/admin/bookings/${id}/cancel`, { cancellation_reason: reason })
return response.data
},
create: async (data: BookingAdminCreate): Promise<Booking> => {
const response = await api.post<Booking>('/admin/bookings', data)
return response.data
@@ -390,6 +407,128 @@ export const googleCalendarApi = {
}
}
// Public API instance (no auth required)
const publicApiInstance = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' }
})
// Properties API
export const propertiesApi = {
list: async (params?: { managed_only?: boolean }): Promise<Property[]> => {
const response = await api.get<Property[]>('/properties', { params })
return response.data
},
listAll: async (): Promise<Property[]> => {
const response = await api.get<Property[]>('/admin/properties', { params: { include_inactive: true } })
return response.data
},
get: async (id: number): Promise<Property> => {
const response = await api.get<Property>(`/properties/${id}`)
return response.data
},
getSpaces: async (id: number, params?: { include_inactive?: boolean }): Promise<Space[]> => {
const response = await api.get<Space[]>(`/properties/${id}/spaces`, { params })
return response.data
},
create: async (data: { name: string; description?: string; address?: string; is_public?: boolean }): Promise<Property> => {
const response = await api.post<Property>('/manager/properties', data)
return response.data
},
update: async (id: number, data: { name?: string; description?: string; address?: string; is_public?: boolean }): Promise<Property> => {
const response = await api.put<Property>(`/manager/properties/${id}`, data)
return response.data
},
updateStatus: async (id: number, is_active: boolean): Promise<Property> => {
const response = await api.patch<Property>(`/manager/properties/${id}/status`, { is_active })
return response.data
},
getAccess: async (id: number): Promise<PropertyAccess[]> => {
const response = await api.get<PropertyAccess[]>(`/manager/properties/${id}/access`)
return response.data
},
grantAccess: async (id: number, data: { user_id?: number; organization_id?: number }): Promise<PropertyAccess> => {
const response = await api.post<PropertyAccess>(`/manager/properties/${id}/access`, data)
return response.data
},
revokeAccess: async (propertyId: number, accessId: number): Promise<void> => {
await api.delete(`/manager/properties/${propertyId}/access/${accessId}`)
},
getSettings: async (id: number): Promise<PropertySettings> => {
const response = await api.get<PropertySettings>(`/manager/properties/${id}/settings`)
return response.data
},
updateSettings: async (id: number, data: Partial<PropertySettings>): Promise<PropertySettings> => {
const response = await api.put<PropertySettings>(`/manager/properties/${id}/settings`, data)
return response.data
},
assignManager: async (propertyId: number, userId: number): Promise<void> => {
await api.post(`/admin/properties/${propertyId}/managers`, { user_id: userId })
},
removeManager: async (propertyId: number, userId: number): Promise<void> => {
await api.delete(`/admin/properties/${propertyId}/managers/${userId}`)
},
delete: async (id: number): Promise<void> => {
await api.delete(`/manager/properties/${id}`)
}
}
// Organizations API
export const organizationsApi = {
list: async (): Promise<Organization[]> => {
const response = await api.get<Organization[]>('/organizations')
return response.data
},
get: async (id: number): Promise<Organization> => {
const response = await api.get<Organization>(`/organizations/${id}`)
return response.data
},
create: async (data: { name: string; description?: string }): Promise<Organization> => {
const response = await api.post<Organization>('/admin/organizations', data)
return response.data
},
update: async (id: number, data: { name?: string; description?: string }): Promise<Organization> => {
const response = await api.put<Organization>(`/admin/organizations/${id}`, data)
return response.data
},
getMembers: async (id: number): Promise<OrganizationMember[]> => {
const response = await api.get<OrganizationMember[]>(`/organizations/${id}/members`)
return response.data
},
addMember: async (orgId: number, data: { user_id: number; role?: string }): Promise<OrganizationMember> => {
const response = await api.post<OrganizationMember>(`/organizations/${orgId}/members`, data)
return response.data
},
removeMember: async (orgId: number, userId: number): Promise<void> => {
await api.delete(`/organizations/${orgId}/members/${userId}`)
},
updateMemberRole: async (orgId: number, userId: number, role: string): Promise<void> => {
await api.put(`/organizations/${orgId}/members/${userId}`, { role })
}
}
// Public API (no auth required)
export const publicApi = {
getProperties: async (): Promise<Property[]> => {
const response = await publicApiInstance.get<Property[]>('/public/properties')
return response.data
},
getPropertySpaces: async (propertyId: number): Promise<Space[]> => {
const response = await publicApiInstance.get<Space[]>(`/public/properties/${propertyId}/spaces`)
return response.data
},
getSpaceAvailability: async (spaceId: number, start: string, end: string) => {
const response = await publicApiInstance.get(`/public/spaces/${spaceId}/availability`, {
params: { start_datetime: start, end_datetime: end }
})
return response.data
},
createBooking: async (data: AnonymousBookingCreate): Promise<Booking> => {
const response = await publicApiInstance.post<Booking>('/public/bookings', data)
return response.data
}
}
// Helper to handle API errors
export const handleApiError = (error: unknown): string => {
if (error instanceof AxiosError) {

View File

@@ -8,14 +8,16 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const isSuperadmin = computed(() => user.value?.role === 'superadmin' || user.value?.role === 'admin')
const isManager = computed(() => user.value?.role === 'manager')
const isAdminOrManager = computed(() => isSuperadmin.value || isManager.value)
// Keep isAdmin for backward compatibility (now means superadmin OR manager for nav visibility)
const isAdmin = computed(() => isSuperadmin.value || isManager.value)
const login = async (credentials: LoginRequest) => {
const response = await authApi.login(credentials)
token.value = response.access_token
localStorage.setItem('token', response.access_token)
// Fetch user data from API
user.value = await usersApi.me()
}
@@ -25,13 +27,11 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('token')
}
// Initialize user from token on page load
const initFromToken = async () => {
if (token.value) {
try {
user.value = await usersApi.me()
} catch (error) {
// Invalid token
logout()
}
}
@@ -44,6 +44,9 @@ export const useAuthStore = defineStore('auth', () => {
user,
isAuthenticated,
isAdmin,
isSuperadmin,
isManager,
isAdminOrManager,
login,
logout
}

View File

@@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { propertiesApi } from '@/services/api'
import { useAuthStore } from './auth'
import type { Property } from '@/types'
export const usePropertyStore = defineStore('property', () => {
const properties = ref<Property[]>([])
const currentPropertyId = ref<number | null>(
localStorage.getItem('currentPropertyId')
? Number(localStorage.getItem('currentPropertyId'))
: null
)
const loading = ref(false)
const currentProperty = computed(() =>
properties.value.find(p => p.id === currentPropertyId.value) || null
)
const setCurrentProperty = (id: number | null) => {
currentPropertyId.value = id
if (id) {
localStorage.setItem('currentPropertyId', String(id))
} else {
localStorage.removeItem('currentPropertyId')
}
}
const fetchMyProperties = async () => {
loading.value = true
try {
const authStore = useAuthStore()
if (authStore.isSuperadmin) {
properties.value = await propertiesApi.listAll()
} else {
properties.value = await propertiesApi.list()
}
// Auto-select first property if none selected
if (!currentPropertyId.value && properties.value.length > 0) {
setCurrentProperty(properties.value[0].id)
}
} finally {
loading.value = false
}
}
return {
properties,
currentPropertyId,
currentProperty,
loading,
setCurrentProperty,
fetchMyProperties
}
})

View File

@@ -46,6 +46,8 @@ export interface Space {
capacity: number
description?: string
is_active: boolean
property_id?: number | null
property_name?: string
working_hours_start?: number | null
working_hours_end?: number | null
min_duration_minutes?: number | null
@@ -55,7 +57,7 @@ export interface Space {
export interface Booking {
id: number
space_id: number
user_id: number
user_id?: number | null
start_datetime: string
end_datetime: string
title: string
@@ -64,6 +66,10 @@ export interface Booking {
created_at: string
space?: Space
user?: User
guest_name?: string
guest_email?: string
guest_organization?: string
is_anonymous?: boolean
}
export interface Settings {
@@ -230,3 +236,78 @@ export interface ApprovalRateReport {
rejection_rate: number
date_range: { start: string | null; end: string | null }
}
export interface PropertyManagerInfo {
user_id: number
full_name: string
email: string
}
export interface Property {
id: number
name: string
description?: string
address?: string
is_public: boolean
is_active: boolean
created_at: string
space_count?: number
managers?: PropertyManagerInfo[]
}
export interface PropertyWithSpaces extends Property {
spaces: Space[]
}
export interface PropertySettings {
id: number
property_id: number
working_hours_start?: number | null
working_hours_end?: number | null
min_duration_minutes?: number | null
max_duration_minutes?: number | null
max_bookings_per_day_per_user?: number | null
require_approval: boolean
min_hours_before_cancel?: number | null
}
export interface PropertyAccess {
id: number
property_id: number
user_id?: number | null
organization_id?: number | null
granted_by?: number | null
user_name?: string
user_email?: string
organization_name?: string
created_at: string
}
export interface Organization {
id: number
name: string
description?: string
is_active: boolean
created_at: string
member_count?: number
}
export interface OrganizationMember {
id: number
organization_id: number
user_id: number
role: string
user_name?: string
user_email?: string
}
export interface AnonymousBookingCreate {
space_id: number
start_datetime: string
end_datetime: string
title: string
description?: string
guest_name: string
guest_email: string
guest_organization?: string
}

View File

@@ -2,7 +2,12 @@
<div class="admin">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Space Management</h2>
<div>
<h2>Space Management</h2>
<p v-if="authStore.isManager && propertyStore.currentProperty" class="property-context">
Property: <strong>{{ propertyStore.currentProperty.name }}</strong>
</p>
</div>
<button class="btn btn-primary" @click="openCreateModal">
<Plus :size="16" />
New Space
@@ -73,6 +78,10 @@
<UsersIcon :size="14" />
{{ space.capacity }}
</span>
<span v-if="space.property_name" class="meta-badge meta-property" :title="'Property: ' + space.property_name">
<Landmark :size="11" />
{{ space.property_name }}
</span>
</div>
<p v-if="space.description" class="space-card-desc">{{ space.description }}</p>
</div>
@@ -94,6 +103,15 @@
/>
</div>
<div class="form-group">
<label for="property">Property *</label>
<select id="property" v-model.number="formData.property_id" required>
<option v-for="prop in availableProperties" :key="prop.id" :value="prop.id">
{{ prop.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="type">Type *</label>
<select id="type" v-model="formData.type" required>
@@ -201,10 +219,15 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { spacesApi, handleApiError } from '@/services/api'
import { spacesApi, propertiesApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { usePropertyStore } from '@/stores/property'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Building2, Plus, Pencil, Power, Users as UsersIcon } from 'lucide-vue-next'
import type { Space } from '@/types'
import { Building2, Plus, Pencil, Power, Users as UsersIcon, Landmark } from 'lucide-vue-next'
import type { Space, Property } from '@/types'
const authStore = useAuthStore()
const propertyStore = usePropertyStore()
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
@@ -212,6 +235,7 @@ const breadcrumbItems = [
]
const spaces = ref<Space[]>([])
const availableProperties = ref<Property[]>([])
const loadingSpaces = ref(false)
const activeCount = computed(() => spaces.value.filter(s => s.is_active).length)
const inactiveCount = computed(() => spaces.value.filter(s => !s.is_active).length)
@@ -226,12 +250,23 @@ const formData = ref({
type: 'sala',
capacity: 1,
description: '',
property_id: null as number | null,
working_hours_start: null as number | null,
working_hours_end: null as number | null,
min_duration_minutes: null as number | null,
max_duration_minutes: null as number | null
})
const loadProperties = async () => {
try {
if (authStore.isSuperadmin) {
availableProperties.value = await propertiesApi.listAll()
} else {
availableProperties.value = await propertiesApi.list()
}
} catch {}
}
const loadSpaces = async () => {
loadingSpaces.value = true
error.value = ''
@@ -274,6 +309,12 @@ const handleSubmit = async () => {
const openCreateModal = () => {
resetForm()
// Auto-select current property context or first available
if (propertyStore.currentPropertyId) {
formData.value.property_id = propertyStore.currentPropertyId
} else if (availableProperties.value.length > 0) {
formData.value.property_id = availableProperties.value[0].id
}
showModal.value = true
}
@@ -284,6 +325,7 @@ const startEdit = (space: Space) => {
type: space.type,
capacity: space.capacity,
description: space.description || '',
property_id: space.property_id ?? null,
working_hours_start: space.working_hours_start ?? null,
working_hours_end: space.working_hours_end ?? null,
min_duration_minutes: space.min_duration_minutes ?? null,
@@ -307,7 +349,8 @@ const resetForm = () => {
working_hours_start: null,
working_hours_end: null,
min_duration_minutes: null,
max_duration_minutes: null
max_duration_minutes: null,
property_id: null
}
}
@@ -333,6 +376,7 @@ const toggleStatus = async (space: Space) => {
onMounted(() => {
loadSpaces()
loadProperties()
})
</script>
@@ -353,6 +397,16 @@ onMounted(() => {
color: var(--color-text-primary);
}
.property-context {
font-size: 14px;
color: var(--color-text-secondary);
margin: 4px 0 0;
}
.property-context strong {
color: var(--color-accent);
}
/* Stats Pills */
.stats-pills {
display: flex;
@@ -544,6 +598,14 @@ onMounted(() => {
color: var(--color-accent);
}
.meta-property {
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning);
}
.meta-item {
display: inline-flex;
align-items: center;

View File

@@ -312,7 +312,7 @@ const filters = ref<FilterValues>({
user_search: null
})
const isAdmin = computed(() => currentUser.value?.role === 'admin')
const isAdmin = computed(() => currentUser.value?.role === 'admin' || currentUser.value?.role === 'superadmin' || currentUser.value?.role === 'manager')
const hasActiveFilters = computed(() =>
filters.value.space_id !== null ||
@@ -454,7 +454,7 @@ const loadDashboard = async () => {
// Load spaces for filter dropdown
spaces.value = await spacesApi.list()
if (currentUser.value.role === 'admin') {
if (currentUser.value.role === 'admin' || currentUser.value.role === 'superadmin' || currentUser.value.role === 'manager') {
const results = await Promise.allSettled([
adminBookingsApi.getPending(),
adminBookingsApi.getAll({
@@ -535,7 +535,11 @@ const handleCancel = async (booking: Booking) => {
processing.value = booking.id
try {
await bookingsApi.update(booking.id, { status: 'canceled' } as any)
if (isAdmin.value) {
await adminBookingsApi.cancel(booking.id)
} else {
await bookingsApi.update(booking.id, { status: 'canceled' } as any)
}
showToast(`Booking "${booking.title}" canceled.`, 'success')
await loadDashboard()
calendarRef.value?.refresh()

View File

@@ -234,6 +234,43 @@
</div>
</div>
<!-- Confirm Modal -->
<div v-if="showConfirmModal" class="modal" @click.self="showConfirmModal = false">
<div class="modal-content">
<h3>{{ confirmTitle }}</h3>
<p class="confirm-message">{{ confirmMessage }}</p>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="showConfirmModal = false" :disabled="confirmLoading">Cancel</button>
<button
type="button"
:class="['btn', confirmDanger ? 'btn-danger' : 'btn-primary']"
@click="executeConfirm"
:disabled="confirmLoading"
>
{{ confirmLoading ? 'Processing...' : confirmLabel }}
</button>
</div>
</div>
</div>
<!-- Reject Modal -->
<div v-if="showRejectModal" class="modal" @click.self="showRejectModal = false">
<div class="modal-content">
<h3>Reject Booking</h3>
<p class="confirm-message">Rejecting "{{ rejectBooking?.title }}"</p>
<div class="form-group">
<label for="reject-reason">Reason (optional)</label>
<textarea id="reject-reason" v-model="rejectReason" rows="3" placeholder="Enter rejection reason..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="showRejectModal = false">Cancel</button>
<button type="button" class="btn btn-danger" @click="doReject" :disabled="processing !== null">
{{ processing !== null ? 'Rejecting...' : 'Reject' }}
</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="toastMsg" :class="['toast', `toast-${toastType}`]">{{ toastMsg }}</div>
</div>
@@ -266,7 +303,7 @@ import type { Booking, Space } from '@/types'
const route = useRoute()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const isAdmin = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const breadcrumbItems = computed(() => [
@@ -325,6 +362,62 @@ const showToast = (msg: string, type: 'success' | 'error') => {
setTimeout(() => { toastMsg.value = '' }, type === 'success' ? 3000 : 5000)
}
// Confirm modal (for cancel / approve)
const showConfirmModal = ref(false)
const confirmTitle = ref('')
const confirmMessage = ref('')
const confirmDanger = ref(false)
const confirmLabel = ref('Yes')
const confirmLoading = ref(false)
const onConfirm = ref<(() => Promise<void>) | null>(null)
const openConfirm = (opts: { title: string; message: string; danger?: boolean; label?: string; action: () => Promise<void> }) => {
confirmTitle.value = opts.title
confirmMessage.value = opts.message
confirmDanger.value = opts.danger ?? false
confirmLabel.value = opts.label ?? 'Yes'
onConfirm.value = opts.action
confirmLoading.value = false
showConfirmModal.value = true
}
const executeConfirm = async () => {
if (!onConfirm.value) return
confirmLoading.value = true
try {
await onConfirm.value()
} finally {
confirmLoading.value = false
showConfirmModal.value = false
}
}
// Reject modal
const showRejectModal = ref(false)
const rejectBooking = ref<Booking | null>(null)
const rejectReason = ref('')
const openRejectModal = (booking: Booking) => {
rejectBooking.value = booking
rejectReason.value = ''
showRejectModal.value = true
}
const doReject = async () => {
if (!rejectBooking.value) return
processing.value = rejectBooking.value.id
try {
await adminBookingsApi.reject(rejectBooking.value.id, rejectReason.value || undefined)
showToast(`Booking "${rejectBooking.value.title}" rejected.`, 'success')
showRejectModal.value = false
await loadBookings()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
processing.value = null
}
}
const hasActiveFilters = computed(() =>
filters.value.space_id !== null ||
filters.value.status !== null ||
@@ -413,50 +506,53 @@ const clearAllFilters = () => {
}
// Actions
const handleCancel = async (booking: Booking) => {
if (!confirm(`Cancel booking "${booking.title}"?`)) return
processing.value = booking.id
try {
await bookingsApi.update(booking.id, { status: 'canceled' } as any)
showToast(`Booking "${booking.title}" canceled.`, 'success')
await loadBookings()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
processing.value = null
}
const handleCancel = (booking: Booking) => {
openConfirm({
title: 'Cancel Booking',
message: `Cancel booking "${booking.title}"?`,
danger: true,
label: 'Cancel Booking',
action: async () => {
processing.value = booking.id
try {
if (isAdmin.value) {
await adminBookingsApi.cancel(booking.id)
} else {
await bookingsApi.cancel(booking.id)
}
showToast(`Booking "${booking.title}" canceled.`, 'success')
await loadBookings()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
processing.value = null
}
}
})
}
const handleApprove = async (booking: Booking) => {
if (!confirm(`Approve booking "${booking.title}"?`)) return
processing.value = booking.id
try {
await adminBookingsApi.approve(booking.id)
showToast(`Booking "${booking.title}" approved!`, 'success')
await loadBookings()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
processing.value = null
}
const handleApprove = (booking: Booking) => {
openConfirm({
title: 'Approve Booking',
message: `Approve booking "${booking.title}"?`,
label: 'Approve',
action: async () => {
processing.value = booking.id
try {
await adminBookingsApi.approve(booking.id)
showToast(`Booking "${booking.title}" approved!`, 'success')
await loadBookings()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
processing.value = null
}
}
})
}
const handleReject = async (booking: Booking) => {
const reason = prompt('Rejection reason (optional):')
if (reason === null) return // User clicked cancel
processing.value = booking.id
try {
await adminBookingsApi.reject(booking.id, reason || undefined)
showToast(`Booking "${booking.title}" rejected.`, 'success')
await loadBookings()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
processing.value = null
}
const handleReject = (booking: Booking) => {
openRejectModal(booking)
}
// Edit modal
@@ -1009,6 +1105,21 @@ onMounted(() => {
background: var(--color-border);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger) 85%, black);
}
.confirm-message {
color: var(--color-text-secondary);
margin-bottom: 20px;
line-height: 1.5;
}
/* Toast */
.toast {
position: fixed;

View File

@@ -0,0 +1,469 @@
<template>
<div class="organization">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Organizations</h2>
<button v-if="authStore.isSuperadmin" class="btn btn-primary" @click="openCreateModal">
<Plus :size="16" />
New Organization
</button>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Loading organizations...</p>
</div>
<div v-else-if="organizations.length === 0" class="empty-state">
<Building2 :size="48" class="empty-icon" />
<p>No organizations found</p>
</div>
<div v-else class="org-list">
<div v-for="org in organizations" :key="org.id" class="org-card">
<div class="org-header">
<div>
<h3>{{ org.name }}</h3>
<p v-if="org.description" class="org-desc">{{ org.description }}</p>
</div>
<div class="org-actions">
<span class="member-count">{{ org.member_count || 0 }} members</span>
<button
class="btn btn-sm btn-secondary"
@click="toggleExpanded(org.id)"
>
{{ expandedOrg === org.id ? 'Hide' : 'Members' }}
</button>
</div>
</div>
<!-- Expanded Members -->
<div v-if="expandedOrg === org.id" class="org-members">
<div v-if="loadingMembers" class="loading-inline">Loading members...</div>
<div v-else>
<div class="members-header">
<h4>Members</h4>
</div>
<div v-if="members.length === 0" class="empty-inline">No members yet</div>
<table v-else class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="member in members" :key="member.id">
<td>{{ member.user_name }}</td>
<td>{{ member.user_email }}</td>
<td>
<span :class="['badge', member.role === 'admin' ? 'badge-admin' : 'badge-user']">
{{ member.role }}
</span>
</td>
<td class="actions">
<button
v-if="authStore.isSuperadmin"
class="btn btn-sm btn-danger"
@click="removeMember(org.id, member.user_id)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create Org Modal -->
<div v-if="showCreateModal" class="modal" @click.self="showCreateModal = false">
<div class="modal-content">
<h3>Create Organization</h3>
<form @submit.prevent="handleCreate" class="org-form">
<div class="form-group">
<label>Name *</label>
<input v-model="createForm.name" type="text" required placeholder="Organization name" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="createForm.description" rows="3" placeholder="Optional"></textarea>
</div>
<div v-if="formError" class="error">{{ formError }}</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">Create</button>
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Cancel</button>
</div>
</form>
</div>
</div>
<div v-if="toast" class="toast toast-success">{{ toast }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { organizationsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Building2, Plus } from 'lucide-vue-next'
import type { Organization, OrganizationMember } from '@/types'
const authStore = useAuthStore()
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Organizations' }
]
const organizations = ref<Organization[]>([])
const loading = ref(true)
const expandedOrg = ref<number | null>(null)
const members = ref<OrganizationMember[]>([])
const loadingMembers = ref(false)
const showCreateModal = ref(false)
const submitting = ref(false)
const formError = ref('')
const toast = ref('')
const createForm = ref({ name: '', description: '' })
const loadOrganizations = async () => {
loading.value = true
try {
organizations.value = await organizationsApi.list()
} catch (err) {
formError.value = handleApiError(err)
} finally {
loading.value = false
}
}
const toggleExpanded = async (orgId: number) => {
if (expandedOrg.value === orgId) {
expandedOrg.value = null
return
}
expandedOrg.value = orgId
loadingMembers.value = true
try {
members.value = await organizationsApi.getMembers(orgId)
} catch {}
finally {
loadingMembers.value = false
}
}
const openCreateModal = () => {
createForm.value = { name: '', description: '' }
formError.value = ''
showCreateModal.value = true
}
const handleCreate = async () => {
submitting.value = true
formError.value = ''
try {
await organizationsApi.create(createForm.value)
showCreateModal.value = false
toast.value = 'Organization created!'
setTimeout(() => { toast.value = '' }, 3000)
await loadOrganizations()
} catch (err) {
formError.value = handleApiError(err)
} finally {
submitting.value = false
}
}
const removeMember = async (orgId: number, userId: number) => {
if (!confirm('Remove this member?')) return
try {
await organizationsApi.removeMember(orgId, userId)
members.value = members.value.filter(m => m.user_id !== userId)
toast.value = 'Member removed'
setTimeout(() => { toast.value = '' }, 3000)
} catch (err) {
formError.value = handleApiError(err)
}
}
onMounted(() => loadOrganizations())
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: var(--color-text-secondary);
}
.spinner {
width: 40px; height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: var(--color-text-muted);
gap: 16px;
}
.empty-icon { color: var(--color-border); }
.org-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.org-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
}
.org-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.org-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.org-desc {
font-size: 14px;
color: var(--color-text-secondary);
margin: 4px 0 0;
}
.org-actions {
display: flex;
align-items: center;
gap: 12px;
}
.member-count {
font-size: 13px;
color: var(--color-text-muted);
}
.org-members {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.members-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.members-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.loading-inline, .empty-inline {
text-align: center;
padding: 16px;
color: var(--color-text-muted);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
}
.data-table td {
padding: 8px 12px;
font-size: 14px;
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-admin {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}
.badge-user {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: 6px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: white; }
.btn-primary:hover:not(:disabled) { background: var(--color-accent-hover); }
.btn-secondary { background: var(--color-bg-tertiary); color: var(--color-text-primary); }
.btn-secondary:hover:not(:disabled) { background: var(--color-border); }
.btn-danger { background: var(--color-danger); color: white; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
/* Modal */
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 28px;
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 20px;
color: var(--color-text-primary);
}
.org-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.form-group input,
.form-group textarea {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-actions {
display: flex;
gap: 12px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
}
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
z-index: 1100;
box-shadow: var(--shadow-lg);
}
.toast-success { background: var(--color-success); color: #fff; }
</style>

View File

@@ -0,0 +1,630 @@
<template>
<div class="properties">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Properties</h2>
<button class="btn btn-primary" @click="openCreateModal">
<Plus :size="16" />
New Property
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Loading properties...</p>
</div>
<!-- Empty -->
<div v-else-if="properties.length === 0" class="empty-state">
<Landmark :size="48" class="empty-icon" />
<p>No properties yet</p>
<button class="btn btn-primary" @click="openCreateModal">Create your first property</button>
</div>
<!-- Property Grid -->
<div v-else class="property-grid">
<div
v-for="prop in properties"
:key="prop.id"
:class="['property-card', { 'property-card-inactive': !prop.is_active }]"
@click="goToProperty(prop.id)"
>
<div class="property-card-header">
<h3>{{ prop.name }}</h3>
<div class="badges">
<span :class="['badge', prop.is_public ? 'badge-public' : 'badge-private']">
{{ prop.is_public ? 'Public' : 'Private' }}
</span>
<span :class="['badge', prop.is_active ? 'badge-active' : 'badge-inactive']">
{{ prop.is_active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
<p v-if="prop.description" class="property-desc">{{ prop.description }}</p>
<p v-if="prop.address" class="property-address">{{ prop.address }}</p>
<div v-if="prop.managers && prop.managers.length > 0" class="property-managers">
<span class="managers-label">Managed by:</span>
<div class="manager-chips">
<span v-for="mgr in prop.managers" :key="mgr.user_id" class="manager-chip" :title="mgr.email">
<span class="manager-avatar">{{ mgr.full_name.charAt(0).toUpperCase() }}</span>
{{ mgr.full_name }}
</span>
</div>
</div>
<div class="property-footer">
<span class="space-count">{{ prop.space_count || 0 }} spaces</span>
<div class="property-actions" @click.stop>
<button
class="btn-icon"
:title="prop.is_active ? 'Deactivate' : 'Activate'"
@click="togglePropertyStatus(prop)"
>
<PowerOff :size="15" />
</button>
<button
class="btn-icon btn-icon-danger"
title="Delete property"
@click="confirmDelete(prop)"
>
<Trash2 :size="15" />
</button>
</div>
</div>
</div>
</div>
<!-- Create Property Modal -->
<div v-if="showModal" class="modal" @click.self="closeModal">
<div class="modal-content">
<h3>Create New Property</h3>
<form @submit.prevent="handleCreate" class="property-form">
<div class="form-group">
<label for="name">Name *</label>
<input id="name" v-model="formData.name" type="text" required placeholder="Property name" />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="formData.description" rows="3" placeholder="Optional description"></textarea>
</div>
<div class="form-group">
<label for="address">Address</label>
<input id="address" v-model="formData.address" type="text" placeholder="Optional address" />
</div>
<div class="form-group form-checkbox">
<label>
<input type="checkbox" v-model="formData.is_public" />
Public (allows anonymous bookings)
</label>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? 'Creating...' : 'Create' }}
</button>
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Confirm Modal -->
<div v-if="showConfirm" class="modal" @click.self="showConfirm = false">
<div class="modal-content modal-confirm">
<h3>{{ confirmTitle }}</h3>
<p>{{ confirmMessage }}</p>
<div v-if="error" class="error" style="margin-bottom: 12px;">{{ error }}</div>
<div class="form-actions">
<button class="btn btn-danger" :disabled="submitting" @click="executeConfirm">
{{ submitting ? 'Processing...' : confirmAction }}
</button>
<button class="btn btn-secondary" @click="showConfirm = false">Cancel</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="successMsg" class="toast toast-success">{{ successMsg }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { propertiesApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Landmark, Plus, PowerOff, Trash2 } from 'lucide-vue-next'
import type { Property } from '@/types'
const router = useRouter()
const authStore = useAuthStore()
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Properties' }
]
const properties = ref<Property[]>([])
const loading = ref(true)
const showModal = ref(false)
const submitting = ref(false)
const error = ref('')
const successMsg = ref('')
// Confirm modal state
const showConfirm = ref(false)
const confirmTitle = ref('')
const confirmMessage = ref('')
const confirmAction = ref('')
const confirmCallback = ref<(() => Promise<void>) | null>(null)
const formData = ref({
name: '',
description: '',
address: '',
is_public: false
})
const loadProperties = async () => {
loading.value = true
try {
if (authStore.isSuperadmin) {
properties.value = await propertiesApi.listAll()
} else {
properties.value = await propertiesApi.list({ managed_only: true })
}
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const openCreateModal = () => {
formData.value = { name: '', description: '', address: '', is_public: false }
error.value = ''
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
const handleCreate = async () => {
submitting.value = true
error.value = ''
try {
await propertiesApi.create(formData.value)
closeModal()
showToast('Property created!')
await loadProperties()
} catch (err) {
error.value = handleApiError(err)
} finally {
submitting.value = false
}
}
const togglePropertyStatus = (prop: Property) => {
const newStatus = !prop.is_active
confirmTitle.value = newStatus ? 'Activate Property' : 'Deactivate Property'
confirmMessage.value = newStatus
? `Activate "${prop.name}"? Users will be able to see and book spaces in this property.`
: `Deactivate "${prop.name}"? This will hide the property from users. Existing bookings will not be affected.`
confirmAction.value = newStatus ? 'Activate' : 'Deactivate'
error.value = ''
confirmCallback.value = async () => {
await propertiesApi.updateStatus(prop.id, newStatus)
showToast(`Property ${newStatus ? 'activated' : 'deactivated'}!`)
await loadProperties()
}
showConfirm.value = true
}
const confirmDelete = (prop: Property) => {
confirmTitle.value = 'Delete Property'
confirmMessage.value = `Are you sure you want to delete "${prop.name}"? This action cannot be undone. Spaces will be unlinked (not deleted). Active bookings must be cancelled first.`
confirmAction.value = 'Delete'
error.value = ''
confirmCallback.value = async () => {
await propertiesApi.delete(prop.id)
showToast('Property deleted!')
await loadProperties()
}
showConfirm.value = true
}
const executeConfirm = async () => {
if (!confirmCallback.value) return
submitting.value = true
error.value = ''
try {
await confirmCallback.value()
showConfirm.value = false
} catch (err) {
error.value = handleApiError(err)
} finally {
submitting.value = false
}
}
const showToast = (msg: string) => {
successMsg.value = msg
setTimeout(() => { successMsg.value = '' }, 3000)
}
const goToProperty = (id: number) => {
router.push(`/properties/${id}`)
}
onMounted(() => {
loadProperties()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: var(--color-text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: var(--color-text-muted);
gap: 16px;
}
.empty-icon { color: var(--color-border); }
.property-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.property-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
cursor: pointer;
transition: all var(--transition-fast);
}
.property-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-accent);
}
.property-card-inactive {
opacity: 0.6;
border-style: dashed;
}
.property-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.property-card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-public {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}
.badge-private {
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
}
.badge-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-inactive {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.property-desc {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0 0 4px;
}
.property-address {
font-size: 13px;
color: var(--color-text-muted);
margin: 0 0 8px;
}
.property-managers {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
}
.managers-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--color-text-muted);
display: block;
margin-bottom: 6px;
}
.manager-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.manager-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px 3px 3px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-text-primary);
}
.manager-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.property-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.space-count {
font-size: 13px;
font-weight: 500;
color: var(--color-accent);
}
.property-actions {
display: flex;
gap: 4px;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.btn-icon-danger:hover {
border-color: var(--color-danger);
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
}
/* Modal */
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 28px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 20px;
color: var(--color-text-primary);
}
.modal-confirm p {
color: var(--color-text-secondary);
font-size: 14px;
line-height: 1.5;
margin: 0 0 20px;
}
.property-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group textarea {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: white; }
.btn-primary:hover:not(:disabled) { background: var(--color-accent-hover); }
.btn-secondary { background: var(--color-bg-tertiary); color: var(--color-text-primary); }
.btn-secondary:hover:not(:disabled) { background: var(--color-border); }
.btn-danger { background: var(--color-danger); color: white; }
.btn-danger:hover:not(:disabled) { opacity: 0.9; }
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
}
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
z-index: 1100;
box-shadow: var(--shadow-lg);
}
.toast-success {
background: var(--color-success);
color: #fff;
}
@media (max-width: 768px) {
.property-grid { grid-template-columns: 1fr; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
<template>
<div class="public-booking-container">
<div class="public-booking-card card">
<h2>Book a Space</h2>
<p class="subtitle">Reserve a meeting room or workspace without an account</p>
<!-- Step 1: Select Property -->
<div v-if="step === 'property'">
<div v-if="loadingProperties" class="loading-inline">Loading properties...</div>
<div v-else-if="properties.length === 0" class="empty-msg">No public properties available.</div>
<div v-else class="property-list">
<div
v-for="prop in properties"
:key="prop.id"
class="selectable-card"
@click="selectProperty(prop)"
>
<h4>{{ prop.name }}</h4>
<p v-if="prop.description" class="card-desc">{{ prop.description }}</p>
<p v-if="prop.address" class="card-meta">{{ prop.address }}</p>
<span class="card-count">{{ prop.space_count || 0 }} spaces</span>
</div>
</div>
</div>
<!-- Step 2: Select Space -->
<div v-else-if="step === 'space'">
<button class="btn-back" @click="step = 'property'">Back to properties</button>
<h3 class="step-title">{{ selectedProperty?.name }} - Choose a Space</h3>
<div v-if="loadingSpaces" class="loading-inline">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty-msg">No spaces available.</div>
<div v-else class="space-list">
<div
v-for="sp in spaces"
:key="sp.id"
class="selectable-card"
@click="selectSpace(sp)"
>
<h4>{{ sp.name }}</h4>
<div class="card-meta-row">
<span>{{ formatType(sp.type) }}</span>
<span>Capacity: {{ sp.capacity }}</span>
</div>
</div>
</div>
</div>
<!-- Step 3: Booking Form -->
<div v-else-if="step === 'form'">
<button class="btn-back" @click="step = 'space'">Back to spaces</button>
<h3 class="step-title">Book {{ selectedSpace?.name }}</h3>
<form @submit.prevent="handleSubmit" class="booking-form">
<div class="form-group">
<label for="guest_name">Your Name *</label>
<input id="guest_name" v-model="form.guest_name" type="text" required placeholder="John Doe" />
</div>
<div class="form-group">
<label for="guest_email">Your Email *</label>
<input id="guest_email" v-model="form.guest_email" type="email" required placeholder="john@example.com" />
</div>
<div class="form-group">
<label for="guest_organization">Organization (optional)</label>
<input id="guest_organization" v-model="form.guest_organization" type="text" placeholder="Company name" />
</div>
<div class="form-group">
<label for="title">Booking Title *</label>
<input id="title" v-model="form.title" type="text" required placeholder="Team meeting" />
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" v-model="form.description" rows="2" placeholder="Additional details..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="date">Date *</label>
<input id="date" v-model="form.date" type="date" required :min="minDate" />
</div>
<div class="form-group">
<label for="start_time">Start Time *</label>
<input id="start_time" v-model="form.start_time" type="time" required />
</div>
<div class="form-group">
<label for="end_time">End Time *</label>
<input id="end_time" v-model="form.end_time" type="time" required />
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<button type="submit" class="btn btn-primary btn-block" :disabled="submitting">
{{ submitting ? 'Submitting...' : 'Submit Booking Request' }}
</button>
</form>
</div>
<!-- Step 4: Success -->
<div v-else-if="step === 'success'" class="success-state">
<div class="success-icon">&#10003;</div>
<h3>Booking Request Sent!</h3>
<p>Your booking request has been submitted. You will receive updates at <strong>{{ form.guest_email }}</strong>.</p>
<button class="btn btn-primary" @click="resetForm">Book Another</button>
</div>
<p class="login-hint">
Already have an account? <router-link to="/login">Sign in</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { publicApi, handleApiError } from '@/services/api'
import type { Property, Space } from '@/types'
const route = useRoute()
const step = ref<'property' | 'space' | 'form' | 'success'>('property')
const loadingProperties = ref(false)
const loadingSpaces = ref(false)
const submitting = ref(false)
const error = ref('')
const properties = ref<Property[]>([])
const spaces = ref<Space[]>([])
const selectedProperty = ref<Property | null>(null)
const selectedSpace = ref<Space | null>(null)
const form = ref({
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: '',
date: '',
start_time: '',
end_time: ''
})
const minDate = computed(() => new Date().toISOString().split('T')[0])
const formatType = (type: string): string => {
const map: Record<string, string> = {
desk: 'Desk', meeting_room: 'Meeting Room', conference_room: 'Conference Room',
sala: 'Sala', birou: 'Birou'
}
return map[type] || type
}
const loadProperties = async () => {
loadingProperties.value = true
try {
properties.value = await publicApi.getProperties()
// If propertyId in route, auto-select
const pid = route.params.propertyId
if (pid) {
const prop = properties.value.find(p => p.id === Number(pid))
if (prop) {
selectProperty(prop)
}
}
} catch (err) {
error.value = handleApiError(err)
} finally {
loadingProperties.value = false
}
}
const selectProperty = async (prop: Property) => {
selectedProperty.value = prop
step.value = 'space'
loadingSpaces.value = true
try {
spaces.value = await publicApi.getPropertySpaces(prop.id)
} catch (err) {
error.value = handleApiError(err)
} finally {
loadingSpaces.value = false
}
}
const selectSpace = (sp: Space) => {
selectedSpace.value = sp
step.value = 'form'
error.value = ''
}
const handleSubmit = async () => {
error.value = ''
if (!selectedSpace.value) return
if (form.value.start_time >= form.value.end_time) {
error.value = 'End time must be after start time'
return
}
submitting.value = true
try {
await publicApi.createBooking({
space_id: selectedSpace.value.id,
start_datetime: `${form.value.date}T${form.value.start_time}:00`,
end_datetime: `${form.value.date}T${form.value.end_time}:00`,
title: form.value.title,
description: form.value.description || undefined,
guest_name: form.value.guest_name,
guest_email: form.value.guest_email,
guest_organization: form.value.guest_organization || undefined
})
step.value = 'success'
} catch (err) {
error.value = handleApiError(err)
} finally {
submitting.value = false
}
}
const resetForm = () => {
step.value = 'property'
selectedProperty.value = null
selectedSpace.value = null
form.value = {
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: '',
date: '',
start_time: '',
end_time: ''
}
error.value = ''
}
onMounted(() => {
loadProperties()
})
</script>
<style scoped>
.public-booking-container {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 2rem 1rem;
background: var(--color-bg-primary);
}
.public-booking-card {
width: 100%;
max-width: 560px;
}
h2 {
text-align: center;
margin-bottom: 0.25rem;
color: var(--color-text-primary);
}
.subtitle {
text-align: center;
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.step-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 16px;
}
.btn-back {
background: none;
border: none;
color: var(--color-accent);
font-size: 14px;
cursor: pointer;
padding: 0;
margin-bottom: 12px;
font-weight: 500;
}
.btn-back:hover {
text-decoration: underline;
}
.loading-inline {
text-align: center;
padding: 24px;
color: var(--color-text-secondary);
}
.empty-msg {
text-align: center;
padding: 24px;
color: var(--color-text-muted);
}
.property-list, .space-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.selectable-card {
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
background: var(--color-bg-secondary);
}
.selectable-card:hover {
border-color: var(--color-accent);
box-shadow: var(--shadow-sm);
}
.selectable-card h4 {
margin: 0 0 4px;
font-size: 16px;
color: var(--color-text-primary);
}
.card-desc {
font-size: 14px;
color: var(--color-text-secondary);
margin: 0 0 4px;
}
.card-meta {
font-size: 13px;
color: var(--color-text-muted);
margin: 0 0 4px;
}
.card-meta-row {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.card-count {
font-size: 12px;
font-weight: 500;
color: var(--color-accent);
}
.booking-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-block {
width: 100%;
margin-top: 0.5rem;
}
.error {
padding: 10px 14px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left: 3px solid var(--color-danger);
border-radius: var(--radius-sm);
color: var(--color-danger);
font-size: 14px;
}
.success-state {
text-align: center;
padding: 24px 0;
}
.success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.success-state h3 {
color: var(--color-success);
margin-bottom: 8px;
}
.success-state p {
color: var(--color-text-secondary);
margin-bottom: 20px;
}
.login-hint {
text-align: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: 14px;
}
.login-hint a {
color: var(--color-accent);
text-decoration: none;
}
.login-hint a:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -66,7 +66,82 @@
<div class="card calendar-card">
<h3>Availability Calendar</h3>
<p class="calendar-subtitle">View existing bookings and available time slots</p>
<SpaceCalendar ref="calendarRef" :space-id="space.id" />
<SpaceCalendar
ref="calendarRef"
:space-id="space.id"
:space-name="space.name"
@edit-booking="openEditBookingModal"
@cancel-booking="handleCancelBooking"
@approve-booking="handleApproveBooking"
@reject-booking="openRejectBookingModal"
/>
</div>
<!-- Bookings List Section -->
<div class="card bookings-card">
<div class="bookings-card-header">
<h3>Bookings</h3>
<span class="result-count" v-if="!bookingsLoading">{{ spaceBookings.length }} bookings</span>
</div>
<div v-if="bookingsLoading" class="bookings-loading">Loading bookings...</div>
<div v-else-if="spaceBookings.length === 0" class="bookings-empty">No bookings found for this space.</div>
<table v-else class="bookings-table">
<thead>
<tr>
<th>User</th>
<th>Date</th>
<th>Time</th>
<th>Title</th>
<th>Status</th>
<th v-if="isAdmin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="b in spaceBookings" :key="b.id">
<td class="cell-user">{{ b.user?.full_name || b.guest_name || 'Unknown' }}</td>
<td>{{ formatBookingDate(b.start_datetime) }}</td>
<td class="cell-time">{{ formatBookingTime(b.start_datetime) }} - {{ formatBookingTime(b.end_datetime) }}</td>
<td class="cell-title">{{ b.title }}</td>
<td><span :class="['badge-status', `badge-${b.status}`]">{{ b.status }}</span></td>
<td v-if="isAdmin" class="cell-actions">
<button
v-if="b.status === 'pending'"
class="btn-action btn-action-approve"
title="Approve"
@click="handleApproveBooking(b)"
>
<Check :size="14" />
</button>
<button
v-if="b.status === 'pending'"
class="btn-action btn-action-reject"
title="Reject"
@click="openRejectBookingModal(b)"
>
<XIcon :size="14" />
</button>
<button
v-if="b.status === 'pending' || b.status === 'approved'"
class="btn-action btn-action-edit"
title="Edit"
@click="openEditBookingModal(b)"
>
<Pencil :size="14" />
</button>
<button
v-if="b.status === 'pending' || b.status === 'approved'"
class="btn-action btn-action-cancel"
title="Cancel"
@click="handleCancelBooking(b)"
>
<Ban :size="14" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
@@ -93,24 +168,114 @@
/>
</div>
</div>
<!-- Edit Booking Modal -->
<div v-if="showEditModal" class="modal" @click.self="closeEditModal">
<div class="modal-content">
<h3>Edit Booking</h3>
<form @submit.prevent="saveEdit">
<div class="form-group">
<label for="edit-title">Title *</label>
<input id="edit-title" v-model="editForm.title" type="text" required maxlength="200" placeholder="Booking title" />
</div>
<div class="form-group">
<label for="edit-description">Description (optional)</label>
<textarea id="edit-description" v-model="editForm.description" rows="3" placeholder="Additional details..."></textarea>
</div>
<div class="form-group">
<label>Start *</label>
<div class="datetime-row">
<div class="datetime-field">
<label for="edit-start-date" class="sublabel">Date</label>
<input id="edit-start-date" v-model="editForm.start_date" type="date" required />
</div>
<div class="datetime-field">
<label for="edit-start-time" class="sublabel">Time</label>
<input id="edit-start-time" v-model="editForm.start_time" type="time" required />
</div>
</div>
</div>
<div class="form-group">
<label>End *</label>
<div class="datetime-row">
<div class="datetime-field">
<label for="edit-end-date" class="sublabel">Date</label>
<input id="edit-end-date" v-model="editForm.end_date" type="date" required />
</div>
<div class="datetime-field">
<label for="edit-end-time" class="sublabel">Time</label>
<input id="edit-end-time" v-model="editForm.end_time" type="time" required />
</div>
</div>
</div>
<div v-if="editError" class="error-msg">{{ editError }}</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="closeEditModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="editSaving">{{ editSaving ? 'Saving...' : 'Save Changes' }}</button>
</div>
</form>
</div>
</div>
<!-- Confirm Modal -->
<div v-if="showConfirmModal" class="modal" @click.self="showConfirmModal = false">
<div class="modal-content">
<h3>{{ confirmTitle }}</h3>
<p class="confirm-text">{{ confirmMessage }}</p>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="showConfirmModal = false" :disabled="confirmLoading">Cancel</button>
<button type="button" :class="['btn', confirmDanger ? 'btn-danger' : 'btn-primary']" @click="executeConfirm" :disabled="confirmLoading">
{{ confirmLoading ? 'Processing...' : confirmLabel }}
</button>
</div>
</div>
</div>
<!-- Reject Modal -->
<div v-if="showRejectModal" class="modal" @click.self="showRejectModal = false">
<div class="modal-content">
<h3>Reject Booking</h3>
<p class="confirm-text">Rejecting "{{ rejectBooking?.title }}"</p>
<div class="form-group">
<label for="reject-reason">Reason (optional)</label>
<textarea id="reject-reason" v-model="rejectReason" rows="3" placeholder="Enter rejection reason..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="showRejectModal = false">Cancel</button>
<button type="button" class="btn btn-danger" @click="doReject" :disabled="rejectLoading">
{{ rejectLoading ? 'Rejecting...' : 'Reject' }}
</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="toastMsg" :class="['toast', `toast-${toastType}`]">{{ toastMsg }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api'
import { spacesApi, bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import {
formatDate as formatDateTZ,
formatTime as formatTimeTZ,
isoToLocalDateTime,
localDateTimeToISO
} from '@/utils/datetime'
import Breadcrumb from '@/components/Breadcrumb.vue'
import SpaceCalendar from '@/components/SpaceCalendar.vue'
import BookingForm from '@/components/BookingForm.vue'
import AdminBookingForm from '@/components/AdminBookingForm.vue'
import { useAuthStore } from '@/stores/auth'
import { Users, Plus, UserPlus } from 'lucide-vue-next'
import type { Space } from '@/types'
import { Users, Plus, UserPlus, Check, X as XIcon, Pencil, Ban } from 'lucide-vue-next'
import type { Space, Booking } from '@/types'
const route = useRoute()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const isAdmin = computed(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const breadcrumbItems = computed(() => [
{ label: 'Dashboard', to: '/dashboard' },
@@ -125,6 +290,45 @@ const showBookingForm = ref(false)
const showAdminBookingForm = ref(false)
const calendarRef = ref<InstanceType<typeof SpaceCalendar> | null>(null)
// Bookings list
const spaceBookings = ref<Booking[]>([])
const bookingsLoading = ref(false)
// Toast
const toastMsg = ref('')
const toastType = ref<'success' | 'error'>('success')
const showToast = (msg: string, type: 'success' | 'error') => {
toastMsg.value = msg
toastType.value = type
setTimeout(() => { toastMsg.value = '' }, type === 'success' ? 3000 : 5000)
}
// Edit modal
const showEditModal = ref(false)
const editingBooking = ref<Booking | null>(null)
const editForm = ref({ title: '', description: '', start_date: '', start_time: '', end_date: '', end_time: '' })
const editError = ref('')
const editSaving = ref(false)
// Confirm modal
const showConfirmModal = ref(false)
const confirmTitle = ref('')
const confirmMessage = ref('')
const confirmDanger = ref(false)
const confirmLabel = ref('Yes')
const confirmLoading = ref(false)
const onConfirm = ref<(() => Promise<void>) | null>(null)
// Reject modal
const showRejectModal = ref(false)
const rejectBooking = ref<Booking | null>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
// Format helpers
const formatBookingDate = (datetime: string): string => formatDateTZ(datetime, userTimezone.value)
const formatBookingTime = (datetime: string): string => formatTimeTZ(datetime, userTimezone.value)
// Format space type for display
const formatType = (type: string): string => {
const typeMap: Record<string, string> = {
@@ -155,6 +359,7 @@ const loadSpace = async () => {
error.value = 'Space not found (404). The space may not exist or has been removed.'
} else {
space.value = foundSpace
loadSpaceBookings()
}
} catch (err) {
error.value = handleApiError(err)
@@ -163,6 +368,29 @@ const loadSpace = async () => {
}
}
// Load bookings for this space
const loadSpaceBookings = async () => {
if (!space.value) return
bookingsLoading.value = true
try {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0)
spaceBookings.value = await bookingsApi.getForSpace(space.value.id, start.toISOString(), end.toISOString())
// Sort by date descending
spaceBookings.value.sort((a, b) => new Date(b.start_datetime).getTime() - new Date(a.start_datetime).getTime())
} catch (err) {
// Non-critical
} finally {
bookingsLoading.value = false
}
}
const refreshAll = () => {
calendarRef.value?.refresh()
loadSpaceBookings()
}
// Handle reserve button click
const handleReserve = () => {
showBookingForm.value = !showBookingForm.value
@@ -176,13 +404,141 @@ const closeBookingModal = () => {
// Handle booking form submit
const handleBookingSubmit = () => {
showBookingForm.value = false
calendarRef.value?.refresh()
refreshAll()
}
// Handle admin booking form submit
const handleAdminBookingSubmit = () => {
showAdminBookingForm.value = false
calendarRef.value?.refresh()
refreshAll()
}
// --- Calendar action handlers ---
const openConfirm = (opts: { title: string; message: string; danger?: boolean; label?: string; action: () => Promise<void> }) => {
confirmTitle.value = opts.title
confirmMessage.value = opts.message
confirmDanger.value = opts.danger ?? false
confirmLabel.value = opts.label ?? 'Yes'
onConfirm.value = opts.action
confirmLoading.value = false
showConfirmModal.value = true
}
const executeConfirm = async () => {
if (!onConfirm.value) return
confirmLoading.value = true
try {
await onConfirm.value()
} finally {
confirmLoading.value = false
showConfirmModal.value = false
}
}
const handleApproveBooking = (booking: Booking) => {
openConfirm({
title: 'Approve Booking',
message: `Approve booking "${booking.title}"?`,
label: 'Approve',
action: async () => {
await adminBookingsApi.approve(booking.id)
showToast(`Booking "${booking.title}" approved!`, 'success')
refreshAll()
}
})
}
const handleCancelBooking = (booking: Booking) => {
openConfirm({
title: 'Cancel Booking',
message: `Cancel booking "${booking.title}"?`,
danger: true,
label: 'Cancel Booking',
action: async () => {
await adminBookingsApi.cancel(booking.id)
showToast(`Booking "${booking.title}" canceled.`, 'success')
refreshAll()
}
})
}
const openRejectBookingModal = (booking: Booking) => {
rejectBooking.value = booking
rejectReason.value = ''
rejectLoading.value = false
showRejectModal.value = true
}
const doReject = async () => {
if (!rejectBooking.value) return
rejectLoading.value = true
try {
await adminBookingsApi.reject(rejectBooking.value.id, rejectReason.value || undefined)
showToast(`Booking "${rejectBooking.value.title}" rejected.`, 'success')
showRejectModal.value = false
refreshAll()
} catch (err) {
showToast(handleApiError(err), 'error')
} finally {
rejectLoading.value = false
}
}
const openEditBookingModal = (booking: Booking) => {
editingBooking.value = booking
const startLocal = isoToLocalDateTime(booking.start_datetime, userTimezone.value)
const endLocal = isoToLocalDateTime(booking.end_datetime, userTimezone.value)
const [startDate, startTime] = startLocal.split('T')
const [endDate, endTime] = endLocal.split('T')
editForm.value = {
title: booking.title,
description: booking.description || '',
start_date: startDate,
start_time: startTime,
end_date: endDate,
end_time: endTime
}
editError.value = ''
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingBooking.value = null
editError.value = ''
}
const saveEdit = async () => {
if (!editingBooking.value) return
editSaving.value = true
editError.value = ''
try {
const startDateTime = `${editForm.value.start_date}T${editForm.value.start_time}`
const endDateTime = `${editForm.value.end_date}T${editForm.value.end_time}`
if (isAdmin.value) {
await adminBookingsApi.update(editingBooking.value.id, {
title: editForm.value.title,
description: editForm.value.description,
start_datetime: localDateTimeToISO(startDateTime),
end_datetime: localDateTimeToISO(endDateTime)
})
} else {
await bookingsApi.update(editingBooking.value.id, {
title: editForm.value.title,
description: editForm.value.description,
start_datetime: localDateTimeToISO(startDateTime),
end_datetime: localDateTimeToISO(endDateTime)
})
}
closeEditModal()
showToast('Booking updated successfully!', 'success')
refreshAll()
} catch (err) {
editError.value = handleApiError(err)
} finally {
editSaving.value = false
}
}
onMounted(() => {
@@ -380,6 +736,264 @@ onMounted(() => {
margin-bottom: 20px;
}
/* Bookings List Card */
.bookings-card-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 16px;
}
.result-count {
font-size: 13px;
color: var(--color-text-muted);
font-weight: 400;
}
.bookings-loading,
.bookings-empty {
text-align: center;
padding: 24px;
color: var(--color-text-muted);
font-size: 14px;
}
.bookings-table {
width: 100%;
border-collapse: collapse;
}
.bookings-table th {
text-align: left;
padding: 10px 12px;
background: var(--color-bg-secondary);
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--color-border);
}
.bookings-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-light);
font-size: 13px;
color: var(--color-text-primary);
vertical-align: middle;
}
.bookings-table tbody tr:hover {
background: var(--color-surface-hover);
}
.bookings-table tbody tr:last-child td {
border-bottom: none;
}
.cell-user {
font-weight: 500;
}
.cell-time {
white-space: nowrap;
}
.cell-title {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-actions {
white-space: nowrap;
}
.badge-status {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
}
.badge-pending {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.badge-approved {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-rejected {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.badge-canceled {
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
}
.btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0;
margin-right: 4px;
}
.btn-action:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-action-approve:hover { color: var(--color-success); border-color: var(--color-success); }
.btn-action-reject:hover { color: var(--color-danger); border-color: var(--color-danger); }
.btn-action-edit:hover { color: var(--color-warning); border-color: var(--color-warning); }
.btn-action-cancel:hover { color: var(--color-danger); border-color: var(--color-danger); }
/* Form styles for modals */
.form-group {
margin-bottom: 16px;
}
.form-group > label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.sublabel {
display: block;
margin-bottom: 4px;
font-weight: 400;
font-size: 12px;
color: var(--color-text-secondary);
}
.datetime-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.datetime-field {
display: flex;
flex-direction: column;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text-primary);
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-group textarea {
resize: vertical;
}
.error-msg {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
padding: 10px 20px;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.btn-danger {
background: var(--color-danger);
color: white;
padding: 10px 20px;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger) 85%, black);
}
.confirm-text {
color: var(--color-text-secondary);
margin-bottom: 20px;
line-height: 1.5;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
z-index: 1100;
animation: slideUp 0.3s ease;
box-shadow: var(--shadow-lg);
}
.toast-success { background: var(--color-success); color: #fff; }
.toast-error { background: var(--color-danger); color: #fff; }
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Modal */
.modal {
position: fixed;

View File

@@ -58,9 +58,12 @@
>
<div class="space-card-header">
<h3>{{ space.name }}</h3>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
{{ space.is_active ? 'Active' : 'Inactive' }}
</span>
<div class="header-badges">
<span v-if="space.property_name" class="badge badge-property">{{ space.property_name }}</span>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
{{ space.is_active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
<div class="space-card-body">
@@ -431,6 +434,18 @@ onMounted(() => {
color: var(--color-danger);
}
.badge-property {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
}
.header-badges {
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.space-card-body {
flex: 1;
margin-bottom: 20px;

View File

@@ -2,7 +2,7 @@
<div class="users">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Admin Dashboard - User Management</h2>
<h2>User Management</h2>
<button class="btn btn-primary" @click="openCreateModal">
<UserPlus :size="16" />
Create New User
@@ -16,7 +16,8 @@
<label for="filter-role">Filter by Role</label>
<select id="filter-role" v-model="filterRole" @change="loadUsers">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="admin">Superadmin</option>
<option value="manager">Manager</option>
<option value="user">User</option>
</select>
</div>
@@ -57,8 +58,8 @@
<td>{{ user.email }}</td>
<td>{{ user.full_name }}</td>
<td>
<span :class="['badge', user.role === 'admin' ? 'badge-admin' : 'badge-user']">
{{ user.role }}
<span :class="['badge', user.role === 'admin' || user.role === 'superadmin' ? 'badge-admin' : user.role === 'manager' ? 'badge-manager' : 'badge-user']">
{{ user.role === 'admin' ? 'superadmin' : user.role }}
</span>
</td>
<td>{{ user.organization || '-' }}</td>
@@ -140,7 +141,8 @@
<label for="role">Role *</label>
<select id="role" v-model="formData.role" required>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="admin">Superadmin</option>
</select>
</div>
@@ -268,7 +270,8 @@ const handleSubmit = async () => {
full_name: formData.value.full_name,
password: formData.value.password,
role: formData.value.role,
organization: formData.value.organization || undefined
organization: formData.value.organization || undefined,
timezone: 'UTC'
})
success.value = 'User created successfully!'
}
@@ -389,6 +392,8 @@ onMounted(() => {
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
@@ -593,6 +598,11 @@ onMounted(() => {
color: var(--color-accent);
}
.badge-manager {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.badge-user {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
@@ -619,15 +629,18 @@ onMounted(() => {
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
border-radius: var(--radius-lg);
padding: 28px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-bottom: 16px;
margin-top: 0;
margin-bottom: 20px;
color: var(--color-text-primary);
}