Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Agent
e21cf03a16 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>
2026-02-15 00:17:21 +00:00
Claude Agent
d637513d92 fix: allow admin to edit any booking via admin endpoint 2026-02-12 15:37:17 +00:00
Claude Agent
d245c72757 feat: complete UI/UX overhaul - dashboard unification, calendar UX, mobile optimization
- Dashboard redesign as command center with filters, quick actions, inline approve/reject
- Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal
- Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle
- Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout
- Collapsible filters with active count badge
- Smart menu positioning with Teleport
- Calendar/list bidirectional data sync
- Navigation: unified History page, removed AdminPending
- Google Calendar OAuth integration
- Dark mode contrast improvements, breadcrumb navigation
- useLocalStorage composable for state persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:34:47 +00:00
Claude Agent
a4d3f862d2 fix(dashboard): sidebar theming, calendar reactivity, and booking filters
Fix multiple dashboard and UI issues:

Frontend fixes:
- Fix sidebar remaining dark on light theme (add proper light/dark CSS variables)
- Fix DashboardCalendar blank/not showing events (use watch + calendar API instead of computed options)
- Fix upcoming bookings to include active and recent past (last 7 days) bookings
- Improve sidebar collapsed state UX (stack footer buttons vertically, full width)

Details:
- theme.css: Add light sidebar colors (white bg) for :root, keep dark colors for [data-theme="dark"]
- DashboardCalendar: Add watch on events, use calendarRef to update events via removeAllEvents/addEventSource
- Dashboard: Change upcoming filter from "startDate >= now" to "endDate >= 7 days ago"
- AppSidebar: Stack footer-actions vertically when collapsed for better visibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 10:30:43 +00:00
Claude Agent
28685d8254 feat(dashboard): redesign with active bookings, calendar, and compact stats
Major Dashboard improvements focusing on active reservations and calendar view:

Frontend changes:
- Add ActiveBookings component showing in-progress bookings with progress bars
- Add DashboardCalendar component with read-only calendar view of all user bookings
- Refactor Dashboard layout: active bookings → stats grid → calendar → activity
- Remove redundant Quick Actions and Available Spaces sections
- Make Quick Stats compact (36px icons, 20px font) and clickable (router-link)
- Add datetime utility functions (isBookingActive, getBookingProgress, formatRemainingTime)
- Fix MyBookings to read status query parameter from URL
- Auto-refresh active bookings every 60s with proper cleanup

Backend changes:
- Add GET /api/bookings/my/calendar endpoint with date range filtering
- Fix Google Calendar sync in reschedule_booking and admin_update_booking
- Add Google OAuth environment variables to .env.example

Design:
- Dark mode compatible with CSS variables throughout
- Mobile responsive (768px breakpoint, 2-column stats grid)
- CollapsibleSection pattern for all dashboard sections
- Progress bars with accent colors for active bookings

Performance:
- Optimized API calls (calendar uses date range filtering)
- Remove duplicate calendar data loading on mount
- Computed property caching for stats and filtered bookings
- Memory leak prevention (setInterval cleanup on unmount)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 10:21:32 +00:00
71 changed files with 12009 additions and 1900 deletions

View File

