Compare commits
5 Commits
72f46b1062
...
e21cf03a16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21cf03a16 | ||
|
|
d637513d92 | ||
|
|
d245c72757 | ||
|
|
a4d3f862d2 | ||
|
|
28685d8254 |
@@ -17,3 +17,11 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM_ADDRESS=noreply@space-booking.local
|
SMTP_FROM_ADDRESS=noreply@space-booking.local
|
||||||
SMTP_ENABLED=false
|
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
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from typing import Annotated, Optional
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session, joinedload
|
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.audit_log import AuditLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.audit_log import AuditLogRead
|
from app.schemas.audit_log import AuditLogRead
|
||||||
@@ -21,15 +22,22 @@ def get_audit_logs(
|
|||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin),
|
current_admin: User = Depends(get_current_manager_or_superadmin),
|
||||||
) -> list[AuditLogRead]:
|
) -> list[AuditLogRead]:
|
||||||
"""
|
"""
|
||||||
Get audit logs with filtering and pagination.
|
Get audit logs with filtering and pagination.
|
||||||
|
|
||||||
Admin only endpoint to view audit trail of administrative actions.
|
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))
|
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
|
# Apply filters
|
||||||
if action:
|
if action:
|
||||||
query = query.filter(AuditLog.action == action)
|
query = query.filter(AuditLog.action == action)
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
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.booking import Booking
|
||||||
|
from app.models.property_manager import PropertyManager
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
from app.models.space import Space
|
from app.models.space import Space
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
from app.services.email_service import send_booking_notification
|
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.services.notification_service import create_notification
|
||||||
from app.schemas.booking import (
|
from app.schemas.booking import (
|
||||||
AdminCancelRequest,
|
AdminCancelRequest,
|
||||||
@@ -35,6 +41,40 @@ from app.services.booking_service import validate_booking_rules
|
|||||||
from app.utils.timezone import convert_to_utc
|
from app.utils.timezone import convert_to_utc
|
||||||
|
|
||||||
router = APIRouter(prefix="/spaces", tags=["bookings"])
|
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"])
|
bookings_router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||||
|
|
||||||
|
|
||||||
@@ -64,9 +104,14 @@ def get_space_bookings(
|
|||||||
detail="Space not found",
|
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(
|
query = db.query(Booking).filter(
|
||||||
Booking.space_id == space_id,
|
Booking.space_id == space_id,
|
||||||
|
Booking.status.in_(["approved", "pending"]),
|
||||||
Booking.start_datetime < end,
|
Booking.start_datetime < end,
|
||||||
Booking.end_datetime > start,
|
Booking.end_datetime > start,
|
||||||
)
|
)
|
||||||
@@ -74,7 +119,7 @@ def get_space_bookings(
|
|||||||
bookings = query.order_by(Booking.start_datetime).all()
|
bookings = query.order_by(Booking.start_datetime).all()
|
||||||
|
|
||||||
# Return different schemas based on user role
|
# 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]
|
return [BookingCalendarAdmin.model_validate(b) for b in bookings]
|
||||||
else:
|
else:
|
||||||
return [BookingCalendarPublic.model_validate(b) for b in bookings]
|
return [BookingCalendarPublic.model_validate(b) for b in bookings]
|
||||||
@@ -111,6 +156,10 @@ def check_availability(
|
|||||||
detail="Space not found",
|
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)
|
# Find conflicting bookings (approved + pending)
|
||||||
conflicts = (
|
conflicts = (
|
||||||
db.query(Booking)
|
db.query(Booking)
|
||||||
@@ -202,6 +251,37 @@ def get_my_bookings(
|
|||||||
return [BookingWithSpace.model_validate(b) for b in 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)
|
@bookings_router.post("", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_booking(
|
def create_booking(
|
||||||
booking_data: BookingCreate,
|
booking_data: BookingCreate,
|
||||||
@@ -235,6 +315,10 @@ def create_booking(
|
|||||||
detail="Space not found",
|
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
|
# Convert input times from user timezone to UTC
|
||||||
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
|
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
|
||||||
start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone)
|
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
|
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)
|
# Create booking (with UTC times)
|
||||||
booking = Booking(
|
booking = Booking(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -265,7 +352,8 @@ def create_booking(
|
|||||||
end_datetime=end_datetime_utc,
|
end_datetime=end_datetime_utc,
|
||||||
title=booking_data.title,
|
title=booking_data.title,
|
||||||
description=booking_data.description,
|
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(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -273,26 +361,43 @@ def create_booking(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
# Notify all admins about the new booking request
|
if not is_admin:
|
||||||
admins = db.query(User).filter(User.role == "admin").all()
|
# Notify admins and property managers
|
||||||
for admin in admins:
|
notify_users = {}
|
||||||
create_notification(
|
|
||||||
db=db,
|
# Get superadmins/admins
|
||||||
user_id=admin.id, # type: ignore[arg-type]
|
admins = db.query(User).filter(User.role.in_(["admin", "superadmin"])).all()
|
||||||
type="booking_created",
|
for admin in admins:
|
||||||
title="Noua Cerere de Rezervare",
|
notify_users[admin.id] = admin
|
||||||
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,
|
# Get property managers for the space's property
|
||||||
)
|
if space.property_id:
|
||||||
# Send email notification to admin
|
manager_ids = [
|
||||||
background_tasks.add_task(
|
pm.user_id
|
||||||
send_booking_notification,
|
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||||
booking,
|
]
|
||||||
"created",
|
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||||
admin.email,
|
for mgr in managers:
|
||||||
current_user.full_name,
|
notify_users[mgr.id] = mgr
|
||||||
None,
|
|
||||||
)
|
for user in notify_users.values():
|
||||||
|
create_notification(
|
||||||
|
db=db,
|
||||||
|
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
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_booking_notification,
|
||||||
|
booking,
|
||||||
|
"created",
|
||||||
|
user.email,
|
||||||
|
current_user.full_name,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
# Return with timezone conversion
|
# Return with timezone conversion
|
||||||
return BookingResponse.from_booking_with_timezone(booking, user_timezone)
|
return BookingResponse.from_booking_with_timezone(booking, user_timezone)
|
||||||
@@ -334,6 +439,10 @@ def create_recurring_booking(
|
|||||||
detail="Space not found",
|
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
|
# Parse time
|
||||||
try:
|
try:
|
||||||
hour, minute = map(int, data.start_time.split(':'))
|
hour, minute = map(int, data.start_time.split(':'))
|
||||||
@@ -417,14 +526,25 @@ def create_recurring_booking(
|
|||||||
for booking in created_bookings:
|
for booking in created_bookings:
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
# Send notifications to admins (in background)
|
# Send notifications to admins and property managers (in background)
|
||||||
if created_bookings:
|
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:
|
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(
|
background_tasks.add_task(
|
||||||
create_notification,
|
create_notification,
|
||||||
db=db,
|
db=db,
|
||||||
user_id=admin.id, # type: ignore[arg-type]
|
user_id=user.id, # type: ignore[arg-type]
|
||||||
type="booking_created",
|
type="booking_created",
|
||||||
title="Noi Cereri de Rezervare Recurente",
|
title="Noi Cereri de Rezervare Recurente",
|
||||||
message=f"Utilizatorul {current_user.full_name} a creat {len(created_bookings)} rezervări 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 = 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])
|
@admin_router.get("/pending", response_model=list[BookingPendingDetail])
|
||||||
def get_pending_bookings(
|
def get_pending_bookings(
|
||||||
space_id: Annotated[int | None, Query()] = None,
|
space_id: Annotated[int | None, Query()] = None,
|
||||||
user_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]
|
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]:
|
) -> 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).
|
Returns pending bookings with user and space details, sorted by creation time (FIFO).
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
- **space_id** (optional): Filter by space ID
|
- **space_id** (optional): Filter by space ID
|
||||||
- **user_id** (optional): Filter by user 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 = (
|
query = (
|
||||||
db.query(Booking)
|
db.query(Booking)
|
||||||
.join(Space, Booking.space_id == Space.id)
|
.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")
|
.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
|
# Apply filters if provided
|
||||||
if space_id is not None:
|
if space_id is not None:
|
||||||
query = query.filter(Booking.space_id == space_id)
|
query = query.filter(Booking.space_id == space_id)
|
||||||
@@ -644,7 +833,7 @@ def approve_booking(
|
|||||||
id: int,
|
id: int,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> Booking:
|
||||||
"""
|
"""
|
||||||
Approve a pending booking request (admin only).
|
Approve a pending booking request (admin only).
|
||||||
@@ -664,6 +853,9 @@ def approve_booking(
|
|||||||
detail="Booking not found",
|
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
|
# Check if booking is pending
|
||||||
if booking.status != "pending":
|
if booking.status != "pending":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -673,11 +865,12 @@ def approve_booking(
|
|||||||
|
|
||||||
# Re-validate booking rules to prevent race conditions
|
# Re-validate booking rules to prevent race conditions
|
||||||
# Use booking owner's timezone for validation
|
# 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(
|
errors = validate_booking_rules(
|
||||||
db=db,
|
db=db,
|
||||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
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]
|
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
||||||
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
end_datetime=booking.end_datetime, # type: ignore[arg-type]
|
||||||
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
|
exclude_booking_id=int(booking.id), # type: ignore[arg-type]
|
||||||
@@ -699,13 +892,14 @@ def approve_booking(
|
|||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|
||||||
# Create Google Calendar event if user has connected their calendar
|
# Create Google Calendar event if user has connected their calendar
|
||||||
google_event_id = create_calendar_event(
|
if booking.user_id:
|
||||||
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
|
google_event_id = create_calendar_event(
|
||||||
)
|
db=db, booking=booking, user_id=int(booking.user_id) # type: ignore[arg-type]
|
||||||
if google_event_id:
|
)
|
||||||
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
|
if google_event_id:
|
||||||
db.commit()
|
booking.google_calendar_event_id = google_event_id # type: ignore[assignment]
|
||||||
db.refresh(booking)
|
db.commit()
|
||||||
|
db.refresh(booking)
|
||||||
|
|
||||||
# Log the action
|
# Log the action
|
||||||
log_action(
|
log_action(
|
||||||
@@ -718,24 +912,35 @@ def approve_booking(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify the user about approval
|
# Notify the user about approval
|
||||||
create_notification(
|
if booking.user_id and booking.user:
|
||||||
db=db,
|
create_notification(
|
||||||
user_id=booking.user_id, # type: ignore[arg-type]
|
db=db,
|
||||||
type="booking_approved",
|
user_id=booking.user_id, # type: ignore[arg-type]
|
||||||
title="Rezervare Aprobată",
|
type="booking_approved",
|
||||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
|
title="Rezervare Aprobată",
|
||||||
booking_id=booking.id,
|
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost aprobată", # type: ignore[union-attr]
|
||||||
)
|
booking_id=booking.id,
|
||||||
|
)
|
||||||
|
|
||||||
# Send email notification to user
|
# Send email notification to user
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
send_booking_notification,
|
send_booking_notification,
|
||||||
booking,
|
booking,
|
||||||
"approved",
|
"approved",
|
||||||
booking.user.email,
|
booking.user.email,
|
||||||
booking.user.full_name,
|
booking.user.full_name,
|
||||||
None,
|
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
|
return booking
|
||||||
|
|
||||||
@@ -746,7 +951,7 @@ def reject_booking(
|
|||||||
reject_data: RejectRequest,
|
reject_data: RejectRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> Booking:
|
||||||
"""
|
"""
|
||||||
Reject a pending booking request (admin only).
|
Reject a pending booking request (admin only).
|
||||||
@@ -766,6 +971,9 @@ def reject_booking(
|
|||||||
detail="Booking not found",
|
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
|
# Check if booking is pending
|
||||||
if booking.status != "pending":
|
if booking.status != "pending":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -791,24 +999,34 @@ def reject_booking(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify the user about rejection
|
# Notify the user about rejection
|
||||||
create_notification(
|
if booking.user_id and booking.user:
|
||||||
db=db,
|
create_notification(
|
||||||
user_id=booking.user_id, # type: ignore[arg-type]
|
db=db,
|
||||||
type="booking_rejected",
|
user_id=booking.user_id, # type: ignore[arg-type]
|
||||||
title="Rezervare Respinsă",
|
type="booking_rejected",
|
||||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
title="Rezervare Respinsă",
|
||||||
booking_id=booking.id,
|
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost respinsă. Motiv: {reject_data.reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||||
)
|
booking_id=booking.id,
|
||||||
|
)
|
||||||
|
|
||||||
# Send email notification to user
|
# Send email notification to user
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
send_booking_notification,
|
send_booking_notification,
|
||||||
booking,
|
booking,
|
||||||
"rejected",
|
"rejected",
|
||||||
booking.user.email,
|
booking.user.email,
|
||||||
booking.user.full_name,
|
booking.user.full_name,
|
||||||
{"rejection_reason": reject_data.reason},
|
{"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
|
return booking
|
||||||
|
|
||||||
@@ -818,10 +1036,10 @@ def admin_update_booking(
|
|||||||
id: int,
|
id: int,
|
||||||
data: BookingUpdate,
|
data: BookingUpdate,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> Booking:
|
||||||
"""
|
"""
|
||||||
Update any booking (admin only).
|
Update any booking (admin/manager).
|
||||||
|
|
||||||
Admin can edit any booking (pending or approved), but cannot edit bookings
|
Admin can edit any booking (pending or approved), but cannot edit bookings
|
||||||
that have already started.
|
that have already started.
|
||||||
@@ -837,6 +1055,9 @@ def admin_update_booking(
|
|||||||
detail="Booking not found",
|
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)
|
# Check if booking already started (cannot edit past bookings)
|
||||||
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
|
if booking.start_datetime < datetime.utcnow() and booking.status == "approved": # type: ignore[operator]
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -856,13 +1077,14 @@ def admin_update_booking(
|
|||||||
|
|
||||||
# Re-validate booking rules
|
# Re-validate booking rules
|
||||||
# Use booking owner's timezone for validation
|
# 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(
|
errors = validate_booking_rules(
|
||||||
db=db,
|
db=db,
|
||||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||||
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
start_datetime=booking.start_datetime, # type: ignore[arg-type]
|
||||||
end_datetime=booking.end_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
|
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||||
user_timezone=user_timezone,
|
user_timezone=user_timezone,
|
||||||
)
|
)
|
||||||
@@ -873,6 +1095,15 @@ def admin_update_booking(
|
|||||||
detail=errors[0],
|
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 audit
|
||||||
log_action(
|
log_action(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -894,10 +1125,10 @@ def admin_cancel_booking(
|
|||||||
cancel_data: AdminCancelRequest,
|
cancel_data: AdminCancelRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> Booking:
|
||||||
"""
|
"""
|
||||||
Cancel any booking (admin only).
|
Cancel any booking (admin/manager).
|
||||||
|
|
||||||
Admin can cancel any booking at any time, regardless of status or timing.
|
Admin can cancel any booking at any time, regardless of status or timing.
|
||||||
No time restrictions apply (unlike user cancellations).
|
No time restrictions apply (unlike user cancellations).
|
||||||
@@ -915,13 +1146,16 @@ def admin_cancel_booking(
|
|||||||
detail="Booking not found",
|
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)
|
# Admin can cancel any booking (no status check needed)
|
||||||
# Update booking status
|
# Update booking status
|
||||||
booking.status = "canceled" # type: ignore[assignment]
|
booking.status = "canceled" # type: ignore[assignment]
|
||||||
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
|
booking.cancellation_reason = cancel_data.cancellation_reason # type: ignore[assignment]
|
||||||
|
|
||||||
# Delete from Google Calendar if event exists
|
# 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(
|
delete_calendar_event(
|
||||||
db=db,
|
db=db,
|
||||||
event_id=booking.google_calendar_event_id,
|
event_id=booking.google_calendar_event_id,
|
||||||
@@ -943,24 +1177,25 @@ def admin_cancel_booking(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify the user about cancellation
|
# Notify the user about cancellation
|
||||||
create_notification(
|
if booking.user_id and booking.user:
|
||||||
db=db,
|
create_notification(
|
||||||
user_id=booking.user_id, # type: ignore[arg-type]
|
db=db,
|
||||||
type="booking_canceled",
|
user_id=booking.user_id, # type: ignore[arg-type]
|
||||||
title="Rezervare Anulată",
|
type="booking_canceled",
|
||||||
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
title="Rezervare Anulată",
|
||||||
booking_id=booking.id,
|
message=f"Rezervarea ta pentru {booking.space.name} din {booking.start_datetime.strftime('%d.%m.%Y %H:%M')} a fost anulată de administrator. Motiv: {cancel_data.cancellation_reason or 'Nu a fost specificat'}", # type: ignore[union-attr]
|
||||||
)
|
booking_id=booking.id,
|
||||||
|
)
|
||||||
|
|
||||||
# Send email notification to user
|
# Send email notification to user
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
send_booking_notification,
|
send_booking_notification,
|
||||||
booking,
|
booking,
|
||||||
"canceled",
|
"canceled",
|
||||||
booking.user.email,
|
booking.user.email,
|
||||||
booking.user.full_name,
|
booking.user.full_name,
|
||||||
{"cancellation_reason": cancel_data.cancellation_reason},
|
{"cancellation_reason": cancel_data.cancellation_reason},
|
||||||
)
|
)
|
||||||
|
|
||||||
return booking
|
return booking
|
||||||
|
|
||||||
@@ -970,10 +1205,10 @@ def reschedule_booking(
|
|||||||
id: int,
|
id: int,
|
||||||
data: BookingReschedule,
|
data: BookingReschedule,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> 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.
|
Validates the new time slot and updates the booking times.
|
||||||
Only approved bookings that haven't started yet can be rescheduled.
|
Only approved bookings that haven't started yet can be rescheduled.
|
||||||
@@ -991,6 +1226,9 @@ def reschedule_booking(
|
|||||||
detail="Booking not found",
|
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)
|
# Check if booking already started (cannot reschedule past bookings)
|
||||||
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
|
if booking.start_datetime < datetime.utcnow(): # type: ignore[operator]
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1004,13 +1242,14 @@ def reschedule_booking(
|
|||||||
|
|
||||||
# Validate new time slot
|
# Validate new time slot
|
||||||
# Use booking owner's timezone for validation
|
# 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(
|
errors = validate_booking_rules(
|
||||||
db=db,
|
db=db,
|
||||||
space_id=int(booking.space_id), # type: ignore[arg-type]
|
space_id=int(booking.space_id), # type: ignore[arg-type]
|
||||||
start_datetime=data.start_datetime,
|
start_datetime=data.start_datetime,
|
||||||
end_datetime=data.end_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
|
exclude_booking_id=booking.id, # Exclude self from overlap check
|
||||||
user_timezone=user_timezone,
|
user_timezone=user_timezone,
|
||||||
)
|
)
|
||||||
@@ -1025,6 +1264,15 @@ def reschedule_booking(
|
|||||||
booking.start_datetime = data.start_datetime # type: ignore[assignment]
|
booking.start_datetime = data.start_datetime # type: ignore[assignment]
|
||||||
booking.end_datetime = data.end_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 audit
|
||||||
log_action(
|
log_action(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -1041,14 +1289,15 @@ def reschedule_booking(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify user about reschedule
|
# Notify user about reschedule
|
||||||
create_notification(
|
if booking.user_id:
|
||||||
db=db,
|
create_notification(
|
||||||
user_id=booking.user_id, # type: ignore[arg-type]
|
db=db,
|
||||||
type="booking_rescheduled",
|
user_id=booking.user_id, # type: ignore[arg-type]
|
||||||
title="Rezervare Reprogramată",
|
type="booking_rescheduled",
|
||||||
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
title="Rezervare Reprogramată",
|
||||||
booking_id=booking.id,
|
message=f"Rezervarea ta pentru {booking.space.name} a fost reprogramată pentru {data.start_datetime.strftime('%d.%m.%Y %H:%M')}",
|
||||||
)
|
booking_id=booking.id,
|
||||||
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
@@ -1060,10 +1309,10 @@ def reschedule_booking(
|
|||||||
def admin_create_booking(
|
def admin_create_booking(
|
||||||
booking_data: BookingAdminCreate,
|
booking_data: BookingAdminCreate,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> 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
|
- **space_id**: ID of the space to book
|
||||||
- **user_id**: Optional user ID (defaults to current admin if not provided)
|
- **user_id**: Optional user ID (defaults to current admin if not provided)
|
||||||
@@ -1087,6 +1336,9 @@ def admin_create_booking(
|
|||||||
detail="Space not found",
|
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
|
# 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]
|
target_user_id = booking_data.user_id if booking_data.user_id is not None else int(current_admin.id) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Google Calendar integration endpoints."""
|
"""Google Calendar integration endpoints."""
|
||||||
|
import urllib.parse
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from google_auth_oauthlib.flow import Flow
|
from google_auth_oauthlib.flow import Flow
|
||||||
from sqlalchemy.orm import Session
|
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.core.deps import get_current_user, get_db
|
||||||
from app.models.google_calendar_token import GoogleCalendarToken
|
from app.models.google_calendar_token import GoogleCalendarToken
|
||||||
from app.models.user import User
|
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()
|
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")
|
@router.get("/integrations/google/connect")
|
||||||
def connect_google(
|
def connect_google(
|
||||||
@@ -20,7 +48,9 @@ def connect_google(
|
|||||||
"""
|
"""
|
||||||
Start Google OAuth flow.
|
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:
|
if not settings.google_client_id or not settings.google_client_secret:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -28,29 +58,23 @@ def connect_google(
|
|||||||
detail="Google Calendar integration not configured",
|
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:
|
try:
|
||||||
flow = Flow.from_client_config(
|
flow = Flow.from_client_config(
|
||||||
{
|
_get_client_config(),
|
||||||
"web": {
|
scopes=GOOGLE_SCOPES,
|
||||||
"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",
|
|
||||||
],
|
|
||||||
redirect_uri=settings.google_redirect_uri,
|
redirect_uri=settings.google_redirect_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
authorization_url, state = flow.authorization_url(
|
authorization_url, _ = flow.authorization_url(
|
||||||
access_type="offline", include_granted_scopes="true", prompt="consent"
|
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}
|
return {"authorization_url": authorization_url, "state": state}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -61,85 +85,113 @@ def connect_google(
|
|||||||
|
|
||||||
@router.get("/integrations/google/callback")
|
@router.get("/integrations/google/callback")
|
||||||
def google_callback(
|
def google_callback(
|
||||||
code: Annotated[str, Query()],
|
|
||||||
state: Annotated[str, Query()],
|
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
code: Annotated[str | None, Query()] = None,
|
||||||
) -> dict[str, str]:
|
state: Annotated[str | None, Query()] = None,
|
||||||
|
error: Annotated[str | None, Query()] = None,
|
||||||
|
) -> RedirectResponse:
|
||||||
"""
|
"""
|
||||||
Handle Google OAuth callback.
|
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:
|
if not settings.google_client_id or not settings.google_client_secret:
|
||||||
raise HTTPException(
|
return RedirectResponse(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
url=f"{frontend_settings}?google_calendar=error&message=Not+configured"
|
||||||
detail="Google Calendar integration not configured",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
flow = Flow.from_client_config(
|
flow = Flow.from_client_config(
|
||||||
{
|
_get_client_config(),
|
||||||
"web": {
|
scopes=GOOGLE_SCOPES,
|
||||||
"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",
|
|
||||||
],
|
|
||||||
redirect_uri=settings.google_redirect_uri,
|
redirect_uri=settings.google_redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exchange code for tokens
|
# Exchange authorization code for tokens
|
||||||
flow.fetch_token(code=code)
|
flow.fetch_token(code=code)
|
||||||
|
|
||||||
credentials = flow.credentials
|
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 = (
|
token_record = (
|
||||||
db.query(GoogleCalendarToken)
|
db.query(GoogleCalendarToken)
|
||||||
.filter(GoogleCalendarToken.user_id == current_user.id)
|
.filter(GoogleCalendarToken.user_id == user_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if token_record:
|
if token_record:
|
||||||
token_record.access_token = credentials.token # type: ignore[assignment]
|
token_record.access_token = encrypted_access # type: ignore[assignment]
|
||||||
token_record.refresh_token = credentials.refresh_token # type: ignore[assignment]
|
token_record.refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||||
token_record.token_expiry = credentials.expiry # type: ignore[assignment]
|
token_record.token_expiry = credentials.expiry # type: ignore[assignment]
|
||||||
else:
|
else:
|
||||||
token_record = GoogleCalendarToken(
|
token_record = GoogleCalendarToken(
|
||||||
user_id=current_user.id, # type: ignore[arg-type]
|
user_id=user_id, # type: ignore[arg-type]
|
||||||
access_token=credentials.token,
|
access_token=encrypted_access,
|
||||||
refresh_token=credentials.refresh_token,
|
refresh_token=encrypted_refresh,
|
||||||
token_expiry=credentials.expiry,
|
token_expiry=credentials.expiry,
|
||||||
)
|
)
|
||||||
db.add(token_record)
|
db.add(token_record)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "Google Calendar connected successfully"}
|
return RedirectResponse(
|
||||||
|
url=f"{frontend_settings}?google_calendar=connected"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
msg = urllib.parse.quote(str(e))
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
return RedirectResponse(
|
||||||
detail=f"OAuth failed: {str(e)}",
|
url=f"{frontend_settings}?google_calendar=error&message={msg}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/integrations/google/disconnect")
|
@router.post("/integrations/google/disconnect")
|
||||||
def disconnect_google(
|
def disconnect_google(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
) -> dict[str, str]:
|
) -> 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 = (
|
token_record = (
|
||||||
db.query(GoogleCalendarToken)
|
db.query(GoogleCalendarToken)
|
||||||
@@ -148,12 +200,56 @@ def disconnect_google(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if token_record:
|
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.delete(token_record)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "Google Calendar disconnected"}
|
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")
|
@router.get("/integrations/google/status")
|
||||||
def google_status(
|
def google_status(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
@@ -162,7 +258,8 @@ def google_status(
|
|||||||
"""
|
"""
|
||||||
Check Google Calendar connection 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 = (
|
token_record = (
|
||||||
db.query(GoogleCalendarToken)
|
db.query(GoogleCalendarToken)
|
||||||
@@ -172,5 +269,9 @@ def google_status(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"connected": token_record is not None,
|
"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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
280
backend/app/api/organizations.py
Normal file
280
backend/app/api/organizations.py
Normal 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,
|
||||||
|
)
|
||||||
575
backend/app/api/properties.py
Normal file
575
backend/app/api/properties.py
Normal 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
227
backend/app/api/public.py
Normal 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)
|
||||||
@@ -7,7 +7,8 @@ from sqlalchemy import and_, case, func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
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.booking import Booking
|
||||||
from app.models.space import Space
|
from app.models.space import Space
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -41,8 +42,9 @@ def get_usage_report(
|
|||||||
start_date: date | None = Query(None),
|
start_date: date | None = Query(None),
|
||||||
end_date: date | None = Query(None),
|
end_date: date | None = Query(None),
|
||||||
space_id: int | None = Query(None),
|
space_id: int | None = Query(None),
|
||||||
|
property_id: int | None = Query(None),
|
||||||
db: Annotated[Session, Depends(get_db)] = 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:
|
) -> SpaceUsageReport:
|
||||||
"""Get booking usage report by space."""
|
"""Get booking usage report by space."""
|
||||||
query = (
|
query = (
|
||||||
@@ -81,6 +83,13 @@ def get_usage_report(
|
|||||||
)
|
)
|
||||||
if space_id:
|
if space_id:
|
||||||
filters.append(Booking.space_id == 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:
|
if filters:
|
||||||
query = query.filter(and_(*filters))
|
query = query.filter(and_(*filters))
|
||||||
@@ -114,7 +123,7 @@ def get_top_users_report(
|
|||||||
end_date: date | None = Query(None),
|
end_date: date | None = Query(None),
|
||||||
limit: int = Query(10, ge=1, le=100),
|
limit: int = Query(10, ge=1, le=100),
|
||||||
db: Annotated[Session, Depends(get_db)] = 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,
|
||||||
) -> TopUsersReport:
|
) -> TopUsersReport:
|
||||||
"""Get top users by booking count."""
|
"""Get top users by booking count."""
|
||||||
query = (
|
query = (
|
||||||
@@ -129,6 +138,7 @@ def get_top_users_report(
|
|||||||
func.sum(calculate_hours_expr()).label("total_hours"),
|
func.sum(calculate_hours_expr()).label("total_hours"),
|
||||||
)
|
)
|
||||||
.join(User, Booking.user_id == User.id)
|
.join(User, Booking.user_id == User.id)
|
||||||
|
.join(Space, Booking.space_id == Space.id)
|
||||||
.group_by(Booking.user_id, User.full_name, User.email)
|
.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())
|
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
|
# Order by total bookings desc
|
||||||
query = query.order_by(func.count(Booking.id).desc()).limit(limit)
|
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),
|
start_date: date | None = Query(None),
|
||||||
end_date: date | None = Query(None),
|
end_date: date | None = Query(None),
|
||||||
db: Annotated[Session, Depends(get_db)] = 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:
|
) -> ApprovalRateReport:
|
||||||
"""Get approval/rejection rate report."""
|
"""Get approval/rejection rate report."""
|
||||||
query = db.query(
|
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 == "rejected", 1), else_=0)).label("rejected"),
|
||||||
func.sum(case((Booking.status == "pending", 1), else_=0)).label("pending"),
|
func.sum(case((Booking.status == "pending", 1), else_=0)).label("pending"),
|
||||||
func.sum(case((Booking.status == "canceled", 1), else_=0)).label("canceled"),
|
func.sum(case((Booking.status == "canceled", 1), else_=0)).label("canceled"),
|
||||||
)
|
).join(Space, Booking.space_id == Space.id)
|
||||||
|
|
||||||
# Apply date filters
|
# Apply date filters
|
||||||
if start_date:
|
if start_date:
|
||||||
@@ -193,6 +208,11 @@ def get_approval_rate_report(
|
|||||||
Booking.start_datetime <= datetime.combine(end_date, datetime.max.time())
|
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()
|
result = query.first()
|
||||||
|
|
||||||
total = result.total or 0
|
total = result.total or 0
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
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.space import Space
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.space import SpaceCreate, SpaceResponse, SpaceStatusUpdate, SpaceUpdate
|
from app.schemas.space import SpaceCreate, SpaceResponse, SpaceStatusUpdate, SpaceUpdate
|
||||||
@@ -18,36 +19,59 @@ admin_router = APIRouter(prefix="/admin/spaces", tags=["admin"])
|
|||||||
def list_spaces(
|
def list_spaces(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
) -> list[Space]:
|
property_id: int | None = None,
|
||||||
|
) -> list[SpaceResponse]:
|
||||||
"""
|
"""
|
||||||
Get list of spaces.
|
Get list of spaces.
|
||||||
|
|
||||||
- Users see only active spaces
|
- Users see only active spaces
|
||||||
- Admins see all spaces (active + inactive)
|
- Admins/superadmins/managers see all spaces (active + inactive)
|
||||||
"""
|
"""
|
||||||
query = db.query(Space)
|
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
|
# 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
|
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()
|
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)
|
@admin_router.post("", response_model=SpaceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_space(
|
def create_space(
|
||||||
space_data: SpaceCreate,
|
space_data: SpaceCreate,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> SpaceResponse:
|
||||||
"""
|
"""
|
||||||
Create a new space (admin only).
|
Create a new space (admin/manager).
|
||||||
|
|
||||||
- name: required, non-empty
|
- name: required, non-empty
|
||||||
- type: "sala" or "birou"
|
- type: "sala" or "birou"
|
||||||
- capacity: must be > 0
|
- capacity: must be > 0
|
||||||
- description: optional
|
- description: optional
|
||||||
|
- property_id: optional, assign to property
|
||||||
"""
|
"""
|
||||||
# Check if space with same name exists
|
# Check if space with same name exists
|
||||||
existing = db.query(Space).filter(Space.name == space_data.name).first()
|
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",
|
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(
|
space = Space(
|
||||||
name=space_data.name,
|
name=space_data.name,
|
||||||
type=space_data.type,
|
type=space_data.type,
|
||||||
capacity=space_data.capacity,
|
capacity=space_data.capacity,
|
||||||
description=space_data.description,
|
description=space_data.description,
|
||||||
|
property_id=space_data.property_id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,7 +109,10 @@ def create_space(
|
|||||||
details={"name": space.name, "type": space.type, "capacity": space.capacity}
|
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)
|
@admin_router.put("/{space_id}", response_model=SpaceResponse)
|
||||||
@@ -87,7 +120,7 @@ def update_space(
|
|||||||
space_id: int,
|
space_id: int,
|
||||||
space_data: SpaceUpdate,
|
space_data: SpaceUpdate,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
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:
|
) -> Space:
|
||||||
"""
|
"""
|
||||||
Update an existing space (admin only).
|
Update an existing space (admin only).
|
||||||
@@ -101,6 +134,15 @@ def update_space(
|
|||||||
detail="Space not found",
|
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
|
# Check if new name conflicts with another space
|
||||||
if space_data.name != space.name:
|
if space_data.name != space.name:
|
||||||
existing = db.query(Space).filter(Space.name == space_data.name).first()
|
existing = db.query(Space).filter(Space.name == space_data.name).first()
|
||||||
@@ -147,7 +189,7 @@ def update_space_status(
|
|||||||
space_id: int,
|
space_id: int,
|
||||||
status_data: SpaceStatusUpdate,
|
status_data: SpaceStatusUpdate,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
_: Annotated[User, Depends(get_current_admin)],
|
current_admin: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||||
) -> Space:
|
) -> Space:
|
||||||
"""
|
"""
|
||||||
Activate or deactivate a space (admin only).
|
Activate or deactivate a space (admin only).
|
||||||
@@ -161,6 +203,15 @@ def update_space_status(
|
|||||||
detail="Space not found",
|
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)
|
setattr(space, "is_active", status_data.is_active)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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.core.security import get_password_hash
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import (
|
from app.schemas.user import (
|
||||||
@@ -65,12 +65,12 @@ def update_timezone(
|
|||||||
@admin_router.get("", response_model=list[UserResponse])
|
@admin_router.get("", response_model=list[UserResponse])
|
||||||
def list_users(
|
def list_users(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
_: Annotated[User, Depends(get_current_admin)],
|
_: Annotated[User, Depends(get_current_manager_or_superadmin)],
|
||||||
role: str | None = None,
|
role: str | None = None,
|
||||||
organization: str | None = None,
|
organization: str | None = None,
|
||||||
) -> list[User]:
|
) -> list[User]:
|
||||||
"""
|
"""
|
||||||
Get list of users (admin only).
|
Get list of users (manager or admin).
|
||||||
|
|
||||||
Supports filtering by role and organization.
|
Supports filtering by role and organization.
|
||||||
"""
|
"""
|
||||||
@@ -109,10 +109,10 @@ def create_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate role
|
# Validate role
|
||||||
if user_data.role not in ["admin", "user"]:
|
if user_data.role not in ["admin", "superadmin", "manager", "user"]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Role must be 'admin' or 'user'",
|
detail="Role must be 'superadmin', 'manager', or 'user'",
|
||||||
)
|
)
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
@@ -170,10 +170,10 @@ def update_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate role
|
# 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Track what changed
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.db.session import get_db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
optional_security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
@@ -40,13 +41,58 @@ def get_current_user(
|
|||||||
return 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(
|
def get_current_admin(
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Verify current user is admin."""
|
"""Verify current user is admin (superadmin or legacy admin)."""
|
||||||
if current_user.role != "admin":
|
if current_user.role not in ("admin", "superadmin"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Not enough permissions",
|
detail="Not enough permissions",
|
||||||
)
|
)
|
||||||
return current_user
|
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
|
||||||
|
|||||||
115
backend/app/core/permissions.py
Normal file
115
backend/app/core/permissions.py
Normal 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))
|
||||||
@@ -11,6 +11,12 @@ from app.api.bookings import bookings_router
|
|||||||
from app.api.bookings import router as spaces_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.google_calendar import router as google_calendar_router
|
||||||
from app.api.notifications import router as notifications_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.reports import router as reports_router
|
||||||
from app.api.settings import router as settings_router
|
from app.api.settings import router as settings_router
|
||||||
from app.api.spaces import admin_router as spaces_admin_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(attachments_router, prefix="/api", tags=["attachments"])
|
||||||
app.include_router(reports_router, prefix="/api", tags=["reports"])
|
app.include_router(reports_router, prefix="/api", tags=["reports"])
|
||||||
app.include_router(google_calendar_router, prefix="/api", tags=["google-calendar"])
|
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("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ from app.models.booking import Booking
|
|||||||
from app.models.booking_template import BookingTemplate
|
from app.models.booking_template import BookingTemplate
|
||||||
from app.models.google_calendar_token import GoogleCalendarToken
|
from app.models.google_calendar_token import GoogleCalendarToken
|
||||||
from app.models.notification import Notification
|
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.settings import Settings
|
||||||
from app.models.space import Space
|
from app.models.space import Space
|
||||||
from app.models.user import User
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Booking model."""
|
"""Booking model."""
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -13,8 +13,12 @@ class Booking(Base):
|
|||||||
__tablename__ = "bookings"
|
__tablename__ = "bookings"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
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)
|
title = Column(String, nullable=False)
|
||||||
description = Column(String, nullable=True)
|
description = Column(String, nullable=True)
|
||||||
start_datetime = Column(DateTime, nullable=False, index=True)
|
start_datetime = Column(DateTime, nullable=False, index=True)
|
||||||
|
|||||||
18
backend/app/models/organization.py
Normal file
18
backend/app/models/organization.py
Normal 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)
|
||||||
17
backend/app/models/organization_member.py
Normal file
17
backend/app/models/organization_member.py
Normal 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"),)
|
||||||
20
backend/app/models/property.py
Normal file
20
backend/app/models/property.py
Normal 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)
|
||||||
19
backend/app/models/property_access.py
Normal file
19
backend/app/models/property_access.py
Normal 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)
|
||||||
16
backend/app/models/property_manager.py
Normal file
16
backend/app/models/property_manager.py
Normal 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"),)
|
||||||
20
backend/app/models/property_settings.py
Normal file
20
backend/app/models/property_settings.py
Normal 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)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Space model."""
|
"""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
|
from app.db.session import Base
|
||||||
|
|
||||||
@@ -15,9 +16,12 @@ class Space(Base):
|
|||||||
capacity = Column(Integer, nullable=False)
|
capacity = Column(Integer, nullable=False)
|
||||||
description = Column(String, nullable=True)
|
description = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
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)
|
# Per-space scheduling settings (NULL = use global default)
|
||||||
working_hours_start = Column(Integer, nullable=True)
|
working_hours_start = Column(Integer, nullable=True)
|
||||||
working_hours_end = Column(Integer, nullable=True)
|
working_hours_end = Column(Integer, nullable=True)
|
||||||
min_duration_minutes = Column(Integer, nullable=True)
|
min_duration_minutes = Column(Integer, nullable=True)
|
||||||
max_duration_minutes = Column(Integer, nullable=True)
|
max_duration_minutes = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
property = relationship("Property", backref="spaces")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class User(Base):
|
|||||||
email = Column(String, unique=True, index=True, nullable=False)
|
email = Column(String, unique=True, index=True, nullable=False)
|
||||||
full_name = Column(String, nullable=False)
|
full_name = Column(String, nullable=False)
|
||||||
hashed_password = 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)
|
organization = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
timezone = Column(String(50), default="UTC", nullable=False) # IANA timezone
|
timezone = Column(String(50), default="UTC", nullable=False) # IANA timezone
|
||||||
@@ -26,3 +26,5 @@ class User(Base):
|
|||||||
google_calendar_token = relationship(
|
google_calendar_token = relationship(
|
||||||
"GoogleCalendarToken", back_populates="user", uselist=False
|
"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")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Booking schemas for request/response."""
|
"""Booking schemas for request/response."""
|
||||||
from datetime import datetime, date
|
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):
|
class BookingCalendarPublic(BaseModel):
|
||||||
@@ -21,7 +21,7 @@ class BookingCalendarAdmin(BaseModel):
|
|||||||
"""Full booking data for admins (calendar view)."""
|
"""Full booking data for admins (calendar view)."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int | None = None
|
||||||
space_id: int
|
space_id: int
|
||||||
start_datetime: datetime
|
start_datetime: datetime
|
||||||
end_datetime: datetime
|
end_datetime: datetime
|
||||||
@@ -50,7 +50,7 @@ class BookingResponse(BaseModel):
|
|||||||
"""Schema for booking response after creation."""
|
"""Schema for booking response after creation."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int | None = None
|
||||||
space_id: int
|
space_id: int
|
||||||
start_datetime: datetime
|
start_datetime: datetime
|
||||||
end_datetime: datetime
|
end_datetime: datetime
|
||||||
@@ -58,6 +58,10 @@ class BookingResponse(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
created_at: datetime
|
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)
|
# Timezone-aware formatted strings (optional, set by endpoint)
|
||||||
start_datetime_tz: Optional[str] = None
|
start_datetime_tz: Optional[str] = None
|
||||||
end_datetime_tz: Optional[str] = None
|
end_datetime_tz: Optional[str] = None
|
||||||
@@ -79,6 +83,10 @@ class BookingResponse(BaseModel):
|
|||||||
title=booking.title,
|
title=booking.title,
|
||||||
description=booking.description,
|
description=booking.description,
|
||||||
created_at=booking.created_at,
|
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),
|
start_datetime_tz=format_datetime_tz(booking.start_datetime, user_timezone),
|
||||||
end_datetime_tz=format_datetime_tz(booking.end_datetime, user_timezone)
|
end_datetime_tz=format_datetime_tz(booking.end_datetime, user_timezone)
|
||||||
)
|
)
|
||||||
@@ -90,9 +98,20 @@ class SpaceInBooking(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
type: str
|
type: str
|
||||||
|
property_id: int | None = None
|
||||||
|
property_name: str | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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):
|
class BookingWithSpace(BaseModel):
|
||||||
"""Booking with associated space details for user's booking list."""
|
"""Booking with associated space details for user's booking list."""
|
||||||
@@ -127,14 +146,18 @@ class BookingPendingDetail(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
space_id: int
|
space_id: int
|
||||||
space: SpaceInBooking
|
space: SpaceInBooking
|
||||||
user_id: int
|
user_id: int | None = None
|
||||||
user: UserInBooking
|
user: UserInBooking | None = None
|
||||||
start_datetime: datetime
|
start_datetime: datetime
|
||||||
end_datetime: datetime
|
end_datetime: datetime
|
||||||
status: str
|
status: str
|
||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
created_at: datetime
|
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}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -242,3 +265,16 @@ class BookingReschedule(BaseModel):
|
|||||||
|
|
||||||
start_datetime: datetime
|
start_datetime: datetime
|
||||||
end_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
|
||||||
|
|||||||
41
backend/app/schemas/organization.py
Normal file
41
backend/app/schemas/organization.py
Normal 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"
|
||||||
82
backend/app/schemas/property.py
Normal file
82
backend/app/schemas/property.py
Normal 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
|
||||||
@@ -20,7 +20,7 @@ class SpaceBase(BaseModel):
|
|||||||
class SpaceCreate(SpaceBase):
|
class SpaceCreate(SpaceBase):
|
||||||
"""Space creation schema."""
|
"""Space creation schema."""
|
||||||
|
|
||||||
pass
|
property_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SpaceUpdate(SpaceBase):
|
class SpaceUpdate(SpaceBase):
|
||||||
@@ -40,6 +40,8 @@ class SpaceResponse(SpaceBase):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
property_id: int | None = None
|
||||||
|
property_name: str | None = None
|
||||||
working_hours_start: int | None = None
|
working_hours_start: int | None = None
|
||||||
working_hours_end: int | None = None
|
working_hours_end: int | None = None
|
||||||
min_duration_minutes: int | None = None
|
min_duration_minutes: int | None = None
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import and_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.booking import Booking
|
from app.models.booking import Booking
|
||||||
|
from app.models.property_settings import PropertySettings
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
from app.models.space import Space
|
from app.models.space import Space
|
||||||
from app.utils.timezone import convert_from_utc, convert_to_utc
|
from app.utils.timezone import convert_from_utc, convert_to_utc
|
||||||
@@ -53,27 +54,43 @@ def validate_booking_rules(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(settings)
|
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()
|
space = db.query(Space).filter(Space.id == space_id).first()
|
||||||
wh_start = (
|
|
||||||
space.working_hours_start
|
# Fetch property settings if space has a property
|
||||||
if space and space.working_hours_start is not None
|
prop_settings = None
|
||||||
else settings.working_hours_start
|
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 = (
|
wh_end = resolve(
|
||||||
space.working_hours_end
|
space.working_hours_end if space else None,
|
||||||
if space and space.working_hours_end is not None
|
prop_settings.working_hours_end if prop_settings else None,
|
||||||
else settings.working_hours_end
|
settings.working_hours_end,
|
||||||
)
|
)
|
||||||
min_dur = (
|
min_dur = resolve(
|
||||||
space.min_duration_minutes
|
space.min_duration_minutes if space else None,
|
||||||
if space and space.min_duration_minutes is not None
|
prop_settings.min_duration_minutes if prop_settings else None,
|
||||||
else settings.min_duration_minutes
|
settings.min_duration_minutes,
|
||||||
)
|
)
|
||||||
max_dur = (
|
max_dur = resolve(
|
||||||
space.max_duration_minutes
|
space.max_duration_minutes if space else None,
|
||||||
if space and space.max_duration_minutes is not None
|
prop_settings.max_duration_minutes if prop_settings else None,
|
||||||
else settings.max_duration_minutes
|
settings.max_duration_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert UTC times to user timezone for validation
|
# 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"
|
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(
|
query = db.query(Booking).filter(
|
||||||
Booking.space_id == space_id,
|
Booking.space_id == space_id,
|
||||||
Booking.status.in_(["approved", "pending"]),
|
Booking.status == "approved",
|
||||||
and_(
|
and_(
|
||||||
Booking.start_datetime < end_datetime,
|
Booking.start_datetime < end_datetime,
|
||||||
Booking.end_datetime > start_datetime,
|
Booking.end_datetime > start_datetime,
|
||||||
|
|||||||
@@ -128,6 +128,58 @@ Motiv: {reason}
|
|||||||
|
|
||||||
Vă rugăm să contactați administratorul pentru detalii.
|
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ă,
|
Cu stimă,
|
||||||
Sistemul de Rezervări
|
Sistemul de Rezervări
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"""Google Calendar integration service."""
|
"""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 typing import Optional
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
from jose import JWTError, jwt
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -12,16 +18,92 @@ from app.models.booking import Booking
|
|||||||
from app.models.google_calendar_token import GoogleCalendarToken
|
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):
|
def get_google_calendar_service(db: Session, user_id: int):
|
||||||
"""
|
"""
|
||||||
Get authenticated Google Calendar service for user.
|
Get authenticated Google Calendar service for user.
|
||||||
|
|
||||||
Args:
|
Handles token decryption and automatic refresh of expired tokens.
|
||||||
db: Database session
|
|
||||||
user_id: User ID
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Google Calendar service object or None if not connected
|
Google Calendar service object or None if not connected/failed
|
||||||
"""
|
"""
|
||||||
token_record = (
|
token_record = (
|
||||||
db.query(GoogleCalendarToken)
|
db.query(GoogleCalendarToken)
|
||||||
@@ -32,10 +114,14 @@ def get_google_calendar_service(db: Session, user_id: int):
|
|||||||
if not token_record:
|
if not token_record:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Decrypt tokens from database
|
||||||
|
access_token = decrypt_token(token_record.access_token)
|
||||||
|
refresh_token = decrypt_token(token_record.refresh_token)
|
||||||
|
|
||||||
# Create credentials
|
# Create credentials
|
||||||
creds = Credentials(
|
creds = Credentials(
|
||||||
token=token_record.access_token,
|
token=access_token,
|
||||||
refresh_token=token_record.refresh_token,
|
refresh_token=refresh_token,
|
||||||
token_uri="https://oauth2.googleapis.com/token",
|
token_uri="https://oauth2.googleapis.com/token",
|
||||||
client_id=settings.google_client_id,
|
client_id=settings.google_client_id,
|
||||||
client_secret=settings.google_client_secret,
|
client_secret=settings.google_client_secret,
|
||||||
@@ -46,13 +132,13 @@ def get_google_calendar_service(db: Session, user_id: int):
|
|||||||
try:
|
try:
|
||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
|
|
||||||
# Update tokens in DB
|
# Update encrypted tokens in DB
|
||||||
token_record.access_token = creds.token # type: ignore[assignment]
|
token_record.access_token = encrypt_token(creds.token) # type: ignore[assignment]
|
||||||
if creds.expiry:
|
if creds.expiry:
|
||||||
token_record.token_expiry = creds.expiry # type: ignore[assignment]
|
token_record.token_expiry = creds.expiry # type: ignore[assignment]
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
# Build service
|
# Build service
|
||||||
@@ -70,11 +156,6 @@ def create_calendar_event(
|
|||||||
"""
|
"""
|
||||||
Create Google Calendar event for booking.
|
Create Google Calendar event for booking.
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
booking: Booking object
|
|
||||||
user_id: User ID
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Google Calendar event ID or None if failed
|
Google Calendar event ID or None if failed
|
||||||
"""
|
"""
|
||||||
@@ -83,7 +164,6 @@ def create_calendar_event(
|
|||||||
if not service:
|
if not service:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create event
|
|
||||||
event = {
|
event = {
|
||||||
"summary": f"{booking.space.name}: {booking.title}",
|
"summary": f"{booking.space.name}: {booking.title}",
|
||||||
"description": booking.description or "",
|
"description": booking.description or "",
|
||||||
@@ -111,12 +191,6 @@ def update_calendar_event(
|
|||||||
"""
|
"""
|
||||||
Update Google Calendar event for booking.
|
Update Google Calendar event for booking.
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
booking: Booking object
|
|
||||||
user_id: User ID
|
|
||||||
event_id: Google Calendar event ID
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -125,7 +199,6 @@ def update_calendar_event(
|
|||||||
if not service:
|
if not service:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Update event
|
|
||||||
event = {
|
event = {
|
||||||
"summary": f"{booking.space.name}: {booking.title}",
|
"summary": f"{booking.space.name}: {booking.title}",
|
||||||
"description": booking.description or "",
|
"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.
|
Delete Google Calendar event.
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
event_id: Google Calendar event ID
|
|
||||||
user_id: User ID
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
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:
|
except Exception as e:
|
||||||
print(f"Failed to delete Google Calendar event: {e}")
|
print(f"Failed to delete Google Calendar event: {e}")
|
||||||
return False
|
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),
|
||||||
|
}
|
||||||
|
|||||||
106
backend/migrate_to_multi_property.py
Normal file
106
backend/migrate_to_multi_property.py
Normal 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()
|
||||||
@@ -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.core.security import get_password_hash
|
||||||
from app.db.session import Base, SessionLocal, engine
|
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.settings import Settings
|
||||||
|
from app.models.space import Space
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
def seed_database() -> None:
|
def seed_database() -> None:
|
||||||
"""Create initial users for testing."""
|
"""Create initial data for testing multi-property system."""
|
||||||
# Create tables
|
# Create tables
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
@@ -18,16 +24,27 @@ def seed_database() -> None:
|
|||||||
print("Database already seeded. Skipping...")
|
print("Database already seeded. Skipping...")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create admin user
|
# Create superadmin user
|
||||||
admin = User(
|
superadmin = User(
|
||||||
email="admin@example.com",
|
email="admin@example.com",
|
||||||
full_name="Admin User",
|
full_name="Super Admin",
|
||||||
hashed_password=get_password_hash("adminpassword"),
|
hashed_password=get_password_hash("adminpassword"),
|
||||||
role="admin",
|
role="superadmin",
|
||||||
organization="Management",
|
organization="Management",
|
||||||
is_active=True,
|
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
|
# Create regular user
|
||||||
user = User(
|
user = User(
|
||||||
@@ -40,6 +57,93 @@ def seed_database() -> None:
|
|||||||
)
|
)
|
||||||
db.add(user)
|
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
|
# Create default settings if not exist
|
||||||
existing_settings = db.query(Settings).filter(Settings.id == 1).first()
|
existing_settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
if not existing_settings:
|
if not existing_settings:
|
||||||
@@ -55,9 +159,12 @@ def seed_database() -> None:
|
|||||||
db.add(default_settings)
|
db.add(default_settings)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print("✓ Database seeded successfully!")
|
print("Database seeded successfully!")
|
||||||
print("Admin: admin@example.com / adminpassword")
|
print("Superadmin: admin@example.com / adminpassword")
|
||||||
|
print("Manager: manager@example.com / managerpassword")
|
||||||
print("User: user@example.com / userpassword")
|
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:
|
except Exception as e:
|
||||||
print(f"Error seeding database: {e}")
|
print(f"Error seeding database: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@fullcalendar/core": "^6.1.0",
|
"@fullcalendar/core": "^6.1.0",
|
||||||
"@fullcalendar/daygrid": "^6.1.0",
|
"@fullcalendar/daygrid": "^6.1.0",
|
||||||
"@fullcalendar/interaction": "^6.1.0",
|
"@fullcalendar/interaction": "^6.1.0",
|
||||||
|
"@fullcalendar/list": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.0",
|
"@fullcalendar/timegrid": "^6.1.0",
|
||||||
"@fullcalendar/vue3": "^6.1.0",
|
"@fullcalendar/vue3": "^6.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
@@ -582,6 +583,15 @@
|
|||||||
"@fullcalendar/core": "~6.1.20"
|
"@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": {
|
"node_modules/@fullcalendar/timegrid": {
|
||||||
"version": "6.1.20",
|
"version": "6.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@fullcalendar/core": "^6.1.0",
|
"@fullcalendar/core": "^6.1.0",
|
||||||
"@fullcalendar/daygrid": "^6.1.0",
|
"@fullcalendar/daygrid": "^6.1.0",
|
||||||
"@fullcalendar/interaction": "^6.1.0",
|
"@fullcalendar/interaction": "^6.1.0",
|
||||||
|
"@fullcalendar/list": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.0",
|
"@fullcalendar/timegrid": "^6.1.0",
|
||||||
"@fullcalendar/vue3": "^6.1.0",
|
"@fullcalendar/vue3": "^6.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<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 -->
|
<!-- 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">
|
<button class="mobile-hamburger" @click="toggleMobile" aria-label="Open menu">
|
||||||
<Menu :size="22" />
|
<Menu :size="22" />
|
||||||
</button>
|
</button>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { notificationsApi } from '@/services/api'
|
import { notificationsApi } from '@/services/api'
|
||||||
import { useSidebar } from '@/composables/useSidebar'
|
import { useSidebar } from '@/composables/useSidebar'
|
||||||
import type { Notification } from '@/types'
|
import type { Notification } from '@/types'
|
||||||
@@ -68,6 +68,10 @@ import { Menu, Bell, X } from 'lucide-vue-next'
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
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 { collapsed, toggleMobile } = useSidebar()
|
||||||
|
|
||||||
const notifications = ref<Notification[]>([])
|
const notifications = ref<Notification[]>([])
|
||||||
@@ -117,7 +121,7 @@ const handleNotificationClick = async (notification: Notification) => {
|
|||||||
|
|
||||||
if (notification.booking_id) {
|
if (notification.booking_id) {
|
||||||
closeNotifications()
|
closeNotifications()
|
||||||
router.push('/my-bookings')
|
router.push('/history')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,10 @@
|
|||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
--sidebar-collapsed-width: 68px;
|
--sidebar-collapsed-width: 68px;
|
||||||
--sidebar-bg: #1a1a2e;
|
--sidebar-bg: #ffffff;
|
||||||
--sidebar-text: #a1a1b5;
|
--sidebar-text: #6b7280;
|
||||||
--sidebar-text-active: #ffffff;
|
--sidebar-text-active: #1a1a2e;
|
||||||
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
--sidebar-hover-bg: #f3f4f6;
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
@@ -52,18 +52,24 @@
|
|||||||
|
|
||||||
/* Dark Theme */
|
/* Dark Theme */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--color-bg-primary: #0f0f1a;
|
--color-bg-primary: #0a0a12;
|
||||||
--color-bg-secondary: #1a1a2e;
|
--color-bg-secondary: #151520;
|
||||||
--color-bg-tertiary: #232340;
|
--color-bg-tertiary: #1f1f2e;
|
||||||
--color-surface: #1a1a2e;
|
--color-surface: #151520;
|
||||||
--color-surface-hover: #232340;
|
--color-surface-hover: #1f1f2e;
|
||||||
--color-text-primary: #e5e5ef;
|
--color-text-primary: #f0f0f5;
|
||||||
--color-text-secondary: #9ca3af;
|
--color-text-secondary: #b8bac5;
|
||||||
--color-text-muted: #6b7280;
|
--color-text-muted: #8b8d9a;
|
||||||
--color-accent-light: #1e1b4b;
|
--color-accent-light: #2a2650;
|
||||||
--color-border: #2d2d4a;
|
--color-border: #3a3a52;
|
||||||
--color-border-light: #232340;
|
--color-border-light: #2d2d42;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-md: 0 4px 6px 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.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);
|
||||||
}
|
}
|
||||||
|
|||||||
224
frontend/src/components/ActionMenu.vue
Normal file
224
frontend/src/components/ActionMenu.vue
Normal 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>
|
||||||
258
frontend/src/components/ActiveBookings.vue
Normal file
258
frontend/src/components/ActiveBookings.vue
Normal 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>
|
||||||
460
frontend/src/components/AdminBookingForm.vue
Normal file
460
frontend/src/components/AdminBookingForm.vue
Normal 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>
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sidebar" :class="{ collapsed, 'mobile-open': mobileOpen }">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<PropertySelector v-show="showLabels" />
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-section">
|
<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
|
<router-link
|
||||||
v-for="item in mainNav"
|
v-for="item in mainNav"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
@@ -17,14 +19,14 @@
|
|||||||
@click="closeMobile"
|
@click="closeMobile"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" :size="20" class="nav-icon" />
|
<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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="authStore.isAdmin" class="nav-section">
|
<div v-if="authStore.isAdminOrManager" class="nav-section">
|
||||||
<span v-show="!collapsed" class="nav-section-label">Admin</span>
|
<span v-show="showLabels" class="nav-section-label">Management</span>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in adminNav"
|
v-for="item in managerNav"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
@@ -32,13 +34,28 @@
|
|||||||
@click="closeMobile"
|
@click="closeMobile"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" :size="20" class="nav-icon" />
|
<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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div v-show="!collapsed" class="user-info">
|
<div v-show="showLabels" class="user-info">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
{{ authStore.user?.email?.charAt(0).toUpperCase() }}
|
{{ authStore.user?.email?.charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +89,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useSidebar } from '@/composables/useSidebar'
|
import { useSidebar } from '@/composables/useSidebar'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import PropertySelector from '@/components/PropertySelector.vue'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -79,7 +97,6 @@ import {
|
|||||||
User,
|
User,
|
||||||
Settings2,
|
Settings2,
|
||||||
Users,
|
Users,
|
||||||
ClipboardCheck,
|
|
||||||
Sliders,
|
Sliders,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
@@ -87,7 +104,8 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
LogOut
|
LogOut,
|
||||||
|
Landmark
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -96,6 +114,9 @@ const router = useRouter()
|
|||||||
const { collapsed, mobileOpen, toggle, closeMobile } = useSidebar()
|
const { collapsed, mobileOpen, toggle, closeMobile } = useSidebar()
|
||||||
const { theme, resolvedTheme, toggleTheme } = useTheme()
|
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(() => {
|
const themeTitle = computed(() => {
|
||||||
if (theme.value === 'light') return 'Switch to dark mode'
|
if (theme.value === 'light') return 'Switch to dark mode'
|
||||||
if (theme.value === 'dark') return 'Switch to auto mode'
|
if (theme.value === 'dark') return 'Switch to auto mode'
|
||||||
@@ -105,14 +126,17 @@ const themeTitle = computed(() => {
|
|||||||
const mainNav = [
|
const mainNav = [
|
||||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
{ to: '/spaces', icon: Building2, label: 'Spaces' },
|
{ 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' },
|
{ to: '/profile', icon: User, label: 'Profile' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const adminNav = [
|
const managerNav = [
|
||||||
|
{ to: '/properties', icon: Landmark, label: 'Properties' },
|
||||||
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
|
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const superadminNav = [
|
||||||
{ to: '/users', icon: Users, label: 'Users' },
|
{ to: '/users', icon: Users, label: 'Users' },
|
||||||
{ to: '/admin/pending', icon: ClipboardCheck, label: 'Pending' },
|
|
||||||
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
|
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
|
||||||
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },
|
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },
|
||||||
{ to: '/admin/audit-log', icon: ScrollText, label: 'Audit Log' },
|
{ to: '/admin/audit-log', icon: ScrollText, label: 'Audit Log' },
|
||||||
@@ -123,6 +147,13 @@ const isActive = (path: string) => {
|
|||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleHeaderClick = () => {
|
||||||
|
// Only toggle on desktop (≥768px)
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -148,6 +179,16 @@ const handleLogout = () => {
|
|||||||
width: var(--sidebar-collapsed-width);
|
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 */
|
/* Header */
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -156,6 +197,12 @@ const handleLogout = () => {
|
|||||||
padding: 1.25rem 1.25rem 1rem;
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo-icon {
|
.sidebar-logo-icon {
|
||||||
@@ -313,6 +360,14 @@ const handleLogout = () => {
|
|||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-only {
|
.desktop-only {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
338
frontend/src/components/BookingEditModal.vue
Normal file
338
frontend/src/components/BookingEditModal.vue
Normal 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>
|
||||||
206
frontend/src/components/BookingFilters.vue
Normal file
206
frontend/src/components/BookingFilters.vue
Normal 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>
|
||||||
443
frontend/src/components/BookingPreviewModal.vue
Normal file
443
frontend/src/components/BookingPreviewModal.vue
Normal 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">
|
||||||
|
· {{ booking.guest_email }}
|
||||||
|
</span>
|
||||||
|
<span v-if="booking.guest_organization" class="detail-muted">
|
||||||
|
· {{ 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">
|
||||||
|
· {{ 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>
|
||||||
249
frontend/src/components/BookingRow.vue
Normal file
249
frontend/src/components/BookingRow.vue
Normal 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>
|
||||||
53
frontend/src/components/Breadcrumb.vue
Normal file
53
frontend/src/components/Breadcrumb.vue
Normal 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>
|
||||||
606
frontend/src/components/DashboardCalendar.vue
Normal file
606
frontend/src/components/DashboardCalendar.vue
Normal 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">→</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>
|
||||||
87
frontend/src/components/PropertySelector.vue
Normal file
87
frontend/src/components/PropertySelector.vue
Normal 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>
|
||||||
@@ -5,7 +5,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div v-if="loading && !confirmModal.show" class="loading">Loading calendar...</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 -->
|
<!-- Confirmation Modal -->
|
||||||
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
||||||
@@ -39,30 +51,37 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import FullCalendar from '@fullcalendar/vue3'
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
|
import listPlugin from '@fullcalendar/list'
|
||||||
import interactionPlugin from '@fullcalendar/interaction'
|
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 { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
|
import { useIsMobile } from '@/composables/useMediaQuery'
|
||||||
|
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
|
||||||
import type { Booking } from '@/types'
|
import type { Booking } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId: number
|
spaceId: number
|
||||||
|
spaceName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
const bookings = ref<Booking[]>([])
|
const bookings = ref<Booking[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref(true)
|
||||||
const modalLoading = ref(false)
|
const modalLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
|
||||||
|
|
||||||
interface ConfirmModal {
|
interface ConfirmModal {
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -84,8 +103,25 @@ const confirmModal = ref<ConfirmModal>({
|
|||||||
revertFunc: null
|
revertFunc: null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin can edit, users see read-only
|
// Admin/superadmin/manager can edit, users see read-only
|
||||||
const isEditable = computed(() => authStore.user?.role === 'admin')
|
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
|
// Status to color mapping
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
@@ -243,13 +279,11 @@ const handleDatesSet = (arg: DatesSetArg) => {
|
|||||||
|
|
||||||
// FullCalendar options
|
// FullCalendar options
|
||||||
const calendarOptions = computed<CalendarOptions>(() => ({
|
const calendarOptions = computed<CalendarOptions>(() => ({
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
|
||||||
initialView: 'dayGridMonth',
|
initialView: isMobile.value ? 'listWeek' : 'dayGridMonth',
|
||||||
headerToolbar: {
|
headerToolbar: isMobile.value
|
||||||
left: 'prev,next today',
|
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
|
||||||
center: 'title',
|
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
|
||||||
},
|
|
||||||
timeZone: userTimezone.value,
|
timeZone: userTimezone.value,
|
||||||
firstDay: 1, // Start week on Monday (0=Sunday, 1=Monday)
|
firstDay: 1, // Start week on Monday (0=Sunday, 1=Monday)
|
||||||
events: events.value,
|
events: events.value,
|
||||||
@@ -262,6 +296,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||||||
dayMaxEvents: true,
|
dayMaxEvents: true,
|
||||||
weekends: true,
|
weekends: true,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
|
noEventsText: 'No bookings this period',
|
||||||
eventTimeFormat: {
|
eventTimeFormat: {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@@ -272,6 +307,20 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: false
|
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
|
// Drag callback
|
||||||
eventDrop: handleEventDrop,
|
eventDrop: handleEventDrop,
|
||||||
// Resize callback
|
// Resize callback
|
||||||
@@ -284,12 +333,20 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Event allow callback
|
// Event allow callback
|
||||||
eventAllow: (dropInfo, draggedEvent) => {
|
eventAllow: (_dropInfo, draggedEvent) => {
|
||||||
// Only allow dragging approved bookings
|
// 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
|
// Public refresh method for parent components
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
if (currentStart && currentEnd) {
|
if (currentStart && currentEnd) {
|
||||||
@@ -501,4 +558,55 @@ defineExpose({ refresh })
|
|||||||
:deep(.fc-event:not(.fc-draggable)) {
|
:deep(.fc-event:not(.fc-draggable)) {
|
||||||
cursor: default;
|
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>
|
</style>
|
||||||
|
|||||||
21
frontend/src/composables/useLocalStorage.ts
Normal file
21
frontend/src/composables/useLocalStorage.ts
Normal 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
|
||||||
|
}
|
||||||
28
frontend/src/composables/useMediaQuery.ts
Normal file
28
frontend/src/composables/useMediaQuery.ts
Normal 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)')
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/VerifyEmail.vue'),
|
component: () => import('@/views/VerifyEmail.vue'),
|
||||||
meta: { requiresAuth: false }
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/book/:propertyId?',
|
||||||
|
name: 'PublicBooking',
|
||||||
|
component: () => import('@/views/PublicBooking.vue'),
|
||||||
|
meta: { requiresAuth: false, isPublic: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
@@ -47,22 +53,44 @@ const router = createRouter({
|
|||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/my-bookings',
|
path: '/history',
|
||||||
name: 'MyBookings',
|
name: 'BookingHistory',
|
||||||
component: () => import('@/views/MyBookings.vue'),
|
component: () => import('@/views/MyBookings.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/my-bookings',
|
||||||
|
redirect: '/history'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
name: 'UserProfile',
|
name: 'UserProfile',
|
||||||
component: () => import('@/views/UserProfile.vue'),
|
component: () => import('@/views/UserProfile.vue'),
|
||||||
meta: { requiresAuth: true }
|
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',
|
path: '/admin',
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
component: () => import('@/views/Admin.vue'),
|
component: () => import('@/views/Admin.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
meta: { requiresAuth: true, requiresManager: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/users',
|
path: '/users',
|
||||||
@@ -78,9 +106,7 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/pending',
|
path: '/admin/pending',
|
||||||
name: 'AdminPending',
|
redirect: '/history?status=pending'
|
||||||
component: () => import('@/views/AdminPending.vue'),
|
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/audit-log',
|
path: '/admin/audit-log',
|
||||||
@@ -101,9 +127,13 @@ const router = createRouter({
|
|||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
if (to.meta.isPublic) {
|
||||||
|
next()
|
||||||
|
} else if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
next('/login')
|
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')
|
next('/dashboard')
|
||||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||||
next('/dashboard')
|
next('/dashboard')
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
Booking,
|
Booking,
|
||||||
BookingCreate,
|
BookingCreate,
|
||||||
BookingUpdate,
|
BookingUpdate,
|
||||||
|
BookingAdminCreate,
|
||||||
BookingTemplate,
|
BookingTemplate,
|
||||||
BookingTemplateCreate,
|
BookingTemplateCreate,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -21,7 +22,13 @@ import type {
|
|||||||
RecurringBookingResult,
|
RecurringBookingResult,
|
||||||
SpaceUsageReport,
|
SpaceUsageReport,
|
||||||
TopUsersReport,
|
TopUsersReport,
|
||||||
ApprovalRateReport
|
ApprovalRateReport,
|
||||||
|
Property,
|
||||||
|
PropertySettings,
|
||||||
|
PropertyAccess,
|
||||||
|
Organization,
|
||||||
|
OrganizationMember,
|
||||||
|
AnonymousBookingCreate
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@@ -119,8 +126,8 @@ export const usersApi = {
|
|||||||
|
|
||||||
// Spaces API
|
// Spaces API
|
||||||
export const spacesApi = {
|
export const spacesApi = {
|
||||||
list: async (): Promise<Space[]> => {
|
list: async (params?: { property_id?: number }): Promise<Space[]> => {
|
||||||
const response = await api.get<Space[]>('/spaces')
|
const response = await api.get<Space[]>('/spaces', { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -190,12 +197,36 @@ export const bookingsApi = {
|
|||||||
createRecurring: async (data: RecurringBookingCreate): Promise<RecurringBookingResult> => {
|
createRecurring: async (data: RecurringBookingCreate): Promise<RecurringBookingResult> => {
|
||||||
const response = await api.post<RecurringBookingResult>('/bookings/recurring', data)
|
const response = await api.post<RecurringBookingResult>('/bookings/recurring', data)
|
||||||
return response.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
|
// Admin Bookings API
|
||||||
export const adminBookingsApi = {
|
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 })
|
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
@@ -221,6 +252,16 @@ export const adminBookingsApi = {
|
|||||||
): Promise<Booking> => {
|
): Promise<Booking> => {
|
||||||
const response = await api.put<Booking>(`/admin/bookings/${id}/reschedule`, data)
|
const response = await api.put<Booking>(`/admin/bookings/${id}/reschedule`, data)
|
||||||
return response.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 }> => {
|
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
|
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
|
// Helper to handle API errors
|
||||||
export const handleApiError = (error: unknown): string => {
|
export const handleApiError = (error: unknown): string => {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
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 login = async (credentials: LoginRequest) => {
|
||||||
const response = await authApi.login(credentials)
|
const response = await authApi.login(credentials)
|
||||||
token.value = response.access_token
|
token.value = response.access_token
|
||||||
localStorage.setItem('token', response.access_token)
|
localStorage.setItem('token', response.access_token)
|
||||||
|
|
||||||
// Fetch user data from API
|
|
||||||
user.value = await usersApi.me()
|
user.value = await usersApi.me()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,13 +27,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize user from token on page load
|
|
||||||
const initFromToken = async () => {
|
const initFromToken = async () => {
|
||||||
if (token.value) {
|
if (token.value) {
|
||||||
try {
|
try {
|
||||||
user.value = await usersApi.me()
|
user.value = await usersApi.me()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Invalid token
|
|
||||||
logout()
|
logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isSuperadmin,
|
||||||
|
isManager,
|
||||||
|
isAdminOrManager,
|
||||||
login,
|
login,
|
||||||
logout
|
logout
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/stores/property.ts
Normal file
55
frontend/src/stores/property.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -46,6 +46,8 @@ export interface Space {
|
|||||||
capacity: number
|
capacity: number
|
||||||
description?: string
|
description?: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
property_id?: number | null
|
||||||
|
property_name?: string
|
||||||
working_hours_start?: number | null
|
working_hours_start?: number | null
|
||||||
working_hours_end?: number | null
|
working_hours_end?: number | null
|
||||||
min_duration_minutes?: number | null
|
min_duration_minutes?: number | null
|
||||||
@@ -55,7 +57,7 @@ export interface Space {
|
|||||||
export interface Booking {
|
export interface Booking {
|
||||||
id: number
|
id: number
|
||||||
space_id: number
|
space_id: number
|
||||||
user_id: number
|
user_id?: number | null
|
||||||
start_datetime: string
|
start_datetime: string
|
||||||
end_datetime: string
|
end_datetime: string
|
||||||
title: string
|
title: string
|
||||||
@@ -64,6 +66,10 @@ export interface Booking {
|
|||||||
created_at: string
|
created_at: string
|
||||||
space?: Space
|
space?: Space
|
||||||
user?: User
|
user?: User
|
||||||
|
guest_name?: string
|
||||||
|
guest_email?: string
|
||||||
|
guest_organization?: string
|
||||||
|
is_anonymous?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -91,6 +97,15 @@ export interface BookingUpdate {
|
|||||||
end_datetime?: string // ISO format
|
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 {
|
export interface Notification {
|
||||||
id: number
|
id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
@@ -221,3 +236,78 @@ export interface ApprovalRateReport {
|
|||||||
rejection_rate: number
|
rejection_rate: number
|
||||||
date_range: { start: string | null; end: string | null }
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,3 +131,39 @@ export const isoToLocalDateTime = (isoDateTime: string, timezone: string = 'UTC'
|
|||||||
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
||||||
return `${year}-${month}-${day}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,61 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin">
|
<div class="admin">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<div class="page-header">
|
<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">
|
<button class="btn btn-primary" @click="openCreateModal">
|
||||||
<Plus :size="16" />
|
<Plus :size="16" />
|
||||||
Create New Space
|
New Space
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Spaces List -->
|
<!-- Stats Pills -->
|
||||||
<CollapsibleSection title="All Spaces" :icon="Building2">
|
<div class="stats-pills">
|
||||||
<div v-if="loadingSpaces" class="loading">Loading spaces...</div>
|
<span class="stat-pill stat-pill-primary">
|
||||||
<div v-else-if="spaces.length === 0" class="empty">
|
<span class="stat-pill-number">{{ spaces.length }}</span>
|
||||||
No spaces created yet. Create one above!
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
|
<div class="space-card-actions">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
title="Edit space"
|
||||||
|
@click="startEdit(space)"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<Pencil :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['icon-btn', space.is_active ? 'icon-btn-warning' : 'icon-btn-success']"
|
||||||
|
:title="space.is_active ? 'Deactivate' : 'Activate'"
|
||||||
|
@click="toggleStatus(space)"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<Power :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
<div v-else class="table-responsive">
|
</div>
|
||||||
<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>
|
|
||||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
|
||||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-secondary"
|
|
||||||
@click="startEdit(space)"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-sm', space.is_active ? 'btn-warning' : 'btn-success']"
|
|
||||||
@click="toggleStatus(space)"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
{{ space.is_active ? 'Deactivate' : 'Activate' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
|
|
||||||
<!-- Create/Edit Space Modal -->
|
<!-- Create/Edit Space Modal -->
|
||||||
<div v-if="showModal" class="modal" @click.self="closeModal">
|
<div v-if="showModal" class="modal" @click.self="closeModal">
|
||||||
@@ -73,6 +103,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="type">Type *</label>
|
<label for="type">Type *</label>
|
||||||
<select id="type" v-model="formData.type" required>
|
<select id="type" v-model="formData.type" required>
|
||||||
@@ -179,14 +218,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { spacesApi, handleApiError } from '@/services/api'
|
import { spacesApi, propertiesApi, handleApiError } from '@/services/api'
|
||||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Building2, Plus } from 'lucide-vue-next'
|
import { usePropertyStore } from '@/stores/property'
|
||||||
import type { Space } from '@/types'
|
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 spaces = ref<Space[]>([])
|
||||||
|
const availableProperties = ref<Property[]>([])
|
||||||
const loadingSpaces = ref(false)
|
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 loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const success = ref('')
|
const success = ref('')
|
||||||
@@ -198,12 +250,23 @@ const formData = ref({
|
|||||||
type: 'sala',
|
type: 'sala',
|
||||||
capacity: 1,
|
capacity: 1,
|
||||||
description: '',
|
description: '',
|
||||||
|
property_id: null as number | null,
|
||||||
working_hours_start: null as number | null,
|
working_hours_start: null as number | null,
|
||||||
working_hours_end: null as number | null,
|
working_hours_end: null as number | null,
|
||||||
min_duration_minutes: null as number | null,
|
min_duration_minutes: null as number | null,
|
||||||
max_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 () => {
|
const loadSpaces = async () => {
|
||||||
loadingSpaces.value = true
|
loadingSpaces.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -246,6 +309,12 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
resetForm()
|
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
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +325,7 @@ const startEdit = (space: Space) => {
|
|||||||
type: space.type,
|
type: space.type,
|
||||||
capacity: space.capacity,
|
capacity: space.capacity,
|
||||||
description: space.description || '',
|
description: space.description || '',
|
||||||
|
property_id: space.property_id ?? null,
|
||||||
working_hours_start: space.working_hours_start ?? null,
|
working_hours_start: space.working_hours_start ?? null,
|
||||||
working_hours_end: space.working_hours_end ?? null,
|
working_hours_end: space.working_hours_end ?? null,
|
||||||
min_duration_minutes: space.min_duration_minutes ?? null,
|
min_duration_minutes: space.min_duration_minutes ?? null,
|
||||||
@@ -279,7 +349,8 @@ const resetForm = () => {
|
|||||||
working_hours_start: null,
|
working_hours_start: null,
|
||||||
working_hours_end: null,
|
working_hours_end: null,
|
||||||
min_duration_minutes: 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(() => {
|
onMounted(() => {
|
||||||
loadSpaces()
|
loadSpaces()
|
||||||
|
loadProperties()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -320,9 +392,291 @@ onMounted(() => {
|
|||||||
|
|
||||||
.page-header h2 {
|
.page-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--color-text-primary);
|
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 {
|
.space-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -355,12 +709,6 @@ onMounted(() => {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -398,65 +746,6 @@ onMounted(() => {
|
|||||||
margin-top: 8px;
|
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 {
|
.error {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
@@ -473,69 +762,7 @@ onMounted(() => {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
/* Modal */
|
||||||
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;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -551,8 +778,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-lg);
|
||||||
padding: 24px;
|
padding: 28px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
@@ -566,14 +793,30 @@ onMounted(() => {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.page-header {
|
.page-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.space-cards {
|
||||||
flex-direction: column;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin-reports">
|
<div class="admin-reports">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<h2>Booking Reports</h2>
|
<h2>Booking Reports</h2>
|
||||||
|
|
||||||
<!-- Date Range Filter -->
|
<!-- Date Range Filter -->
|
||||||
@@ -150,10 +151,17 @@
|
|||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { reportsApi } from '@/services/api'
|
import { reportsApi } from '@/services/api'
|
||||||
import Chart from 'chart.js/auto'
|
import Chart from 'chart.js/auto'
|
||||||
|
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
||||||
import { CalendarDays } from 'lucide-vue-next'
|
import { CalendarDays } from 'lucide-vue-next'
|
||||||
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
|
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard' },
|
||||||
|
{ label: 'Admin', to: '/admin' },
|
||||||
|
{ label: 'Reports' }
|
||||||
|
]
|
||||||
|
|
||||||
const activeTab = ref('usage')
|
const activeTab = ref('usage')
|
||||||
const startDate = ref('')
|
const startDate = ref('')
|
||||||
const endDate = ref('')
|
const endDate = ref('')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="audit-log">
|
<div class="audit-log">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<h2>Jurnal Acțiuni Administrative</h2>
|
<h2>Jurnal Acțiuni Administrative</h2>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -77,10 +78,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { auditLogApi } from '@/services/api'
|
import { auditLogApi } from '@/services/api'
|
||||||
|
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
import type { AuditLog } from '@/types'
|
import type { AuditLog } from '@/types'
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard' },
|
||||||
|
{ label: 'Admin', to: '/admin' },
|
||||||
|
{ label: 'Audit Log' }
|
||||||
|
]
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
const logs = ref<AuditLog[]>([])
|
const logs = ref<AuditLog[]>([])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
469
frontend/src/views/Organization.vue
Normal file
469
frontend/src/views/Organization.vue
Normal 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>
|
||||||
630
frontend/src/views/Properties.vue
Normal file
630
frontend/src/views/Properties.vue
Normal 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>
|
||||||
1096
frontend/src/views/PropertyDetail.vue
Normal file
1096
frontend/src/views/PropertyDetail.vue
Normal file
File diff suppressed because it is too large
Load Diff
493
frontend/src/views/PublicBooking.vue
Normal file
493
frontend/src/views/PublicBooking.vue
Normal 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">✓</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>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<h2>Global Booking Settings</h2>
|
<h2>Global Booking Settings</h2>
|
||||||
|
|
||||||
<!-- Settings Form -->
|
<!-- Settings Form -->
|
||||||
@@ -124,10 +125,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { settingsApi, handleApiError } from '@/services/api'
|
import { settingsApi, handleApiError } from '@/services/api'
|
||||||
|
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
||||||
import { Sliders, Info } from 'lucide-vue-next'
|
import { Sliders, Info } from 'lucide-vue-next'
|
||||||
import type { Settings } from '@/types'
|
import type { Settings } from '@/types'
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard' },
|
||||||
|
{ label: 'Admin', to: '/admin' },
|
||||||
|
{ label: 'Settings' }
|
||||||
|
]
|
||||||
|
|
||||||
const loadingSettings = ref(true)
|
const loadingSettings = ref(true)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-detail">
|
<div class="space-detail">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<nav class="breadcrumbs">
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading">
|
<div v-if="loading" class="loading">
|
||||||
@@ -41,14 +35,25 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="header-actions">
|
||||||
class="btn btn-primary btn-reserve"
|
<button
|
||||||
:disabled="!space.is_active"
|
class="btn btn-primary btn-reserve"
|
||||||
@click="handleReserve"
|
:disabled="!space.is_active"
|
||||||
>
|
@click="handleReserve"
|
||||||
<Plus :size="18" />
|
>
|
||||||
{{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }}
|
<Plus :size="18" />
|
||||||
</button>
|
{{ 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>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -61,7 +66,82 @@
|
|||||||
<div class="card calendar-card">
|
<div class="card calendar-card">
|
||||||
<h3>Availability Calendar</h3>
|
<h3>Availability Calendar</h3>
|
||||||
<p class="calendar-subtitle">View existing bookings and available time slots</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,26 +156,179 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
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 SpaceCalendar from '@/components/SpaceCalendar.vue'
|
||||||
import BookingForm from '@/components/BookingForm.vue'
|
import BookingForm from '@/components/BookingForm.vue'
|
||||||
import { Users, Plus } from 'lucide-vue-next'
|
import AdminBookingForm from '@/components/AdminBookingForm.vue'
|
||||||
import type { Space } from '@/types'
|
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 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 space = ref<Space | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showBookingForm = ref(false)
|
const showBookingForm = ref(false)
|
||||||
|
const showAdminBookingForm = ref(false)
|
||||||
const calendarRef = ref<InstanceType<typeof SpaceCalendar> | null>(null)
|
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
|
// Format space type for display
|
||||||
const formatType = (type: string): string => {
|
const formatType = (type: string): string => {
|
||||||
const typeMap: Record<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.'
|
error.value = 'Space not found (404). The space may not exist or has been removed.'
|
||||||
} else {
|
} else {
|
||||||
space.value = foundSpace
|
space.value = foundSpace
|
||||||
|
loadSpaceBookings()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = handleApiError(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
|
// Handle reserve button click
|
||||||
const handleReserve = () => {
|
const handleReserve = () => {
|
||||||
showBookingForm.value = !showBookingForm.value
|
showBookingForm.value = !showBookingForm.value
|
||||||
@@ -147,7 +404,141 @@ const closeBookingModal = () => {
|
|||||||
// Handle booking form submit
|
// Handle booking form submit
|
||||||
const handleBookingSubmit = () => {
|
const handleBookingSubmit = () => {
|
||||||
showBookingForm.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
@@ -156,36 +547,6 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 State */
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -335,8 +696,14 @@ onMounted(() => {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-reserve {
|
.btn-reserve {
|
||||||
min-width: 180px;
|
min-width: 160px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +736,264 @@ onMounted(() => {
|
|||||||
margin-bottom: 20px;
|
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 */
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -413,31 +1038,6 @@ onMounted(() => {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.fc .fc-toolbar) {
|
/* Calendar mobile styles handled by SpaceCalendar component */
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="spaces">
|
<div class="spaces">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<div class="spaces-header">
|
<div class="spaces-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Available Spaces</h2>
|
<h2>Available Spaces</h2>
|
||||||
@@ -57,9 +58,12 @@
|
|||||||
>
|
>
|
||||||
<div class="space-card-header">
|
<div class="space-card-header">
|
||||||
<h3>{{ space.name }}</h3>
|
<h3>{{ space.name }}</h3>
|
||||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
<div class="header-badges">
|
||||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
<span v-if="space.property_name" class="badge badge-property">{{ space.property_name }}</span>
|
||||||
</span>
|
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||||
|
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-card-body">
|
<div class="space-card-body">
|
||||||
@@ -84,6 +88,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="space-card-footer">
|
||||||
<button class="btn btn-secondary">
|
<button class="btn btn-secondary">
|
||||||
View Details
|
View Details
|
||||||
@@ -98,17 +120,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { spacesApi, handleApiError } from '@/services/api'
|
import { spacesApi, bookingsApi, handleApiError } from '@/services/api'
|
||||||
import { Building2, Tag, Users, ChevronRight, MapPin } from 'lucide-vue-next'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { Space } from '@/types'
|
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 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 spaces = ref<Space[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const selectedType = ref('')
|
const selectedType = ref('')
|
||||||
const selectedStatus = ref('')
|
const selectedStatus = ref('')
|
||||||
|
const spaceBookings = ref<Map<number, Booking[]>>(new Map())
|
||||||
|
|
||||||
// Format space type for display
|
// Format space type for display
|
||||||
const formatType = (type: string): string => {
|
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
|
// Load spaces from API
|
||||||
const loadSpaces = async () => {
|
const loadSpaces = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -151,6 +222,8 @@ const loadSpaces = async () => {
|
|||||||
try {
|
try {
|
||||||
const data = await spacesApi.list()
|
const data = await spacesApi.list()
|
||||||
spaces.value = data
|
spaces.value = data
|
||||||
|
// Load bookings preview in background (non-blocking)
|
||||||
|
loadSpaceBookings(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = handleApiError(err)
|
error.value = handleApiError(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -361,6 +434,18 @@ onMounted(() => {
|
|||||||
color: var(--color-danger);
|
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 {
|
.space-card-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -402,6 +487,58 @@ onMounted(() => {
|
|||||||
margin-top: 12px;
|
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 {
|
.space-card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-profile">
|
<div class="user-profile">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<h2>User Profile</h2>
|
<h2>User Profile</h2>
|
||||||
|
|
||||||
<!-- Profile Information Card -->
|
<!-- Profile Information Card -->
|
||||||
@@ -74,9 +75,18 @@
|
|||||||
Your approved bookings will automatically sync to your Google Calendar.
|
Your approved bookings will automatically sync to your Google Calendar.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
|
<div class="button-group">
|
||||||
{{ disconnecting ? 'Disconnecting...' : 'Disconnect Google Calendar' }}
|
<button @click="syncGoogle" class="btn btn-primary" :disabled="syncing">
|
||||||
</button>
|
{{ 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>
|
||||||
|
|
||||||
<div v-else class="google-disconnected">
|
<div v-else class="google-disconnected">
|
||||||
@@ -125,10 +135,16 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
|
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
|
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
||||||
import { User as UserIcon, Globe, CalendarDays, CheckCircle, Info } from 'lucide-vue-next'
|
import { User as UserIcon, Globe, CalendarDays, CheckCircle, Info } from 'lucide-vue-next'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard' },
|
||||||
|
{ label: 'Profile' }
|
||||||
|
]
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
|
|
||||||
@@ -136,6 +152,8 @@ const user = ref<User | null>(null)
|
|||||||
const loadingGoogleStatus = ref(true)
|
const loadingGoogleStatus = ref(true)
|
||||||
const connecting = ref(false)
|
const connecting = ref(false)
|
||||||
const disconnecting = 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 error = ref('')
|
||||||
const success = 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 => {
|
const formatDate = (dateString: string): string => {
|
||||||
return formatDateTimeUtil(dateString, userTimezone.value)
|
return formatDateTimeUtil(dateString, userTimezone.value)
|
||||||
}
|
}
|
||||||
@@ -420,6 +459,20 @@ h2 {
|
|||||||
cursor: not-allowed;
|
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 {
|
.error {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="users">
|
<div class="users">
|
||||||
|
<Breadcrumb :items="breadcrumbItems" />
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Admin Dashboard - User Management</h2>
|
<h2>User Management</h2>
|
||||||
<button class="btn btn-primary" @click="openCreateModal">
|
<button class="btn btn-primary" @click="openCreateModal">
|
||||||
<UserPlus :size="16" />
|
<UserPlus :size="16" />
|
||||||
Create New User
|
Create New User
|
||||||
@@ -15,7 +16,8 @@
|
|||||||
<label for="filter-role">Filter by Role</label>
|
<label for="filter-role">Filter by Role</label>
|
||||||
<select id="filter-role" v-model="filterRole" @change="loadUsers">
|
<select id="filter-role" v-model="filterRole" @change="loadUsers">
|
||||||
<option value="">All Roles</option>
|
<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>
|
<option value="user">User</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,8 +58,8 @@
|
|||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
<td>{{ user.full_name }}</td>
|
<td>{{ user.full_name }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['badge', user.role === 'admin' ? 'badge-admin' : 'badge-user']">
|
<span :class="['badge', user.role === 'admin' || user.role === 'superadmin' ? 'badge-admin' : user.role === 'manager' ? 'badge-manager' : 'badge-user']">
|
||||||
{{ user.role }}
|
{{ user.role === 'admin' ? 'superadmin' : user.role }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.organization || '-' }}</td>
|
<td>{{ user.organization || '-' }}</td>
|
||||||
@@ -139,7 +141,8 @@
|
|||||||
<label for="role">Role *</label>
|
<label for="role">Role *</label>
|
||||||
<select id="role" v-model="formData.role" required>
|
<select id="role" v-model="formData.role" required>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="manager">Manager</option>
|
||||||
|
<option value="admin">Superadmin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,10 +204,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { usersApi, handleApiError } from '@/services/api'
|
import { usersApi, handleApiError } from '@/services/api'
|
||||||
|
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
||||||
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
|
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard' },
|
||||||
|
{ label: 'Admin', to: '/admin' },
|
||||||
|
{ label: 'Users' }
|
||||||
|
]
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const loadingUsers = ref(false)
|
const loadingUsers = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -260,7 +270,8 @@ const handleSubmit = async () => {
|
|||||||
full_name: formData.value.full_name,
|
full_name: formData.value.full_name,
|
||||||
password: formData.value.password,
|
password: formData.value.password,
|
||||||
role: formData.value.role,
|
role: formData.value.role,
|
||||||
organization: formData.value.organization || undefined
|
organization: formData.value.organization || undefined,
|
||||||
|
timezone: 'UTC'
|
||||||
})
|
})
|
||||||
success.value = 'User created successfully!'
|
success.value = 'User created successfully!'
|
||||||
}
|
}
|
||||||
@@ -381,6 +392,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
.page-header h2 {
|
.page-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +598,11 @@ onMounted(() => {
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-manager {
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
.badge-user {
|
.badge-user {
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
@@ -611,15 +629,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-lg);
|
||||||
padding: 24px;
|
padding: 28px;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h3 {
|
.modal-content h3 {
|
||||||
margin-bottom: 16px;
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user