@@ -17,3 +17,11 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=noreply@space-booking.local
SMTP_ENABLED=false
# Frontend URL (used for OAuth callback redirects)
FRONTEND_URL=http://localhost:5173
# Google Calendar Integration
GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
GOOGLE_REDIRECT_URI=http://localhost:8000/api/integrations/google/callback

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,14 +5,20 @@ 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
from app.services.audit_service import log_action
from app.services.email_service import send_booking_notification
from app.services.google_calendar_service import create_calendar_event, delete_calendar_event
from app.services.google_calendar_service import (
create_calendar_event,
delete_calendar_event,
update_calendar_event,
)
from app.services.notification_service import create_notification
from app.schemas.booking import (
AdminCancelRequest,
@@ -35,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"])
@@ -64,9 +104,14 @@ def get_space_bookings(
detail="Space not found",
)
# Query bookings in the time range
# 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,
Booking.status.in_(["approved", "pending"]),
Booking.start_datetime < end,
Booking.end_datetime > start,
)
@@ -74,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]
@@ -111,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)
@@ -202,6 +251,37 @@ def get_my_bookings(
return [BookingWithSpace.model_validate(b) for b in bookings]
@bookings_router.get("/my/calendar", response_model=list[BookingWithSpace])
def get_my_bookings_calendar(
start: Annotated[datetime, Query(description="Start datetime (ISO format)")],
end: Annotated[datetime, Query(description="End datetime (ISO format)")],
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[BookingWithSpace]:
"""
Get user's bookings for calendar view within date range.
Query parameters:
- **start**: Start datetime in ISO format (e.g., 2024-01-01T00:00:00)
- **end**: End datetime in ISO format (e.g., 2024-01-31T23:59:59)
Returns bookings with status approved or pending, sorted by start time.
"""
bookings = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.filter(
Booking.user_id == current_user.id,
Booking.start_datetime < end,
Booking.end_datetime > start,
Booking.status.in_(["approved", "pending"]),
)
.order_by(Booking.start_datetime)
.all()
)
return [BookingWithSpace.model_validate(b) for b in bookings]
@bookings_router.post("", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
def create_booking(
booking_data: BookingCreate,
@@ -235,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)
@@ -257,6 +341,9 @@ def create_booking(
detail=errors[0], # Return first error
)
# Auto-approve if admin/superadmin, otherwise pending
is_admin = current_user.role in ("admin", "superadmin")
# Create booking (with UTC times)
booking = Booking(
user_id=user_id,
@@ -265,7 +352,8 @@ def create_booking(
end_datetime=end_datetime_utc,
title=booking_data.title,
description=booking_data.description,
status="pending",
status="approved" if is_admin else "pending",
approved_by=current_user.id if is_admin else None, # type: ignore[assignment]
created_at=datetime.utcnow(),
)
@@ -273,23 +361,40 @@ def create_booking(
db.commit()
db.refresh(booking)
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
if not is_admin:
# 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,
)
@@ -334,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(':'))
@@ -417,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.",
@@ -602,30 +722,99 @@ def cancel_booking(
admin_router = APIRouter(prefix="/admin/bookings", tags=["admin"])
@admin_router.get("/all", response_model=list[BookingPendingDetail])
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_manager_or_superadmin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all bookings across all users (admin/manager).
Returns bookings with user and space details.
Query parameters:
- **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)
.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)
if space_id is not None:
query = query.filter(Booking.space_id == space_id)
if user_id is not None:
query = query.filter(Booking.user_id == user_id)
if start is not None:
query = query.filter(Booking.end_datetime > start)
bookings = (
query.order_by(Booking.start_datetime.asc())
.limit(limit)
.all()
)
return [BookingPendingDetail.model_validate(b) for b in bookings]
@admin_router.get("/pending", response_model=list[BookingPendingDetail])
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)
@@ -644,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).
@@ -664,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(
@@ -673,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]
@@ -699,6 +892,7 @@ def approve_booking(
db.refresh(booking)
# Create Google Calendar event if user has connected their calendar
if booking.user_id:
google_event_id = create_calendar_event(
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
)
@@ -718,6 +912,7 @@ def approve_booking(
)
# Notify the user about approval
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
@@ -736,6 +931,16 @@ def approve_booking(
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
@@ -746,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).
@@ -766,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(
@@ -791,6 +999,7 @@ def reject_booking(
)
# Notify the user about rejection
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
@@ -809,6 +1018,15 @@ def reject_booking(
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
@@ -818,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.
@@ -837,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(
@@ -856,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,
)
@@ -873,6 +1095,15 @@ def admin_update_booking(
detail=errors[0],
)
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
user_id=int(booking.user_id), # type: ignore[arg-type]
event_id=booking.google_calendar_event_id,
)
# Log audit
log_action(
db=db,
@@ -894,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).
@@ -915,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,
@@ -943,6 +1177,7 @@ def admin_cancel_booking(
)
# Notify the user about cancellation
if booking.user_id and booking.user:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
@@ -970,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.
@@ -991,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(
@@ -1004,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,
)
@@ -1025,6 +1264,15 @@ def reschedule_booking(
booking.start_datetime = data.start_datetime # type: ignore[assignment]
booking.end_datetime = data.end_datetime # type: ignore[assignment]
# Sync with Google Calendar if event exists
if booking.google_calendar_event_id and booking.user_id:
update_calendar_event(
db=db,
booking=booking,
user_id=int(booking.user_id), # type: ignore[arg-type]
event_id=booking.google_calendar_event_id,
)
# Log audit
log_action(
db=db,
@@ -1041,6 +1289,7 @@ def reschedule_booking(
)
# Notify user about reschedule
if booking.user_id:
create_notification(
db=db,
user_id=booking.user_id, # type: ignore[arg-type]
@@ -1060,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)
@@ -1087,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

@@ -1,7 +1,9 @@
"""Google Calendar integration endpoints."""
import urllib.parse
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow
from sqlalchemy.orm import Session
@@ -9,9 +11,35 @@ from app.core.config import settings
from app.core.deps import get_current_user, get_db
from app.models.google_calendar_token import GoogleCalendarToken
from app.models.user import User
from app.services.google_calendar_service import (
create_oauth_state,
decrypt_token,
encrypt_token,
revoke_google_token,
sync_all_bookings,
verify_oauth_state,
)
router = APIRouter()
GOOGLE_SCOPES = [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
]
def _get_client_config() -> dict:
"""Build Google OAuth client configuration."""
return {
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
}
@router.get("/integrations/google/connect")
def connect_google(
@@ -20,7 +48,9 @@ def connect_google(
"""
Start Google OAuth flow.
Returns authorization URL that user should visit to grant access.
Returns an authorization URL with a signed state parameter for CSRF prevention.
The state encodes the user's identity so the callback can identify the user
without requiring an auth header (since it's a browser redirect from Google).
"""
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(
@@ -28,29 +58,23 @@ def connect_google(
detail="Google Calendar integration not configured",
)
# Create signed state with user_id for CSRF prevention
state = create_oauth_state(int(current_user.id)) # type: ignore[arg-type]
try:
flow = Flow.from_client_config(
{
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
},
scopes=[
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
_get_client_config(),
scopes=GOOGLE_SCOPES,
redirect_uri=settings.google_redirect_uri,
)
authorization_url, state = flow.authorization_url(
access_type="offline", include_granted_scopes="true", prompt="consent"
authorization_url, _ = flow.authorization_url(
access_type="offline",
include_granted_scopes="true",
prompt="consent",
state=state,
)
# Note: In production, store state in session/cache and validate it in callback
return {"authorization_url": authorization_url, "state": state}
except Exception as e:
raise HTTPException(
@@ -61,85 +85,113 @@ def connect_google(
@router.get("/integrations/google/callback")
def google_callback(
code: Annotated[str, Query()],
state: Annotated[str, Query()],
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
code: Annotated[str | None, Query()] = None,
state: Annotated[str | None, Query()] = None,
error: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""
Handle Google OAuth callback.
Exchange authorization code for tokens and store them.
This endpoint receives the browser redirect from Google after authorization.
User identity is verified via the signed state parameter (no auth header needed).
After processing, redirects to the frontend settings page with status.
"""
frontend_settings = f"{settings.frontend_url}/settings"
# Handle user denial or Google error
if error:
msg = urllib.parse.quote(error)
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message={msg}"
)
if not code or not state:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Missing+code+or+state"
)
# Verify state and extract user_id (CSRF protection)
user_id = verify_oauth_state(state)
if user_id is None:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Invalid+or+expired+state"
)
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=User+not+found"
)
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google Calendar integration not configured",
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Not+configured"
)
try:
flow = Flow.from_client_config(
{
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
},
scopes=[
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
_get_client_config(),
scopes=GOOGLE_SCOPES,
redirect_uri=settings.google_redirect_uri,
state=state,
)
# Exchange code for tokens
# Exchange authorization code for tokens
flow.fetch_token(code=code)
credentials = flow.credentials
# Store tokens
# Encrypt tokens for secure database storage
encrypted_access = encrypt_token(credentials.token)
encrypted_refresh = (
encrypt_token(credentials.refresh_token)
if credentials.refresh_token
else ""
)
# Store or update tokens
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.filter(GoogleCalendarToken.user_id == user_id)
.first()
)
if token_record:
token_record.access_token = credentials.token # type: ignore[assignment]
token_record.refresh_token = credentials.refresh_token # type: ignore[assignment]
token_record.access_token = encrypted_access # type: ignore[assignment]
token_record.refresh_token = encrypted_refresh # type: ignore[assignment]
token_record.token_expiry = credentials.expiry # type: ignore[assignment]
else:
token_record = GoogleCalendarToken(
user_id=current_user.id, # type: ignore[arg-type]
access_token=credentials.token,
refresh_token=credentials.refresh_token,
user_id=user_id, # type: ignore[arg-type]
access_token=encrypted_access,
refresh_token=encrypted_refresh,
token_expiry=credentials.expiry,
)
db.add(token_record)
db.commit()
return {"message": "Google Calendar connected successfully"}
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=connected"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"OAuth failed: {str(e)}",
msg = urllib.parse.quote(str(e))
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message={msg}"
)
@router.delete("/integrations/google/disconnect")
@router.post("/integrations/google/disconnect")
def disconnect_google(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
"""
Disconnect Google Calendar.
Disconnect Google Calendar and revoke access.
Removes stored tokens for the current user.
Attempts to revoke the token with Google, then removes stored tokens.
Token revocation is best-effort - local tokens are always deleted.
"""
token_record = (
db.query(GoogleCalendarToken)
@@ -148,12 +200,56 @@ def disconnect_google(
)
if token_record:
# Attempt to revoke the token with Google (best-effort)
try:
access_token = decrypt_token(token_record.access_token)
revoke_google_token(access_token)
except Exception:
pass # Delete locally even if revocation fails
db.delete(token_record)
db.commit()
return {"message": "Google Calendar disconnected"}
@router.post("/integrations/google/sync")
def sync_google_calendar(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict:
"""
Manually sync all approved future bookings to Google Calendar.
Creates calendar events for bookings that don't have one yet,
and updates existing events for bookings that do.
Returns a summary of sync results (created/updated/failed counts).
"""
# Check if connected
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.first()
)
if not token_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google Calendar not connected. Please connect first.",
)
result = sync_all_bookings(db, int(current_user.id)) # type: ignore[arg-type]
if "error" in result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"],
)
return result
@router.get("/integrations/google/status")
def google_status(
db: Annotated[Session, Depends(get_db)],
@@ -162,7 +258,8 @@ def google_status(
"""
Check Google Calendar connection status.
Returns whether user has connected their Google Calendar account.
Returns whether user has connected their Google Calendar account
and when the current token expires.
"""
token_record = (
db.query(GoogleCalendarToken)
@@ -172,5 +269,9 @@ def google_status(
return {
"connected": token_record is not None,
"expires_at": token_record.token_expiry.isoformat() if token_record and token_record.token_expiry else None,
"expires_at": (
token_record.token_expiry.isoformat()
if token_record and token_record.token_expiry
else None
),
}

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
@@ -93,10 +110,11 @@ def validate_booking_rules(
f"Rezervările sunt permise doar între {wh_start}:00 și {wh_end}:00"
)
# c) Check for overlapping bookings
# c) Check for overlapping bookings (only approved block new bookings;
# admin re-validates at approval time to catch conflicts)
query = db.query(Booking).filter(
Booking.space_id == space_id,
Booking.status.in_(["approved", "pending"]),
Booking.status == "approved",
and_(
Booking.start_datetime < end_datetime,
Booking.end_datetime > start_datetime,

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

@@ -1,10 +1,16 @@
"""Google Calendar integration service."""
from datetime import datetime
import base64
import hashlib
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
@@ -12,16 +18,92 @@ from app.models.booking import Booking
from app.models.google_calendar_token import GoogleCalendarToken
# --- Token Encryption ---
def _get_fernet() -> Fernet:
"""Get Fernet instance derived from SECRET_KEY for token encryption."""
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_token(token: str) -> str:
"""Encrypt a token string for secure database storage."""
return _get_fernet().encrypt(token.encode()).decode()
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt a token string from database storage."""
try:
return _get_fernet().decrypt(encrypted_token.encode()).decode()
except InvalidToken:
# Fallback for legacy unencrypted tokens
return encrypted_token
# --- OAuth State (CSRF Prevention) ---
def create_oauth_state(user_id: int) -> str:
"""Create a signed JWT state parameter for CSRF prevention.
Encodes user_id in a short-lived JWT token that will be validated
on the OAuth callback to prevent CSRF attacks.
"""
expire = datetime.utcnow() + timedelta(minutes=10)
payload = {
"sub": str(user_id),
"type": "google_oauth_state",
"exp": expire,
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def verify_oauth_state(state: str) -> Optional[int]:
"""Verify and decode the OAuth state parameter.
Returns user_id if valid, None otherwise.
"""
try:
payload = jwt.decode(
state, settings.secret_key, algorithms=[settings.algorithm]
)
if payload.get("type") != "google_oauth_state":
return None
return int(payload["sub"])
except (JWTError, KeyError, ValueError):
return None
# --- Token Revocation ---
def revoke_google_token(token: str) -> bool:
"""Revoke a Google OAuth token via Google's revocation endpoint."""
try:
params = urllib.parse.urlencode({"token": token})
request = urllib.request.Request(
f"https://oauth2.googleapis.com/revoke?{params}",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
urllib.request.urlopen(request, timeout=10)
return True
except Exception:
return False
# --- Google Calendar Service ---
def get_google_calendar_service(db: Session, user_id: int):
"""
Get authenticated Google Calendar service for user.
Args:
db: Database session
user_id: User ID
Handles token decryption and automatic refresh of expired tokens.
Returns:
Google Calendar service object or None if not connected
Google Calendar service object or None if not connected/failed
"""
token_record = (
db.query(GoogleCalendarToken)
@@ -32,10 +114,14 @@ def get_google_calendar_service(db: Session, user_id: int):
if not token_record:
return None
# Decrypt tokens from database
access_token = decrypt_token(token_record.access_token)
refresh_token = decrypt_token(token_record.refresh_token)
# Create credentials
creds = Credentials(
token=token_record.access_token,
refresh_token=token_record.refresh_token,
token=access_token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
@@ -46,13 +132,13 @@ def get_google_calendar_service(db: Session, user_id: int):
try:
creds.refresh(Request())
# Update tokens in DB
token_record.access_token = creds.token # type: ignore[assignment]
# Update encrypted tokens in DB
token_record.access_token = encrypt_token(creds.token) # type: ignore[assignment]
if creds.expiry:
token_record.token_expiry = creds.expiry # type: ignore[assignment]
db.commit()
except Exception as e:
print(f"Failed to refresh Google token: {e}")
print(f"Failed to refresh Google token for user {user_id}: {e}")
return None
# Build service
@@ -70,11 +156,6 @@ def create_calendar_event(
"""
Create Google Calendar event for booking.
Args:
db: Database session
booking: Booking object
user_id: User ID
Returns:
Google Calendar event ID or None if failed
"""
@@ -83,7 +164,6 @@ def create_calendar_event(
if not service:
return None
# Create event
event = {
"summary": f"{booking.space.name}: {booking.title}",
"description": booking.description or "",
@@ -111,12 +191,6 @@ def update_calendar_event(
"""
Update Google Calendar event for booking.
Args:
db: Database session
booking: Booking object
user_id: User ID
event_id: Google Calendar event ID
Returns:
True if successful, False otherwise
"""
@@ -125,7 +199,6 @@ def update_calendar_event(
if not service:
return False
# Update event
event = {
"summary": f"{booking.space.name}: {booking.title}",
"description": booking.description or "",
@@ -153,11 +226,6 @@ def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
"""
Delete Google Calendar event.
Args:
db: Database session
event_id: Google Calendar event ID
user_id: User ID
Returns:
True if successful, False otherwise
"""
@@ -171,3 +239,65 @@ def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
except Exception as e:
print(f"Failed to delete Google Calendar event: {e}")
return False
def sync_all_bookings(db: Session, user_id: int) -> dict:
"""
Sync all approved future bookings to Google Calendar.
Creates events for bookings without a google_calendar_event_id,
and updates events for bookings that already have one.
Returns:
Summary dict with created/updated/failed counts
"""
service = get_google_calendar_service(db, user_id)
if not service:
return {"error": "Google Calendar not connected or token expired"}
# Get all approved future bookings for the user
bookings = (
db.query(Booking)
.filter(
Booking.user_id == user_id,
Booking.status == "approved",
Booking.start_datetime >= datetime.utcnow(),
)
.all()
)
created = 0
updated = 0
failed = 0
for booking in bookings:
try:
if booking.google_calendar_event_id:
# Update existing event
success = update_calendar_event(
db, booking, user_id, booking.google_calendar_event_id
)
if success:
updated += 1
else:
failed += 1
else:
# Create new event
event_id = create_calendar_event(db, booking, user_id)
if event_id:
booking.google_calendar_event_id = event_id # type: ignore[assignment]
created += 1
else:
failed += 1
except Exception:
failed += 1
db.commit()
return {
"synced": created + updated,
"created": created,
"updated": updated,
"failed": failed,
"total_bookings": len(bookings),
}

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

@@ -11,6 +11,7 @@
"@fullcalendar/core": "^6.1.0",
"@fullcalendar/daygrid": "^6.1.0",
"@fullcalendar/interaction": "^6.1.0",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0",
"axios": "^1.6.0",
@@ -582,6 +583,15 @@
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/list": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz",
"integrity": "sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",

View File

@@ -13,6 +13,7 @@
"@fullcalendar/core": "^6.1.0",
"@fullcalendar/daygrid": "^6.1.0",
"@fullcalendar/interaction": "^6.1.0",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0",
"axios": "^1.6.0",

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[]>([])
@@ -117,7 +121,7 @@ const handleNotificationClick = async (notification: Notification) => {
if (notification.booking_id) {
closeNotifications()
router.push('/my-bookings')
router.push('/history')
}
}

View File

@@ -35,10 +35,10 @@
/* Sidebar */
--sidebar-width: 260px;
--sidebar-collapsed-width: 68px;
--sidebar-bg: #1a1a2e;
--sidebar-text: #a1a1b5;
--sidebar-text-active: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
--sidebar-bg: #ffffff;
--sidebar-text: #6b7280;
--sidebar-text-active: #1a1a2e;
--sidebar-hover-bg: #f3f4f6;
/* Spacing */
--radius-sm: 6px;
@@ -52,18 +52,24 @@
/* Dark Theme */
[data-theme="dark"] {
--color-bg-primary: #0f0f1a;
--color-bg-secondary: #1a1a2e;
--color-bg-tertiary: #232340;
--color-surface: #1a1a2e;
--color-surface-hover: #232340;
--color-text-primary: #e5e5ef;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
--color-accent-light: #1e1b4b;
--color-border: #2d2d4a;
--color-border-light: #232340;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--color-bg-primary: #0a0a12;
--color-bg-secondary: #151520;
--color-bg-tertiary: #1f1f2e;
--color-surface: #151520;
--color-surface-hover: #1f1f2e;
--color-text-primary: #f0f0f5;
--color-text-secondary: #b8bac5;
--color-text-muted: #8b8d9a;
--color-accent-light: #2a2650;
--color-border: #3a3a52;
--color-border-light: #2d2d42;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
/* Sidebar - Dark theme */
--sidebar-bg: #151520;
--sidebar-text: #b8bac5;
--sidebar-text-active: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.1);
}

View File

@@ -0,0 +1,224 @@
<template>
<div class="action-menu" ref="menuRef">
<button
class="action-menu-trigger"
ref="triggerRef"
@click.stop="toggleMenu"
title="Actions"
>
<MoreVertical :size="16" />
</button>
<Teleport to="body">
<Transition name="menu-fade">
<div
v-if="open"
class="action-menu-dropdown"
:class="{ upward: openUpward }"
:style="dropdownStyle"
ref="dropdownRef"
@click.stop
>
<button
v-for="action in visibleActions"
:key="action.key"
class="action-menu-item"
:style="action.color ? { color: action.color } : {}"
@click.stop="selectAction(action.key)"
>
<component :is="action.icon" :size="15" />
<span>{{ action.label }}</span>
</button>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, type Component } from 'vue'
import { MoreVertical } from 'lucide-vue-next'
export interface ActionItem {
key: string
label: string
icon: Component
color?: string
show?: boolean
}
const props = defineProps<{
actions: ActionItem[]
}>()
const emit = defineEmits<{
select: [key: string]
}>()
const open = ref(false)
const openUpward = ref(false)
const menuRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownStyle = ref<Record<string, string>>({})
const visibleActions = computed(() =>
props.actions.filter((a) => a.show !== false)
)
const updateDropdownPosition = () => {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const menuHeight = 200 // estimated max dropdown height
const spaceBelow = window.innerHeight - rect.bottom
const shouldOpenUp = spaceBelow < menuHeight && rect.top > spaceBelow
openUpward.value = shouldOpenUp
if (shouldOpenUp) {
dropdownStyle.value = {
position: 'fixed',
bottom: `${window.innerHeight - rect.top + 4}px`,
right: `${window.innerWidth - rect.right}px`
}
} else {
dropdownStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
right: `${window.innerWidth - rect.right}px`
}
}
}
const toggleMenu = () => {
const wasOpen = open.value
// Close all other open menus first
document.dispatchEvent(new CustomEvent('action-menu:close-all'))
if (!wasOpen) {
updateDropdownPosition()
open.value = true
}
}
const selectAction = (key: string) => {
open.value = false
emit('select', key)
}
const handleClickOutside = (e: MouseEvent) => {
if (!open.value) return
const target = e.target as Node
const inMenu = menuRef.value?.contains(target)
const inDropdown = dropdownRef.value?.contains(target)
if (!inMenu && !inDropdown) {
open.value = false
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open.value) {
open.value = false
}
}
const handleCloseAll = () => {
open.value = false
}
const handleScroll = () => {
if (open.value) {
open.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('action-menu:close-all', handleCloseAll)
window.addEventListener('scroll', handleScroll, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('action-menu:close-all', handleCloseAll)
window.removeEventListener('scroll', handleScroll, true)
})
</script>
<style>
/* Not scoped - dropdown is teleported to body */
.action-menu {
position: relative;
display: inline-flex;
}
.action-menu-trigger {
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;
}
.action-menu-trigger:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.action-menu-dropdown {
min-width: 160px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 9999;
overflow: hidden;
padding: 4px 0;
}
.action-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--color-text-primary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: background var(--transition-fast);
text-align: left;
}
.action-menu-item:hover {
background: var(--color-bg-secondary);
}
/* Transition */
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.upward.menu-fade-enter-from,
.upward.menu-fade-leave-to {
transform: translateY(4px);
}
</style>

View File

@@ -0,0 +1,258 @@
<template>
<div v-if="activeBookings.length > 0" class="active-bookings">
<div class="active-header">
<Zap :size="20" class="active-icon" />
<h3>Active Now</h3>
</div>
<div class="active-list">
<div
v-for="booking in activeBookings"
:key="booking.id"
class="active-card"
>
<div class="active-card-top">
<div class="active-info">
<h4>{{ booking.title }}</h4>
<p class="active-space">{{ booking.space?.name || 'Space' }}</p>
<p class="active-time">
{{ formatTime(booking.start_datetime) }} {{ formatTime(booking.end_datetime) }}
</p>
</div>
<span class="active-remaining">{{ getRemainingTime(booking.end_datetime) }}</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: getProgress(booking.start_datetime, booking.end_datetime) + '%' }"
/>
</div>
<div class="active-actions">
<router-link :to="`/history`" class="action-btn action-btn-view" title="View bookings">
<Eye :size="16" />
</router-link>
<button
v-if="booking.status === 'pending'"
class="action-btn action-btn-edit"
title="Edit booking"
@click="$emit('refresh')"
>
<Pencil :size="16" />
</button>
<button
class="action-btn action-btn-cancel"
title="Cancel booking"
@click="$emit('cancel', booking)"
>
<X :size="16" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { Zap, Eye, Pencil, X } from 'lucide-vue-next'
import { isBookingActive, getBookingProgress, formatRemainingTime } from '@/utils/datetime'
import { formatTime as formatTimeUtil } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import type { Booking } from '@/types'
const props = defineProps<{
bookings: Booking[]
}>()
defineEmits<{
cancel: [booking: Booking]
refresh: []
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const now = ref(new Date())
const activeBookings = computed(() => {
void now.value
return props.bookings.filter(
(b) => b.status === 'approved' && isBookingActive(b.start_datetime, b.end_datetime)
)
})
const formatTime = (datetime: string): string => {
return formatTimeUtil(datetime, userTimezone.value)
}
const getProgress = (start: string, end: string): number => {
void now.value
return getBookingProgress(start, end)
}
const getRemainingTime = (end: string): string => {
void now.value
return formatRemainingTime(end)
}
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refreshInterval = setInterval(() => {
now.value = new Date()
}, 60000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
})
</script>
<style scoped>
.active-bookings {
background: var(--color-surface);
border: 1px solid var(--color-accent);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.active-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: var(--color-accent-light);
border-bottom: 1px solid var(--color-border-light);
}
.active-icon {
color: var(--color-accent);
}
.active-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
}
.active-list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border-light);
}
.active-card {
padding: 1rem 1.25rem;
background: var(--color-surface);
}
.active-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.active-info h4 {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 4px;
}
.active-space {
font-size: 13px;
color: var(--color-text-secondary);
margin: 0 0 2px;
}
.active-time {
font-size: 12px;
color: var(--color-text-muted);
margin: 0;
}
.active-remaining {
font-size: 13px;
font-weight: 600;
color: var(--color-accent);
white-space: nowrap;
flex-shrink: 0;
}
.progress-bar {
height: 6px;
background: var(--color-bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-hover));
border-radius: 3px;
transition: width 1s ease;
}
.active-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
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);
text-decoration: none;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.action-btn-view:hover {
color: var(--color-info);
border-color: var(--color-info);
background: color-mix(in srgb, var(--color-info) 10%, transparent);
}
.action-btn-edit:hover {
color: var(--color-warning);
border-color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.action-btn-cancel:hover {
color: var(--color-danger);
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
}
/* Mobile responsive */
@media (max-width: 768px) {
.active-card-top {
flex-direction: column;
gap: 0.5rem;
}
.active-remaining {
align-self: flex-start;
}
}
</style>

View File

@@ -0,0 +1,460 @@
<template>
<div class="admin-booking-form">
<div class="admin-banner">
Admin Direct Booking will be approved immediately
</div>
<form @submit.prevent="handleSubmit">
<!-- User Selection -->
<div class="form-group">
<label for="admin-user" class="form-label">Book for User *</label>
<select
id="admin-user"
v-model="formData.user_id"
class="form-input"
:disabled="loadingUsers"
>
<option :value="null">Select a user</option>
<optgroup v-for="group in groupedUsers" :key="group.org" :label="group.org">
<option v-for="user in group.users" :key="user.id" :value="user.id">
{{ user.full_name }} ({{ user.email }})
</option>
</optgroup>
</select>
<span v-if="errors.user_id" class="form-error">{{ errors.user_id }}</span>
</div>
<!-- Space Selection -->
<div class="form-group">
<label for="admin-space" class="form-label">Space *</label>
<select
v-if="!spaceId"
id="admin-space"
v-model="formData.space_id"
class="form-input"
:disabled="loadingSpaces"
>
<option :value="null">Select a space</option>
<option v-for="space in activeSpaces" :key="space.id" :value="space.id">
{{ space.name }} ({{ space.type }}, Capacity: {{ space.capacity }})
</option>
</select>
<input
v-else
type="text"
class="form-input"
:value="selectedSpaceName"
readonly
disabled
/>
<span v-if="errors.space_id" class="form-error">{{ errors.space_id }}</span>
</div>
<!-- Title -->
<div class="form-group">
<label for="admin-title" class="form-label">Title *</label>
<input
id="admin-title"
v-model="formData.title"
type="text"
class="form-input"
placeholder="Booking title"
maxlength="200"
/>
<span v-if="errors.title" class="form-error">{{ errors.title }}</span>
</div>
<!-- Description -->
<div class="form-group">
<label for="admin-desc" class="form-label">Description (optional)</label>
<textarea
id="admin-desc"
v-model="formData.description"
class="form-textarea"
rows="2"
placeholder="Additional details..."
></textarea>
</div>
<!-- Start Date & Time -->
<div class="form-group">
<label class="form-label">Start *</label>
<div class="datetime-row">
<div class="datetime-field">
<label for="admin-start-date" class="form-sublabel">Date</label>
<input
id="admin-start-date"
v-model="formData.start_date"
type="date"
class="form-input"
:min="minDate"
/>
</div>
<div class="datetime-field">
<label for="admin-start-time" class="form-sublabel">Time</label>
<input
id="admin-start-time"
v-model="formData.start_time"
type="time"
class="form-input"
/>
</div>
</div>
<span v-if="errors.start" class="form-error">{{ errors.start }}</span>
</div>
<!-- End Date & Time -->
<div class="form-group">
<label class="form-label">End *</label>
<div class="datetime-row">
<div class="datetime-field">
<label for="admin-end-date" class="form-sublabel">Date</label>
<input
id="admin-end-date"
v-model="formData.end_date"
type="date"
class="form-input"
:min="formData.start_date || minDate"
/>
</div>
<div class="datetime-field">
<label for="admin-end-time" class="form-sublabel">Time</label>
<input
id="admin-end-time"
v-model="formData.end_time"
type="time"
class="form-input"
/>
</div>
</div>
<span v-if="errors.end" class="form-error">{{ errors.end }}</span>
</div>
<!-- Booking As indicator -->
<div v-if="selectedUserName" class="booking-as">
Booking as: <strong>{{ selectedUserName }}</strong>
</div>
<!-- API Error -->
<div v-if="apiError" class="api-error">{{ apiError }}</div>
<!-- Success -->
<div v-if="successMessage" class="success-message">{{ successMessage }}</div>
<!-- Actions -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="$emit('cancel')">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? 'Creating...' : 'Create Booking' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { spacesApi, usersApi, adminBookingsApi, handleApiError } from '@/services/api'
import type { Space, User, BookingAdminCreate } from '@/types'
interface Props {
spaceId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'submit'): void
(e: 'cancel'): void
}>()
const spaces = ref<Space[]>([])
const users = ref<User[]>([])
const loadingSpaces = ref(false)
const loadingUsers = ref(false)
const submitting = ref(false)
const apiError = ref('')
const successMessage = ref('')
const errors = ref<Record<string, string>>({})
const now = new Date()
const startHour = now.getHours() + 1
const pad = (n: number) => String(n).padStart(2, '0')
const todayStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
const formData = ref({
user_id: null as number | null,
space_id: (props.spaceId || null) as number | null,
title: '',
description: '',
start_date: todayStr,
start_time: `${pad(startHour)}:00`,
end_date: todayStr,
end_time: `${pad(startHour + 1)}:00`
})
const activeSpaces = computed(() => spaces.value.filter(s => s.is_active))
const selectedSpaceName = computed(() => {
if (!props.spaceId) return ''
const space = spaces.value.find(s => s.id === props.spaceId)
return space ? `${space.name} (${space.type})` : ''
})
const selectedUserName = computed(() => {
if (!formData.value.user_id) return ''
const user = users.value.find(u => u.id === formData.value.user_id)
return user ? `${user.full_name} (${user.email})` : ''
})
const minDate = computed(() => todayStr)
// Group users by organization
const groupedUsers = computed(() => {
const groups = new Map<string, User[]>()
for (const user of users.value.filter(u => u.is_active)) {
const org = user.organization || 'No Organization'
if (!groups.has(org)) groups.set(org, [])
groups.get(org)!.push(user)
}
return Array.from(groups.entries())
.map(([org, users]) => ({ org, users: users.sort((a, b) => a.full_name.localeCompare(b.full_name)) }))
.sort((a, b) => a.org.localeCompare(b.org))
})
const loadData = async () => {
loadingSpaces.value = true
loadingUsers.value = true
try {
const [spaceData, userData] = await Promise.all([
spacesApi.list(),
usersApi.list()
])
spaces.value = spaceData
users.value = userData
} catch (err) {
apiError.value = handleApiError(err)
} finally {
loadingSpaces.value = false
loadingUsers.value = false
}
}
const handleSubmit = async () => {
errors.value = {}
apiError.value = ''
successMessage.value = ''
// Validate
if (!formData.value.user_id) {
errors.value.user_id = 'Please select a user'
}
if (!formData.value.space_id) {
errors.value.space_id = 'Please select a space'
}
if (!formData.value.title.trim()) {
errors.value.title = 'Title is required'
}
if (!formData.value.start_date || !formData.value.start_time) {
errors.value.start = 'Start date and time are required'
}
if (!formData.value.end_date || !formData.value.end_time) {
errors.value.end = 'End date and time are required'
}
if (formData.value.start_date && formData.value.start_time && formData.value.end_date && formData.value.end_time) {
const start = new Date(`${formData.value.start_date}T${formData.value.start_time}`)
const end = new Date(`${formData.value.end_date}T${formData.value.end_time}`)
if (end <= start) {
errors.value.end = 'End must be after start'
}
}
if (Object.keys(errors.value).length > 0) return
submitting.value = true
try {
const payload: BookingAdminCreate = {
space_id: formData.value.space_id!,
user_id: formData.value.user_id!,
start_datetime: `${formData.value.start_date}T${formData.value.start_time}:00`,
end_datetime: `${formData.value.end_date}T${formData.value.end_time}:00`,
title: formData.value.title.trim(),
description: formData.value.description.trim() || undefined
}
await adminBookingsApi.create(payload)
successMessage.value = 'Booking created and approved!'
setTimeout(() => {
emit('submit')
}, 1000)
} catch (err) {
apiError.value = handleApiError(err)
} finally {
submitting.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.admin-banner {
background: color-mix(in srgb, var(--color-info) 15%, transparent);
color: var(--color-info);
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.form-sublabel {
display: block;
margin-bottom: 4px;
font-weight: 400;
color: var(--color-text-secondary);
font-size: 12px;
}
.datetime-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.datetime-field {
display: flex;
flex-direction: column;
}
.form-input,
.form-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);
transition: border-color var(--transition-fast);
}
.form-input:focus,
.form-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-input:disabled {
background: var(--color-bg-tertiary);
cursor: not-allowed;
}
.form-textarea {
resize: vertical;
}
.form-error {
display: block;
margin-top: 4px;
color: var(--color-danger);
font-size: 13px;
}
.booking-as {
padding: 10px 14px;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
.api-error {
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;
}
.success-message {
padding: 12px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn {
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);
}
@media (max-width: 640px) {
.datetime-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column-reverse;
}
.btn {
width: 100%;
}
}
</style>

View File

@@ -1,13 +1,15 @@
<template>
<aside class="sidebar" :class="{ collapsed, 'mobile-open': mobileOpen }">
<div class="sidebar-header">
<div class="sidebar-header" @click="handleHeaderClick" :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
<LayoutDashboard :size="24" class="sidebar-logo-icon" />
<span v-show="!collapsed" class="sidebar-title">Space Booking</span>
<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="!collapsed" class="nav-section-label">Main</span>
<span v-show="showLabels" class="nav-section-label">Main</span>
<router-link
v-for="item in mainNav"
:key="item.to"
@@ -17,14 +19,14 @@
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
<div v-if="authStore.isAdmin" class="nav-section">
<span v-show="!collapsed" class="nav-section-label">Admin</span>
<div v-if="authStore.isAdminOrManager" class="nav-section">
<span v-show="showLabels" class="nav-section-label">Management</span>
<router-link
v-for="item in adminNav"
v-for="item in managerNav"
:key="item.to"
:to="item.to"
class="nav-link"
@@ -32,13 +34,28 @@
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
<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 superadminNav"
: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>
</nav>
<div class="sidebar-footer">
<div v-show="!collapsed" class="user-info">
<div v-show="showLabels" class="user-info">
<div class="user-avatar">
{{ authStore.user?.email?.charAt(0).toUpperCase() }}
</div>
@@ -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,
@@ -79,7 +97,6 @@ import {
User,
Settings2,
Users,
ClipboardCheck,
Sliders,
BarChart3,
ScrollText,
@@ -87,7 +104,8 @@ import {
Moon,
ChevronLeft,
ChevronRight,
LogOut
LogOut,
Landmark
} from 'lucide-vue-next'
const authStore = useAuthStore()
@@ -96,6 +114,9 @@ const router = useRouter()
const { collapsed, mobileOpen, toggle, closeMobile } = useSidebar()
const { theme, resolvedTheme, toggleTheme } = useTheme()
// On mobile, always show labels when sidebar is open (even if collapsed on desktop)
const showLabels = computed(() => !collapsed.value || mobileOpen.value)
const themeTitle = computed(() => {
if (theme.value === 'light') return 'Switch to dark mode'
if (theme.value === 'dark') return 'Switch to auto mode'
@@ -105,14 +126,17 @@ const themeTitle = computed(() => {
const mainNav = [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/spaces', icon: Building2, label: 'Spaces' },
{ to: '/my-bookings', icon: CalendarDays, label: 'My Bookings' },
{ to: '/history', icon: CalendarDays, label: 'History' },
{ 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/pending', icon: ClipboardCheck, label: 'Pending' },
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },
{ to: '/admin/audit-log', icon: ScrollText, label: 'Audit Log' },
@@ -123,6 +147,13 @@ const isActive = (path: string) => {
return route.path.startsWith(path)
}
const handleHeaderClick = () => {
// Only toggle on desktop (≥768px)
if (window.innerWidth >= 768) {
toggle()
}
}
const handleLogout = () => {
authStore.logout()
router.push('/login')
@@ -148,6 +179,16 @@ const handleLogout = () => {
width: var(--sidebar-collapsed-width);
}
.sidebar.collapsed .footer-actions {
flex-direction: column;
gap: 0.5rem;
}
.sidebar.collapsed .footer-btn {
width: 100%;
justify-content: center;
}
/* Header */
.sidebar-header {
display: flex;
@@ -156,6 +197,12 @@ const handleLogout = () => {
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
min-height: 60px;
cursor: pointer;
transition: background var(--transition-fast);
}
.sidebar-header:hover {
background: var(--sidebar-hover-bg);
}
.sidebar-logo-icon {
@@ -313,6 +360,14 @@ const handleLogout = () => {
width: var(--sidebar-width);
}
.sidebar-header {
cursor: default;
}
.sidebar-header:hover {
background: transparent;
}
.desktop-only {
display: none;
}

View File

@@ -0,0 +1,338 @@
<template>
<Transition name="modal-fade">
<div v-if="show && booking" class="edit-overlay" @click.self="$emit('close')">
<div class="edit-modal">
<h3>Edit Booking</h3>
<form @submit.prevent="saveEdit">
<div class="form-group">
<label for="edit-space">Space</label>
<input
id="edit-space"
type="text"
:value="booking.space?.name || 'Unknown'"
readonly
disabled
/>
</div>
<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..."
/>
</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="$emit('close')">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { isoToLocalDateTime, localDateTimeToISO } from '@/utils/datetime'
import type { Booking } from '@/types'
const props = withDefaults(defineProps<{
booking: Booking | null
show: boolean
isAdmin?: boolean
}>(), {
isAdmin: false
})
const emit = defineEmits<{
close: []
saved: []
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const editForm = ref({
title: '',
description: '',
start_date: '',
start_time: '',
end_date: '',
end_time: ''
})
const editError = ref('')
const saving = ref(false)
// Populate form when booking changes or modal opens
watch(() => [props.booking, props.show], () => {
if (props.show && props.booking) {
const startLocal = isoToLocalDateTime(props.booking.start_datetime, userTimezone.value)
const endLocal = isoToLocalDateTime(props.booking.end_datetime, userTimezone.value)
const [startDate, startTime] = startLocal.split('T')
const [endDate, endTime] = endLocal.split('T')
editForm.value = {
title: props.booking.title,
description: props.booking.description || '',
start_date: startDate,
start_time: startTime,
end_date: endDate,
end_time: endTime
}
editError.value = ''
}
}, { immediate: true })
const saveEdit = async () => {
if (!props.booking) return
saving.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}`
const updateData = {
title: editForm.value.title,
description: editForm.value.description,
start_datetime: localDateTimeToISO(startDateTime),
end_datetime: localDateTimeToISO(endDateTime)
}
if (props.isAdmin) {
await adminBookingsApi.update(props.booking.id, updateData)
} else {
await bookingsApi.update(props.booking.id, updateData)
}
emit('saved')
} catch (err) {
editError.value = handleApiError(err)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.edit-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.edit-modal {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.edit-modal h3 {
margin: 0 0 20px;
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.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 input:disabled {
background: var(--color-bg-tertiary);
cursor: not-allowed;
}
.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 {
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
font-family: inherit;
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);
}
/* Modal transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .edit-modal,
.modal-fade-leave-active .edit-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .edit-modal,
.modal-fade-leave-to .edit-modal {
transform: scale(0.95);
opacity: 0;
}
@media (max-width: 640px) {
.edit-modal {
max-width: none;
width: calc(100% - 32px);
margin: 16px;
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="booking-filters">
<button v-if="collapsible" class="filter-toggle-btn" @click="collapsed = !collapsed">
<SlidersHorizontal :size="15" />
<span>Filters</span>
<span v-if="activeCount > 0" class="filter-badge">{{ activeCount }}</span>
<ChevronDown :size="14" class="toggle-chevron" :class="{ rotated: !collapsed }" />
</button>
<div class="filters-content" :class="{ 'filters-collapsed': collapsible && collapsed }">
<div class="filter-fields">
<div class="filter-field">
<label for="filter-space">Space</label>
<select id="filter-space" :value="modelValue.space_id" @change="updateFilter('space_id', ($event.target as HTMLSelectElement).value || null)">
<option :value="null">All Spaces</option>
<option v-for="space in spaces" :key="space.id" :value="space.id">
{{ space.name }}
</option>
</select>
</div>
<div class="filter-field">
<label for="filter-status">Status</label>
<select id="filter-status" :value="modelValue.status" @change="updateFilter('status', ($event.target as HTMLSelectElement).value || null)">
<option :value="null">All</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="canceled">Canceled</option>
</select>
</div>
<div v-if="showUserFilter" class="filter-field">
<label for="filter-user">User</label>
<input
id="filter-user"
type="text"
:value="modelValue.user_search"
placeholder="Search user..."
@input="updateFilter('user_search', ($event.target as HTMLInputElement).value || null)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { useLocalStorage } from '@/composables/useLocalStorage'
import type { Space } from '@/types'
export interface FilterValues {
space_id: number | null
status: string | null
user_search: string | null
}
const props = withDefaults(defineProps<{
spaces: Space[]
showUserFilter?: boolean
modelValue: FilterValues
collapsible?: boolean
defaultCollapsed?: boolean
}>(), {
collapsible: true,
defaultCollapsed: true
})
const emit = defineEmits<{
'update:modelValue': [value: FilterValues]
}>()
const collapsed = useLocalStorage('sb-filters-collapsed', props.defaultCollapsed)
const activeCount = computed(() => {
let count = 0
if (props.modelValue.space_id !== null) count++
if (props.modelValue.status !== null) count++
if (props.modelValue.user_search !== null) count++
return count
})
const updateFilter = (key: keyof FilterValues, value: string | number | null) => {
const parsed = key === 'space_id' && value !== null ? Number(value) : value
emit('update:modelValue', { ...props.modelValue, [key]: parsed || null })
}
</script>
<style scoped>
.booking-filters {
display: flex;
flex-direction: column;
gap: 0;
}
.filter-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all var(--transition-fast);
align-self: flex-start;
}
.filter-toggle-btn:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--color-accent);
color: #fff;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
}
.toggle-chevron {
transition: transform var(--transition-fast);
}
.toggle-chevron.rotated {
transform: rotate(180deg);
}
/* Collapse animation */
.filters-content {
max-height: 200px;
overflow: hidden;
transition: max-height 0.25s ease, opacity 0.2s ease, margin 0.25s ease;
opacity: 1;
margin-top: 10px;
}
.filters-content.filters-collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.filter-fields {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 140px;
}
.filter-field label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
}
.filter-field select,
.filter-field input {
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text-primary);
transition: border-color var(--transition-fast);
}
.filter-field select:focus,
.filter-field input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
@media (max-width: 640px) {
.filter-fields {
flex-direction: column;
gap: 8px;
}
.filter-field {
min-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<Transition name="modal-fade">
<div v-if="show && booking" class="preview-overlay" @click.self="$emit('close')">
<div class="preview-modal" @keydown.escape="$emit('close')">
<button class="preview-close" @click="$emit('close')" title="Close">
<X :size="18" />
</button>
<div class="preview-header">
<h3>{{ booking.title }}</h3>
<span :class="['preview-badge', `preview-badge-${booking.status}`]">
{{ booking.status }}
</span>
</div>
<div class="preview-details">
<div class="detail-row">
<Building2 :size="16" class="detail-icon" />
<span>{{ booking.space?.name || 'Unknown Space' }}</span>
</div>
<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 }}
<span v-if="booking.user.organization" class="detail-muted">
&middot; {{ booking.user.organization }}
</span>
</span>
</div>
<div class="detail-row">
<CalendarDays :size="16" class="detail-icon" />
<span>{{ formatDate(booking.start_datetime) }}</span>
</div>
<div class="detail-row">
<Clock :size="16" class="detail-icon" />
<span>{{ formatTimeRange(booking.start_datetime, booking.end_datetime) }}</span>
</div>
</div>
<div v-if="booking.description" class="preview-description">
<p :class="{ truncated: !showFullDesc && isLongDesc }">
{{ booking.description }}
</p>
<button
v-if="isLongDesc"
class="show-more-btn"
@click="showFullDesc = !showFullDesc"
>
{{ showFullDesc ? 'Show less' : 'Show more' }}
</button>
</div>
<div v-if="actionButtons.length > 0" class="preview-actions">
<button
v-for="action in actionButtons"
:key="action.key"
:class="['preview-action-btn', `preview-action-${action.key}`]"
@click="$emit(action.key as any, booking)"
>
<component :is="action.icon" :size="16" />
{{ action.label }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, type Component } from 'vue'
import { X, Building2, User as UserIcon, CalendarDays, Clock, Check, XCircle, Pencil, Ban } from 'lucide-vue-next'
import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import type { Booking } from '@/types'
const props = defineProps<{
booking: Booking | null
isAdmin: boolean
show: boolean
}>()
const emit = defineEmits<{
close: []
approve: [booking: Booking]
reject: [booking: Booking]
cancel: [booking: Booking]
edit: [booking: Booking]
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const showFullDesc = ref(false)
const DESC_MAX_LENGTH = 150
const isLongDesc = computed(() =>
(props.booking?.description?.length || 0) > DESC_MAX_LENGTH
)
// Reset show more when booking changes
watch(() => props.booking?.id, () => {
showFullDesc.value = false
})
const formatDate = (datetime: string): string =>
formatDateUtil(datetime, userTimezone.value)
const formatTimeRange = (start: string, end: string): string =>
`${formatTimeUtil(start, userTimezone.value)} ${formatTimeUtil(end, userTimezone.value)}`
interface ActionButton {
key: string
label: string
icon: Component
}
const actionButtons = computed<ActionButton[]>(() => {
if (!props.booking) return []
const status = props.booking.status
const buttons: ActionButton[] = []
if (props.isAdmin && status === 'pending') {
buttons.push({ key: 'approve', label: 'Approve', icon: Check })
buttons.push({ key: 'reject', label: 'Reject', icon: XCircle })
}
if (status === 'pending' || status === 'approved') {
buttons.push({ key: 'edit', label: 'Edit', icon: Pencil })
}
if (status === 'pending' || status === 'approved') {
buttons.push({ key: 'cancel', label: 'Cancel', icon: Ban })
}
return buttons
})
// ESC key handler
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.show) {
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped>
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.preview-modal {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
position: relative;
}
.preview-close {
position: absolute;
top: 12px;
right: 12px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.preview-close:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.preview-header {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
padding-right: 28px;
}
.preview-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
flex: 1;
line-height: 1.3;
}
.preview-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 10px;
text-transform: capitalize;
white-space: nowrap;
flex-shrink: 0;
}
.preview-badge-pending {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.preview-badge-approved {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.preview-badge-rejected {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.preview-badge-canceled {
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
}
/* Details */
.preview-details {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-primary);
}
.detail-icon {
color: var(--color-text-muted);
flex-shrink: 0;
}
.detail-muted {
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);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 16px;
}
.preview-description p {
margin: 0;
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.5;
white-space: pre-wrap;
}
.preview-description p.truncated {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.show-more-btn {
margin-top: 6px;
padding: 0;
background: none;
border: none;
color: var(--color-accent);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.show-more-btn:hover {
text-decoration: underline;
}
/* Actions */
.preview-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.preview-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.preview-action-approve {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 25%, transparent);
}
.preview-action-approve:hover {
background: var(--color-success);
color: #fff;
}
.preview-action-reject {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 25%, transparent);
}
.preview-action-reject:hover {
background: var(--color-danger);
color: #fff;
}
.preview-action-edit {
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning);
border-color: color-mix(in srgb, var(--color-warning) 25%, transparent);
}
.preview-action-edit:hover {
background: var(--color-warning);
color: #fff;
}
.preview-action-cancel {
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border-color: var(--color-border);
}
.preview-action-cancel:hover {
background: var(--color-border);
color: var(--color-text-primary);
}
/* Modal transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .preview-modal,
.modal-fade-leave-active .preview-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .preview-modal,
.modal-fade-leave-to .preview-modal {
transform: scale(0.95);
opacity: 0;
}
/* Mobile */
@media (max-width: 640px) {
.preview-modal {
max-width: none;
width: calc(100% - 32px);
margin: 16px;
}
.preview-actions {
flex-direction: column;
}
.preview-action-btn {
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<div class="booking-row" :class="[`booking-row-${booking.status}`]">
<div class="row-time">
{{ formatTimeRange(booking.start_datetime, booking.end_datetime) }}
</div>
<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}`]">
{{ statusLabel }}
</span>
<ActionMenu
v-if="rowActions.length > 0"
:actions="rowActions"
@select="handleAction"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Check, XCircle, Pencil, Ban } from 'lucide-vue-next'
import { formatTime as formatTimeUtil } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import ActionMenu from '@/components/ActionMenu.vue'
import type { ActionItem } from '@/components/ActionMenu.vue'
import type { Booking } from '@/types'
const props = defineProps<{
booking: Booking
isAdmin: boolean
showUser?: boolean
}>()
const emit = defineEmits<{
approve: [booking: Booking]
reject: [booking: Booking]
cancel: [booking: Booking]
edit: [booking: Booking]
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
canceled: 'Canceled'
}
const statusLabel = computed(() => STATUS_LABELS[props.booking.status] || props.booking.status)
const rowActions = computed<ActionItem[]>(() => {
const actions: ActionItem[] = []
const status = props.booking.status
if (props.isAdmin && status === 'pending') {
actions.push({ key: 'approve', label: 'Approve', icon: Check, color: 'var(--color-success)' })
actions.push({ key: 'reject', label: 'Reject', icon: XCircle, color: 'var(--color-danger)' })
}
if (status === 'pending' || status === 'approved') {
actions.push({ key: 'edit', label: 'Edit', icon: Pencil })
}
if (status === 'pending' || status === 'approved') {
actions.push({ key: 'cancel', label: 'Cancel', icon: Ban, color: 'var(--color-danger)' })
}
return actions
})
const handleAction = (key: string) => {
switch (key) {
case 'approve': emit('approve', props.booking); break
case 'reject': emit('reject', props.booking); break
case 'edit': emit('edit', props.booking); break
case 'cancel': emit('cancel', props.booking); break
}
}
const formatTimeRange = (start: string, end: string): string => {
return `${formatTimeUtil(start, userTimezone.value)}${formatTimeUtil(end, userTimezone.value)}`
}
</script>
<style scoped>
.booking-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
min-height: 44px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
border-left: 3px solid transparent;
transition: background var(--transition-fast);
}
.booking-row:hover {
background: var(--color-bg-tertiary);
}
.booking-row-pending {
border-left-color: var(--color-warning);
}
.booking-row-approved {
border-left-color: var(--color-success);
}
.booking-row-rejected {
border-left-color: var(--color-danger);
opacity: 0.7;
}
.booking-row-canceled {
border-left-color: var(--color-text-muted);
opacity: 0.7;
}
.row-time {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
min-width: 100px;
}
.row-space {
font-size: 13px;
font-weight: 500;
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 {
font-size: 13px;
color: var(--color-text-secondary);
white-space: nowrap;
min-width: 80px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.row-title {
flex: 1;
font-size: 13px;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.row-badge-pending {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.row-badge-approved {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.row-badge-rejected {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.row-badge-canceled {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
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;
gap: 6px 10px;
padding: 10px 12px;
}
.row-time {
min-width: auto;
}
.row-space {
min-width: auto;
}
.row-user {
min-width: auto;
max-width: none;
}
.row-title {
flex-basis: 100%;
order: 10;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<nav class="breadcrumbs">
<template v-for="(item, index) in items" :key="index">
<router-link v-if="item.to && index < items.length - 1" :to="item.to" class="breadcrumb-link">
{{ item.label }}
</router-link>
<span v-else class="current">{{ item.label }}</span>
<span v-if="index < items.length - 1" class="separator">/</span>
</template>
</nav>
</template>
<script setup lang="ts">
interface BreadcrumbItem {
label: string
to?: string
}
defineProps<{
items: BreadcrumbItem[]
}>()
</script>
<style scoped>
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 14px;
color: var(--color-text-secondary);
}
.breadcrumb-link {
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.breadcrumb-link:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.separator {
color: var(--color-text-muted);
}
.current {
color: var(--color-text-primary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,606 @@
<template>
<div class="dashboard-calendar">
<div v-if="error" class="error">{{ error }}</div>
<div class="calendar-wrapper" :class="{ 'calendar-loading': loading }">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div v-if="loading && !confirmModal.show" class="loading-overlay">Loading calendar...</div>
</div>
<!-- Reschedule Confirmation Modal -->
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
<div class="modal-content">
<h3>Confirm Reschedule</h3>
<p>Reschedule this booking?</p>
<div class="time-comparison">
<div class="old-time">
<strong>Old Time:</strong><br />
{{ formatModalDateTime(confirmModal.oldStart) }} {{ formatModalDateTime(confirmModal.oldEnd) }}
</div>
<div class="arrow">&rarr;</div>
<div class="new-time">
<strong>New Time:</strong><br />
{{ formatModalDateTime(confirmModal.newStart) }} {{ formatModalDateTime(confirmModal.newEnd) }}
</div>
</div>
<div class="modal-actions">
<button @click="confirmReschedule" :disabled="modalLoading" class="btn-primary">
{{ modalLoading ? 'Saving...' : 'Confirm' }}
</button>
<button @click="cancelReschedule" :disabled="modalLoading" class="btn-secondary">
Cancel
</button>
</div>
</div>
</div>
<!-- Booking Preview Modal -->
<BookingPreviewModal
:booking="selectedBooking"
:is-admin="isAdmin"
:show="showPreview"
@close="showPreview = false"
@approve="handlePreviewApprove"
@reject="handlePreviewReject"
@cancel="handlePreviewCancel"
@edit="handlePreviewEdit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
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 } from '@fullcalendar/core'
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { useIsMobile } from '@/composables/useMediaQuery'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
import type { Booking } from '@/types'
const props = withDefaults(defineProps<{
viewMode?: 'grid' | 'list'
}>(), {
viewMode: 'grid'
})
const emit = defineEmits<{
approve: [booking: Booking]
reject: [booking: Booking]
cancel: [booking: Booking]
edit: [booking: Booking]
changed: []
}>()
const authStore = useAuthStore()
const isMobile = useIsMobile()
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[]>([])
const loading = ref(true)
const initialLoad = ref(true)
const modalLoading = ref(false)
const error = ref('')
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
// Preview modal state
const selectedBooking = ref<Booking | null>(null)
const showPreview = ref(false)
// Reschedule confirmation modal
interface ConfirmModal {
show: boolean
booking: any
oldStart: Date | null
oldEnd: Date | null
newStart: Date | null
newEnd: Date | null
revertFunc: (() => void) | null
}
const confirmModal = ref<ConfirmModal>({
show: false,
booking: null,
oldStart: null,
oldEnd: null,
newStart: null,
newEnd: null,
revertFunc: null
})
const STATUS_COLORS: Record<string, string> = {
pending: '#FFA500',
approved: '#4CAF50',
rejected: '#F44336',
canceled: '#9E9E9E'
}
const events = computed<EventInput[]>(() => {
return bookings.value.map((booking) => ({
id: String(booking.id),
title: booking.space?.name ? `${booking.space.name} - ${booking.title}` : booking.title,
start: booking.start_datetime,
end: booking.end_datetime,
backgroundColor: STATUS_COLORS[booking.status] || '#9E9E9E',
borderColor: STATUS_COLORS[booking.status] || '#9E9E9E',
extendedProps: {
status: booking.status,
description: booking.description
}
}))
})
let currentStart: Date | null = null
let currentEnd: Date | null = null
const loadBookings = async (start: Date, end: Date) => {
currentStart = start
currentEnd = end
error.value = ''
try {
const startStr = start.toISOString()
const endStr = end.toISOString()
if (isAdmin.value) {
bookings.value = await adminBookingsApi.getAll({
start: startStr,
limit: 100
})
} else {
bookings.value = await bookingsApi.getMyCalendar(startStr, endStr)
}
} catch (err) {
error.value = handleApiError(err)
} finally {
if (initialLoad.value) {
loading.value = false
initialLoad.value = false
}
}
}
const handleDatesSet = (arg: DatesSetArg) => {
loadBookings(arg.start, arg.end)
}
// Event click → open preview modal
const handleEventClick = (info: any) => {
const bookingId = parseInt(info.event.id)
const booking = bookings.value.find((b) => b.id === bookingId)
if (booking) {
selectedBooking.value = booking
showPreview.value = true
}
}
// Drag & drop handlers
const handleEventDrop = (info: EventDropArg) => {
confirmModal.value = {
show: true,
booking: info.event,
oldStart: info.oldEvent.start,
oldEnd: info.oldEvent.end,
newStart: info.event.start,
newEnd: info.event.end,
revertFunc: info.revert
}
}
const handleEventResize = (info: any) => {
confirmModal.value = {
show: true,
booking: info.event,
oldStart: info.oldEvent.start,
oldEnd: info.oldEvent.end,
newStart: info.event.start,
newEnd: info.event.end,
revertFunc: info.revert
}
}
const confirmReschedule = async () => {
if (!confirmModal.value.newStart || !confirmModal.value.newEnd) return
try {
modalLoading.value = true
await adminBookingsApi.reschedule(parseInt(confirmModal.value.booking.id), {
start_datetime: confirmModal.value.newStart.toISOString(),
end_datetime: confirmModal.value.newEnd.toISOString()
})
// Reload bookings for the full calendar view range, not just the event's old/new range
if (currentStart && currentEnd) {
await loadBookings(currentStart, currentEnd)
}
confirmModal.value.show = false
emit('changed')
} catch (err: any) {
if (confirmModal.value.revertFunc) {
confirmModal.value.revertFunc()
}
error.value = err.response?.data?.detail || 'Failed to reschedule booking'
setTimeout(() => { error.value = '' }, 5000)
confirmModal.value.show = false
} finally {
modalLoading.value = false
}
}
const cancelReschedule = () => {
if (confirmModal.value.revertFunc) {
confirmModal.value.revertFunc()
}
confirmModal.value.show = false
}
const formatModalDateTime = (date: Date | null) => {
if (!date) return ''
return formatDateTimeUtil(date.toISOString(), userTimezone.value)
}
// Preview modal action handlers
const handlePreviewApprove = (booking: Booking) => {
showPreview.value = false
emit('approve', booking)
}
const handlePreviewReject = (booking: Booking) => {
showPreview.value = false
emit('reject', booking)
}
const handlePreviewCancel = (booking: Booking) => {
showPreview.value = false
emit('cancel', booking)
}
const handlePreviewEdit = (booking: Booking) => {
showPreview.value = false
emit('edit', booking)
}
// Stable callback references (avoid new functions on every computed recompute)
const handleEventDidMount = (info: any) => {
if (info.event.extendedProps.status === 'approved' && isEditable.value) {
info.el.style.cursor = 'move'
} else {
info.el.style.cursor = 'pointer'
}
}
const handleEventAllow = (_dropInfo: any, draggedEvent: any) => {
return draggedEvent?.extendedProps?.status === 'approved'
}
// Resolve initial view from props (not reactive - only used at init)
const resolveDesktopView = (mode: 'grid' | 'list') =>
mode === 'list' ? 'listMonth' : 'dayGridMonth'
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: isMobile.value ? 'listWeek' : resolveDesktopView(props.viewMode),
headerToolbar: isMobile.value
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek' },
timeZone: userTimezone.value,
firstDay: 1,
events: events.value,
datesSet: handleDatesSet,
editable: isEditable.value,
eventStartEditable: isEditable.value,
eventDurationEditable: isEditable.value,
selectable: false,
dayMaxEvents: true,
height: 'auto',
noEventsText: 'No bookings this period',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
eventDidMount: handleEventDidMount,
eventAllow: handleEventAllow
}))
// Switch view dynamically when screen size changes
watch(isMobile, (mobile) => {
const calendarApi = calendarRef.value?.getApi()
if (calendarApi) {
calendarApi.changeView(mobile ? 'listWeek' : 'dayGridMonth')
nextTick(() => calendarApi.updateSize())
}
})
// Switch view when viewMode prop changes (desktop toggle)
watch(() => props.viewMode, (newView) => {
if (isMobile.value) return
const calendarApi = calendarRef.value?.getApi()
if (calendarApi) {
calendarApi.changeView(newView === 'list' ? 'listMonth' : 'dayGridMonth')
nextTick(() => calendarApi.updateSize())
}
})
const refresh = () => {
if (currentStart && currentEnd) {
loadBookings(currentStart, currentEnd)
} else {
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
loadBookings(startOfMonth, endOfMonth)
}
}
defineExpose({ refresh })
</script>
<style scoped>
.dashboard-calendar {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.calendar-wrapper {
position: relative;
min-height: 200px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
color: var(--color-text-secondary);
z-index: 10;
border-radius: var(--radius-md);
}
/* Reschedule Modal */
.modal-overlay {
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);
padding: 24px;
border-radius: var(--radius-md);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 16px;
color: var(--color-text-primary);
}
.modal-content p {
margin-bottom: 20px;
color: var(--color-text-secondary);
}
.time-comparison {
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0;
padding: 16px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
}
.old-time,
.new-time {
flex: 1;
font-size: 14px;
color: var(--color-text-secondary);
}
.old-time strong,
.new-time strong {
color: var(--color-text-primary);
display: block;
margin-bottom: 4px;
}
.arrow {
font-size: 24px;
color: var(--color-text-muted);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
.btn-primary {
background: var(--color-accent);
color: white;
border: none;
padding: 10px 20px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: background var(--transition-fast);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
padding: 10px 20px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: background var(--transition-fast);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* FullCalendar custom styles */
:deep(.fc) {
font-family: inherit;
}
:deep(.fc-button) {
background: var(--color-accent);
border-color: var(--color-accent);
text-transform: capitalize;
}
:deep(.fc-button:hover) {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
:deep(.fc-button-active) {
background: var(--color-accent-hover) !important;
border-color: var(--color-accent-hover) !important;
}
:deep(.fc-daygrid-day-number) {
color: var(--color-text-primary);
font-weight: 500;
}
:deep(.fc-col-header-cell-cushion) {
color: var(--color-text-primary);
font-weight: 600;
}
:deep(.fc-event) {
cursor: pointer;
}
:deep(.fc-event-title) {
font-weight: 500;
}
:deep(.fc-event.fc-draggable) {
cursor: move;
}
/* List view theming */
:deep(.fc-list) {
border-color: var(--color-border);
}
:deep(.fc-list-day-cushion) {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
:deep(.fc-list-event td) {
border-color: var(--color-border);
color: var(--color-text-primary);
}
:deep(.fc-list-event:hover td) {
background: var(--color-bg-tertiary);
}
:deep(.fc-list-empty-cushion) {
color: var(--color-text-muted);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.dashboard-calendar {
padding: 12px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1rem;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 4px 8px;
font-size: 0.75rem;
}
.time-comparison {
flex-direction: column;
gap: 8px;
}
.arrow {
transform: rotate(90deg);
}
}
</style>

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

@@ -5,7 +5,19 @@
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading && !confirmModal.show" class="loading">Loading calendar...</div>
<FullCalendar v-show="!loading" :options="calendarOptions" />
<FullCalendar ref="calendarRef" v-show="!loading" :options="calendarOptions" />
<!-- Booking Preview Modal -->
<BookingPreviewModal
:booking="selectedBooking"
:is-admin="isEditable"
:show="showPreview"
@close="showPreview = false"
@approve="handlePreviewAction('approve', $event)"
@reject="handlePreviewAction('reject', $event)"
@cancel="handlePreviewAction('cancel', $event)"
@edit="handlePreviewAction('edit', $event)"
/>
<!-- Confirmation Modal -->
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
@@ -39,30 +51,37 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
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'
import { useIsMobile } from '@/composables/useMediaQuery'
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
import type { Booking } from '@/types'
interface Props {
spaceId: number
spaceName?: string
}
const props = defineProps<Props>()
const authStore = useAuthStore()
const isMobile = useIsMobile()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])
const loading = ref(true)
const initialLoad = ref(true)
const modalLoading = ref(false)
const error = ref('')
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
interface ConfirmModal {
show: boolean
@@ -84,8 +103,25 @@ 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) => {
showPreview.value = false
emit(`${action}-booking` as any, booking)
}
// Status to color mapping
const STATUS_COLORS: Record<string, string> = {
@@ -243,13 +279,11 @@ const handleDatesSet = (arg: DatesSetArg) => {
// FullCalendar options
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: isMobile.value ? 'listWeek' : 'dayGridMonth',
headerToolbar: isMobile.value
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
timeZone: userTimezone.value,
firstDay: 1, // Start week on Monday (0=Sunday, 1=Monday)
events: events.value,
@@ -262,6 +296,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
dayMaxEvents: true,
weekends: true,
height: 'auto',
noEventsText: 'No bookings this period',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
@@ -272,6 +307,20 @@ const calendarOptions = computed<CalendarOptions>(() => ({
minute: '2-digit',
hour12: false
},
// Click handler
eventClick: (info: any) => {
const bookingId = parseInt(info.event.id)
const booking = bookings.value.find((b) => b.id === bookingId)
if (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
}
},
// Drag callback
eventDrop: handleEventDrop,
// Resize callback
@@ -284,12 +333,20 @@ 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'
}
}))
// Switch view dynamically when screen size changes
watch(isMobile, (mobile) => {
const calendarApi = calendarRef.value?.getApi()
if (calendarApi) {
calendarApi.changeView(mobile ? 'listWeek' : 'dayGridMonth')
}
})
// Public refresh method for parent components
const refresh = () => {
if (currentStart && currentEnd) {
@@ -501,4 +558,55 @@ defineExpose({ refresh })
:deep(.fc-event:not(.fc-draggable)) {
cursor: default;
}
/* List view theming */
:deep(.fc-list) {
border-color: var(--color-border);
}
:deep(.fc-list-day-cushion) {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
:deep(.fc-list-event td) {
border-color: var(--color-border);
color: var(--color-text-primary);
}
:deep(.fc-list-event:hover td) {
background: var(--color-bg-tertiary);
}
:deep(.fc-list-empty-cushion) {
color: var(--color-text-muted);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.space-calendar {
padding: 12px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1rem;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 4px 8px;
font-size: 0.75rem;
}
}
</style>

View File

@@ -0,0 +1,21 @@
import { ref, watch, type Ref } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
let initial = defaultValue
try {
const stored = localStorage.getItem(key)
if (stored !== null) {
initial = JSON.parse(stored)
}
} catch {
// Invalid JSON in storage, use default
}
const value = ref<T>(initial) as Ref<T>
watch(value, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true })
return value
}

View File

@@ -0,0 +1,28 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useMediaQuery(query: string) {
const matches = ref(false)
let mediaQuery: MediaQueryList | null = null
const update = () => {
if (mediaQuery) {
matches.value = mediaQuery.matches
}
}
onMounted(() => {
mediaQuery = window.matchMedia(query)
matches.value = mediaQuery.matches
mediaQuery.addEventListener('change', update)
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', update)
})
return matches
}
export function useIsMobile() {
return useMediaQuery('(max-width: 768px)')
}

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',
@@ -47,22 +53,44 @@ const router = createRouter({
meta: { requiresAuth: true }
},
{
path: '/my-bookings',
name: 'MyBookings',
path: '/history',
name: 'BookingHistory',
component: () => import('@/views/MyBookings.vue'),
meta: { requiresAuth: true }
},
{
path: '/my-bookings',
redirect: '/history'
},
{
path: '/profile',
name: 'UserProfile',
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',
@@ -78,9 +106,7 @@ const router = createRouter({
},
{
path: '/admin/pending',
name: 'AdminPending',
component: () => import('@/views/AdminPending.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
redirect: '/history?status=pending'
},
{
path: '/admin/audit-log',
@@ -101,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

@@ -12,6 +12,7 @@ import type {
Booking,
BookingCreate,
BookingUpdate,
BookingAdminCreate,
BookingTemplate,
BookingTemplateCreate,
Notification,
@@ -21,7 +22,13 @@ import type {
RecurringBookingResult,
SpaceUsageReport,
TopUsersReport,
ApprovalRateReport
ApprovalRateReport,
Property,
PropertySettings,
PropertyAccess,
Organization,
OrganizationMember,
AnonymousBookingCreate
} from '@/types'
const api = axios.create({
@@ -119,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
},
@@ -190,12 +197,36 @@ export const bookingsApi = {
createRecurring: async (data: RecurringBookingCreate): Promise<RecurringBookingResult> => {
const response = await api.post<RecurringBookingResult>('/bookings/recurring', data)
return response.data
},
getMyCalendar: async (start: string, end: string): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/bookings/my/calendar', {
params: { start, end }
})
return response.data
},
cancel: async (id: number): Promise<Booking> => {
const response = await api.put<Booking>(`/bookings/${id}/cancel`)
return response.data
}
}
// Admin Bookings API
export const adminBookingsApi = {
getPending: async (filters?: { space_id?: number; user_id?: number }): Promise<Booking[]> => {
getAll: async (params?: {
status?: string
space_id?: number
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; property_id?: number }): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
return response.data
},
@@ -221,6 +252,16 @@ export const adminBookingsApi = {
): Promise<Booking> => {
const response = await api.put<Booking>(`/admin/bookings/${id}/reschedule`, data)
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
}
}
@@ -347,7 +388,14 @@ export const googleCalendarApi = {
},
disconnect: async (): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>('/integrations/google/disconnect')
const response = await api.post<{ message: string }>('/integrations/google/disconnect')
return response.data
},
sync: async (): Promise<{ synced: number; created: number; updated: number; failed: number; total_bookings: number }> => {
const response = await api.post<{ synced: number; created: number; updated: number; failed: number; total_bookings: number }>(
'/integrations/google/sync'
)
return response.data
},
@@ -359,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 {
@@ -91,6 +97,15 @@ export interface BookingUpdate {
end_datetime?: string // ISO format
}
export interface BookingAdminCreate {
space_id: number
user_id?: number
start_datetime: string // ISO format
end_datetime: string // ISO format
title: string
description?: string
}
export interface Notification {
id: number
user_id: number
@@ -221,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

@@ -131,3 +131,39 @@ export const isoToLocalDateTime = (isoDateTime: string, timezone: string = 'UTC'
// Format as YYYY-MM-DDTHH:mm for datetime-local input
return `${year}-${month}-${day}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
}
/**
* Check if a booking is currently active (in progress).
*/
export const isBookingActive = (startDatetime: string, endDatetime: string): boolean => {
const now = new Date()
const start = new Date(ensureUTC(startDatetime))
const end = new Date(ensureUTC(endDatetime))
return start <= now && end >= now
}
/**
* Calculate progress percentage for an active booking.
*/
export const getBookingProgress = (startDatetime: string, endDatetime: string): number => {
const now = new Date()
const start = new Date(ensureUTC(startDatetime))
const end = new Date(ensureUTC(endDatetime))
const total = end.getTime() - start.getTime()
const elapsed = now.getTime() - start.getTime()
return Math.min(100, Math.max(0, (elapsed / total) * 100))
}
/**
* Format remaining time for an active booking.
*/
export const formatRemainingTime = (endDatetime: string): string => {
const now = new Date()
const end = new Date(ensureUTC(endDatetime))
const remaining = end.getTime() - now.getTime()
if (remaining <= 0) return 'Ended'
const hours = Math.floor(remaining / 3600000)
const minutes = Math.floor((remaining % 3600000) / 60000)
if (hours > 0) return `${hours}h ${minutes}m remaining`
return `${minutes}m remaining`
}

View File

@@ -1,61 +1,91 @@
<template>
<div class="admin">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Admin Dashboard - 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" />
Create New Space
New Space
</button>
</div>
<!-- Spaces List -->
<CollapsibleSection title="All Spaces" :icon="Building2">
<div v-if="loadingSpaces" class="loading">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty">
No spaces created yet. Create one above!
<!-- Stats Pills -->
<div class="stats-pills">
<span class="stat-pill stat-pill-primary">
<span class="stat-pill-number">{{ spaces.length }}</span>
<span class="stat-pill-label">Total Spaces</span>
</span>
<span class="stat-pill stat-pill-success">
<span class="stat-pill-number">{{ activeCount }}</span>
<span class="stat-pill-label">Active</span>
</span>
<span class="stat-pill stat-pill-danger">
<span class="stat-pill-number">{{ inactiveCount }}</span>
<span class="stat-pill-label">Inactive</span>
</span>
</div>
<div v-else class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Capacity</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="space in spaces" :key="space.id">
<td>{{ space.name }}</td>
<td>{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</td>
<td>{{ space.capacity }}</td>
<td>
<!-- Loading State -->
<div v-if="loadingSpaces" class="loading-state">
<div class="spinner"></div>
<p>Loading spaces...</p>
</div>
<!-- Empty State -->
<div v-else-if="spaces.length === 0" class="empty-state">
<Building2 :size="48" class="empty-icon" />
<p>No spaces created yet</p>
<button class="btn btn-primary" @click="openCreateModal">Create your first space</button>
</div>
<!-- Space Cards Grid -->
<div v-else class="space-cards">
<div v-for="space in spaces" :key="space.id" class="space-card">
<div class="space-card-header">
<div class="space-card-title">
<h3>{{ space.name }}</h3>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
{{ space.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions">
</div>
<div class="space-card-actions">
<button
class="btn btn-sm btn-secondary"
class="icon-btn"
title="Edit space"
@click="startEdit(space)"
:disabled="loading"
>
Edit
<Pencil :size="16" />
</button>
<button
:class="['btn', 'btn-sm', space.is_active ? 'btn-warning' : 'btn-success']"
:class="['icon-btn', space.is_active ? 'icon-btn-warning' : 'icon-btn-success']"
:title="space.is_active ? 'Deactivate' : 'Activate'"
@click="toggleStatus(space)"
:disabled="loading"
>
{{ space.is_active ? 'Deactivate' : 'Activate' }}
<Power :size="16" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleSection>
</div>
<div class="space-card-meta">
<span class="meta-badge meta-type">{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</span>
<span class="meta-item">
<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>
</div>
<!-- Create/Edit Space Modal -->
<div v-if="showModal" class="modal" @click.self="closeModal">
@@ -73,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>
@@ -179,14 +218,27 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { spacesApi, handleApiError } from '@/services/api'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Building2, Plus } from 'lucide-vue-next'
import type { Space } from '@/types'
import { ref, computed, onMounted } from 'vue'
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, Landmark } from 'lucide-vue-next'
import type { Space, Property } from '@/types'
const authStore = useAuthStore()
const propertyStore = usePropertyStore()
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin' }
]
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)
const loading = ref(false)
const error = ref('')
const success = ref('')
@@ -198,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 = ''
@@ -246,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
}
@@ -256,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,
@@ -279,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
}
}
@@ -305,6 +376,7 @@ const toggleStatus = async (space: Space) => {
onMounted(() => {
loadSpaces()
loadProperties()
})
</script>
@@ -320,9 +392,291 @@ onMounted(() => {
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
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;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.stat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
height: 40px;
box-sizing: border-box;
}
.stat-pill-number {
font-weight: 700;
font-size: 15px;
}
.stat-pill-primary {
background: var(--color-accent-light);
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.stat-pill-success {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 20%, transparent);
}
.stat-pill-danger {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 20%, transparent);
}
/* Loading & Empty States */
.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);
}
.empty-state p {
color: var(--color-text-secondary);
font-size: 15px;
}
/* Space Cards Grid */
.space-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.space-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
transition: all var(--transition-fast);
}
.space-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-accent);
}
.space-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.space-card-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.space-card-title h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.space-card-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
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);
}
.icon-btn:hover {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-light);
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn-warning:hover {
color: var(--color-warning);
border-color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.icon-btn-success:hover {
color: var(--color-success);
border-color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 10%, transparent);
}
.space-card-meta {
display: flex;
align-items: center;
gap: 10px;
}
.meta-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-type {
background: var(--color-accent-light);
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;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary);
}
.space-card-desc {
margin: 10px 0 0;
font-size: 13px;
color: var(--color-text-muted);
line-height: 1.4;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.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);
}
/* 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);
}
/* Form Styles */
.space-form {
display: flex;
flex-direction: column;
@@ -355,12 +709,6 @@ onMounted(() => {
gap: 16px;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
.form-group {
display: flex;
flex-direction: column;
@@ -398,65 +746,6 @@ onMounted(() => {
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-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-warning {
background: var(--color-warning);
color: white;
}
.btn-warning:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-warning) 85%, black);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
@@ -473,69 +762,7 @@ onMounted(() => {
margin-top: 12px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.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);
}
.actions {
display: flex;
gap: 8px;
}
/* Modal */
.modal {
position: fixed;
top: 0;
@@ -551,8 +778,8 @@ onMounted(() => {
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
border-radius: var(--radius-lg);
padding: 28px;
max-width: 600px;
width: 90%;
max-height: 90vh;
@@ -566,14 +793,30 @@ onMounted(() => {
color: var(--color-text-primary);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-direction: column;
.space-cards {
grid-template-columns: 1fr;
}
.stats-pills {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.stat-pill {
justify-content: center;
}
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,524 +0,0 @@
<template>
<div class="admin-pending">
<h2>Admin Dashboard - Pending Booking Requests</h2>
<!-- Filters Card -->
<CollapsibleSection title="Filters" :icon="Filter">
<div class="filters">
<div class="form-group">
<label for="filter-space">Filter by Space</label>
<select id="filter-space" v-model="filterSpaceId" @change="loadPendingBookings">
<option value="">All Spaces</option>
<option v-for="space in spaces" :key="space.id" :value="space.id">
{{ space.name }}
</option>
</select>
</div>
</div>
</CollapsibleSection>
<!-- Loading State -->
<div v-if="loading" class="card">
<div class="loading">Loading pending requests...</div>
</div>
<!-- Empty State -->
<div v-else-if="bookings.length === 0" class="card">
<div class="empty">
No pending requests found.
{{ filterSpaceId ? 'Try different filters.' : 'All bookings have been processed.' }}
</div>
</div>
<!-- Bookings Table -->
<CollapsibleSection v-else :title="`Pending Requests (${bookings.length})`" :icon="ClipboardCheck">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>User</th>
<th>Space</th>
<th>Date</th>
<th>Time</th>
<th>Title</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="booking in bookings" :key="booking.id">
<td>
<div class="user-info">
<div class="user-name">{{ booking.user?.full_name || 'Unknown' }}</div>
<div class="user-email">{{ booking.user?.email || '-' }}</div>
<div class="user-org" v-if="booking.user?.organization">
{{ booking.user.organization }}
</div>
</div>
</td>
<td>
<div class="space-info">
<div class="space-name">{{ booking.space?.name || 'Unknown Space' }}</div>
<div class="space-type">{{ formatType(booking.space?.type || '') }}</div>
</div>
</td>
<td>{{ formatDate(booking.start_datetime) }}</td>
<td>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</td>
<td>{{ booking.title }}</td>
<td>
<div class="description" :title="booking.description || '-'">
{{ truncateText(booking.description || '-', 40) }}
</div>
</td>
<td class="actions">
<button
class="btn btn-sm btn-success"
@click="handleApprove(booking)"
:disabled="processing === booking.id"
>
{{ processing === booking.id ? 'Processing...' : 'Approve' }}
</button>
<button
class="btn btn-sm btn-danger"
@click="showRejectModal(booking)"
:disabled="processing === booking.id"
>
Reject
</button>
</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleSection>
<!-- Reject Modal -->
<div v-if="rejectingBooking" class="modal" @click.self="closeRejectModal">
<div class="modal-content">
<h3>Reject Booking Request</h3>
<div class="booking-summary">
<p><strong>User:</strong> {{ rejectingBooking.user?.full_name }}</p>
<p><strong>Space:</strong> {{ rejectingBooking.space?.name }}</p>
<p><strong>Title:</strong> {{ rejectingBooking.title }}</p>
<p>
<strong>Date:</strong> {{ formatDate(rejectingBooking.start_datetime) }} -
{{ formatTime(rejectingBooking.start_datetime, rejectingBooking.end_datetime) }}
</p>
</div>
<form @submit.prevent="handleReject">
<div class="form-group">
<label for="reject_reason">Rejection Reason (optional)</label>
<textarea
id="reject_reason"
v-model="rejectReason"
rows="4"
placeholder="Provide a reason for rejection..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-danger" :disabled="processing !== null">
{{ processing !== null ? 'Rejecting...' : 'Confirm Rejection' }}
</button>
<button type="button" class="btn btn-secondary" @click="closeRejectModal">
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="card">
<div class="error">{{ error }}</div>
</div>
<!-- Success Message -->
<div v-if="success" class="card">
<div class="success-msg">{{ success }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Filter, ClipboardCheck } from 'lucide-vue-next'
import type { Booking, Space } from '@/types'
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])
const spaces = ref<Space[]>([])
const loading = ref(false)
const error = ref('')
const success = ref('')
const processing = ref<number | null>(null)
const filterSpaceId = ref<string>('')
const rejectingBooking = ref<Booking | null>(null)
const rejectReason = ref('')
const loadPendingBookings = async () => {
loading.value = true
error.value = ''
try {
const filters: { space_id?: number } = {}
if (filterSpaceId.value) {
filters.space_id = Number(filterSpaceId.value)
}
bookings.value = await adminBookingsApi.getPending(filters)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const loadSpaces = async () => {
try {
spaces.value = await spacesApi.list()
} catch (err) {
console.error('Failed to load spaces:', err)
}
}
const formatDate = (datetime: string): string => {
return formatDateUtil(datetime, userTimezone.value)
}
const formatTime = (start: string, end: string): string => {
const startTime = formatTimeUtil(start, userTimezone.value)
const endTime = formatTimeUtil(end, userTimezone.value)
return `${startTime} - ${endTime}`
}
const formatType = (type: string): string => {
const typeMap: Record<string, string> = {
sala: 'Sala',
birou: 'Birou'
}
return typeMap[type] || type
}
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
const handleApprove = async (booking: Booking) => {
if (!confirm('Are you sure you want to approve this booking?')) {
return
}
processing.value = booking.id
error.value = ''
success.value = ''
try {
await adminBookingsApi.approve(booking.id)
success.value = `Booking "${booking.title}" approved successfully!`
// Remove from list
bookings.value = bookings.value.filter((b) => b.id !== booking.id)
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
processing.value = null
}
}
const showRejectModal = (booking: Booking) => {
rejectingBooking.value = booking
rejectReason.value = ''
}
const closeRejectModal = () => {
rejectingBooking.value = null
rejectReason.value = ''
}
const handleReject = async () => {
if (!rejectingBooking.value) return
processing.value = rejectingBooking.value.id
error.value = ''
success.value = ''
try {
await adminBookingsApi.reject(
rejectingBooking.value.id,
rejectReason.value || undefined
)
success.value = `Booking "${rejectingBooking.value.title}" rejected successfully!`
// Remove from list
bookings.value = bookings.value.filter((b) => b.id !== rejectingBooking.value!.id)
closeRejectModal()
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
processing.value = null
}
}
onMounted(() => {
loadSpaces()
loadPendingBookings()
})
</script>
<style scoped>
h2 {
margin-bottom: 24px;
color: var(--color-text-primary);
}
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
margin-top: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
.collapsible-section + .collapsible-section,
.card + .collapsible-section,
.collapsible-section + .card {
margin-top: 16px;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.form-group select,
.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);
}
.form-group select: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;
margin-top: 16px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
}
.success-msg {
padding: 12px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
border-radius: var(--radius-sm);
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.user-info,
.space-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name,
.space-name {
font-weight: 500;
color: var(--color-text-primary);
}
.user-email,
.user-org,
.space-type {
font-size: 12px;
color: var(--color-text-secondary);
}
.description {
max-width: 200px;
word-wrap: break-word;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
white-space: nowrap;
}
.btn {
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-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger) 85%, black);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.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-md);
padding: 24px;
max-width: 600px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 16px;
color: var(--color-text-primary);
}
.booking-summary {
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 16px;
}
.booking-summary p {
margin: 8px 0;
font-size: 14px;
color: var(--color-text-secondary);
}
.booking-summary strong {
color: var(--color-text-primary);
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="admin-reports">
<Breadcrumb :items="breadcrumbItems" />
<h2>Booking Reports</h2>
<!-- Date Range Filter -->
@@ -150,10 +151,17 @@
import { ref, onMounted, watch, nextTick } from 'vue'
import { reportsApi } from '@/services/api'
import Chart from 'chart.js/auto'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { CalendarDays } from 'lucide-vue-next'
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Reports' }
]
const activeTab = ref('usage')
const startDate = ref('')
const endDate = ref('')

View File

@@ -1,5 +1,6 @@
<template>
<div class="audit-log">
<Breadcrumb :items="breadcrumbItems" />
<h2>Jurnal Acțiuni Administrative</h2>
<!-- Filters -->
@@ -77,10 +78,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { auditLogApi } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import type { AuditLog } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Audit Log' }
]
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const logs = ref<AuditLog[]>([])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -1,5 +1,6 @@
<template>
<div class="settings">
<Breadcrumb :items="breadcrumbItems" />
<h2>Global Booking Settings</h2>
<!-- Settings Form -->
@@ -124,10 +125,17 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { settingsApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Sliders, Info } from 'lucide-vue-next'
import type { Settings } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Settings' }
]
const loadingSettings = ref(true)
const loading = ref(false)
const error = ref('')

View File

@@ -1,13 +1,7 @@
<template>
<div class="space-detail">
<!-- Breadcrumbs -->
<nav class="breadcrumbs">
<router-link to="/">Home</router-link>
<span class="separator">/</span>
<router-link to="/spaces">Spaces</router-link>
<span class="separator">/</span>
<span class="current">{{ space?.name || 'Loading...' }}</span>
</nav>
<Breadcrumb :items="breadcrumbItems" />
<!-- Loading State -->
<div v-if="loading" class="loading">
@@ -41,14 +35,25 @@
</span>
</div>
</div>
<div class="header-actions">
<button
class="btn btn-primary btn-reserve"
:disabled="!space.is_active"
@click="handleReserve"
>
<Plus :size="18" />
{{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }}
{{ showBookingForm ? 'Cancel' : 'Reserve Space' }}
</button>
<button
v-if="isAdmin"
class="btn btn-secondary btn-reserve"
:disabled="!space.is_active"
@click="showAdminBookingForm = true"
>
<UserPlus :size="18" />
Book for User
</button>
</div>
</div>
<!-- Description -->
@@ -61,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>
@@ -76,26 +156,179 @@
/>
</div>
</div>
<!-- Admin Booking Modal -->
<div v-if="showAdminBookingForm && space" class="modal" @click.self="showAdminBookingForm = false">
<div class="modal-content">
<h3>Admin: Book for User</h3>
<AdminBookingForm
:space-id="space.id"
@submit="handleAdminBookingSubmit"
@cancel="showAdminBookingForm = false"
/>
</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, onMounted } from 'vue'
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 { Users, Plus } from 'lucide-vue-next'
import type { Space } from '@/types'
import AdminBookingForm from '@/components/AdminBookingForm.vue'
import { useAuthStore } from '@/stores/auth'
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(() => ['admin', 'superadmin', 'manager'].includes(authStore.user?.role || ''))
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const breadcrumbItems = computed(() => [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Spaces', to: '/spaces' },
{ label: space.value?.name || 'Loading...' }
])
const space = ref<Space | null>(null)
const loading = ref(true)
const error = ref('')
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> = {
@@ -126,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)
@@ -134,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
@@ -147,7 +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
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(() => {
@@ -156,36 +547,6 @@ onMounted(() => {
</script>
<style scoped>
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 14px;
color: var(--color-text-secondary);
}
.breadcrumbs a {
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.breadcrumbs a:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.breadcrumbs .separator {
color: var(--color-text-muted);
}
.breadcrumbs .current {
color: var(--color-text-primary);
font-weight: 500;
}
/* Loading State */
.loading {
display: flex;
@@ -335,8 +696,14 @@ onMounted(() => {
box-shadow: var(--shadow-md);
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn-reserve {
min-width: 180px;
min-width: 160px;
justify-content: center;
}
@@ -369,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;
@@ -413,31 +1038,6 @@ onMounted(() => {
font-size: 24px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
width: 100%;
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1.2em;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 6px 10px;
font-size: 0.85em;
}
:deep(.fc .fc-col-header-cell) {
font-size: 0.75em;
padding: 4px 2px;
}
/* Calendar mobile styles handled by SpaceCalendar component */
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="spaces">
<Breadcrumb :items="breadcrumbItems" />
<div class="spaces-header">
<div>
<h2>Available Spaces</h2>
@@ -57,10 +58,13 @@
>
<div class="space-card-header">
<h3>{{ space.name }}</h3>
<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">
<div class="space-info">
@@ -84,6 +88,24 @@
</div>
</div>
<!-- Upcoming Bookings Preview -->
<div class="bookings-preview">
<div v-if="getUpcomingBookings(space.id).length > 0" class="bookings-preview-list">
<div
v-for="booking in getUpcomingBookings(space.id)"
:key="booking.id"
class="booking-preview-item"
>
<Clock :size="14" class="booking-preview-icon" />
<div class="booking-preview-info">
<span class="booking-preview-title">{{ booking.title }}</span>
<span class="booking-preview-time">{{ formatBookingDate(booking.start_datetime) }} {{ formatBookingTime(booking.start_datetime, booking.end_datetime) }}</span>
</div>
</div>
</div>
<div v-else class="bookings-preview-empty">No upcoming bookings</div>
</div>
<div class="space-card-footer">
<button class="btn btn-secondary">
View Details
@@ -98,17 +120,28 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api'
import { Building2, Tag, Users, ChevronRight, MapPin } from 'lucide-vue-next'
import type { Space } from '@/types'
import { spacesApi, bookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDate as formatDateTZ, formatTime as formatTimeTZ } from '@/utils/datetime'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Building2, Tag, Users, ChevronRight, MapPin, Clock } from 'lucide-vue-next'
import type { Space, Booking } from '@/types'
const router = useRouter()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Spaces' }
]
const spaces = ref<Space[]>([])
const loading = ref(true)
const error = ref('')
const selectedType = ref('')
const selectedStatus = ref('')
const spaceBookings = ref<Map<number, Booking[]>>(new Map())
// Format space type for display
const formatType = (type: string): string => {
@@ -143,6 +176,44 @@ const filteredSpaces = computed(() => {
})
})
// Get upcoming bookings for a space (max 3)
const getUpcomingBookings = (spaceId: number): Booking[] => {
return spaceBookings.value.get(spaceId) || []
}
const formatBookingDate = (datetime: string): string => {
return formatDateTZ(datetime, userTimezone.value)
}
const formatBookingTime = (start: string, end: string): string => {
return `${formatTimeTZ(start, userTimezone.value)} - ${formatTimeTZ(end, userTimezone.value)}`
}
// Load bookings preview for all spaces
const loadSpaceBookings = async (spaceList: Space[]) => {
const now = new Date()
const future = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000) // 2 weeks ahead
const results = await Promise.allSettled(
spaceList.map(async (space) => {
const bookings = await bookingsApi.getForSpace(space.id, now.toISOString(), future.toISOString())
const upcoming = bookings
.filter(b => b.status === 'approved')
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime())
.slice(0, 3)
return { spaceId: space.id, bookings: upcoming }
})
)
const newMap = new Map<number, Booking[]>()
for (const result of results) {
if (result.status === 'fulfilled') {
newMap.set(result.value.spaceId, result.value.bookings)
}
}
spaceBookings.value = newMap
}
// Load spaces from API
const loadSpaces = async () => {
loading.value = true
@@ -151,6 +222,8 @@ const loadSpaces = async () => {
try {
const data = await spacesApi.list()
spaces.value = data
// Load bookings preview in background (non-blocking)
loadSpaceBookings(data)
} catch (err) {
error.value = handleApiError(err)
} finally {
@@ -361,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;
@@ -402,6 +487,58 @@ onMounted(() => {
margin-top: 12px;
}
/* Bookings Preview */
.bookings-preview {
border-top: 1px solid var(--color-border-light, var(--color-border));
padding-top: 12px;
margin-bottom: 16px;
}
.bookings-preview-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.booking-preview-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.booking-preview-icon {
color: var(--color-accent);
flex-shrink: 0;
margin-top: 2px;
}
.booking-preview-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.booking-preview-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.booking-preview-time {
font-size: 11px;
color: var(--color-text-muted);
}
.bookings-preview-empty {
font-size: 12px;
color: var(--color-text-muted);
font-style: italic;
}
.space-card-footer {
display: flex;
justify-content: flex-end;

View File

@@ -1,5 +1,6 @@
<template>
<div class="user-profile">
<Breadcrumb :items="breadcrumbItems" />
<h2>User Profile</h2>
<!-- Profile Information Card -->
@@ -74,9 +75,18 @@
Your approved bookings will automatically sync to your Google Calendar.
</p>
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
{{ disconnecting ? 'Disconnecting...' : 'Disconnect Google Calendar' }}
<div class="button-group">
<button @click="syncGoogle" class="btn btn-primary" :disabled="syncing">
{{ syncing ? 'Syncing...' : 'Sync Now' }}
</button>
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
{{ disconnecting ? 'Disconnecting...' : 'Disconnect' }}
</button>
</div>
<div v-if="syncResult" class="sync-result">
Synced {{ syncResult.synced }} bookings ({{ syncResult.created }} created, {{ syncResult.updated }} updated<span v-if="syncResult.failed">, {{ syncResult.failed }} failed</span>)
</div>
</div>
<div v-else class="google-disconnected">
@@ -125,10 +135,16 @@ import { ref, computed, onMounted } from 'vue'
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { User as UserIcon, Globe, CalendarDays, CheckCircle, Info } from 'lucide-vue-next'
import type { User } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Profile' }
]
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
@@ -136,6 +152,8 @@ const user = ref<User | null>(null)
const loadingGoogleStatus = ref(true)
const connecting = ref(false)
const disconnecting = ref(false)
const syncing = ref(false)
const syncResult = ref<{ synced: number; created: number; updated: number; failed: number } | null>(null)
const error = ref('')
const success = ref('')
@@ -296,6 +314,27 @@ const disconnectGoogle = async () => {
}
}
const syncGoogle = async () => {
error.value = ''
success.value = ''
syncResult.value = null
syncing.value = true
try {
const result = await googleCalendarApi.sync()
syncResult.value = result
success.value = 'Calendar synced successfully!'
setTimeout(() => {
success.value = ''
syncResult.value = null
}, 5000)
} catch (err) {
error.value = handleApiError(err)
} finally {
syncing.value = false
}
}
const formatDate = (dateString: string): string => {
return formatDateTimeUtil(dateString, userTimezone.value)
}
@@ -420,6 +459,20 @@ h2 {
cursor: not-allowed;
}
.button-group {
display: flex;
gap: 0.75rem;
align-items: center;
}
.sync-result {
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-info) 10%, transparent);
border-radius: var(--radius-sm);
color: var(--color-info);
font-size: 0.9rem;
}
.error {
padding: 0.75rem;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);

View File

@@ -1,7 +1,8 @@
<template>
<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
@@ -15,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>
@@ -56,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>
@@ -139,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>
@@ -201,10 +204,17 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usersApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
import type { User } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Users' }
]
const users = ref<User[]>([])
const loadingUsers = ref(false)
const loading = ref(false)
@@ -260,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!'
}
@@ -381,6 +392,8 @@ onMounted(() => {
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
@@ -585,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);
@@ -611,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);
}