feat: complete UI/UX overhaul - dashboard unification, calendar UX, mobile optimization

- Dashboard redesign as command center with filters, quick actions, inline approve/reject
- Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal
- Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle
- Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout
- Collapsible filters with active count badge
- Smart menu positioning with Teleport
- Calendar/list bidirectional data sync
- Navigation: unified History page, removed AdminPending
- Google Calendar OAuth integration
- Dark mode contrast improvements, breadcrumb navigation
- useLocalStorage composable for state persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-12 15:34:47 +00:00
parent a4d3f862d2
commit d245c72757
36 changed files with 5275 additions and 1569 deletions

View File

@@ -18,7 +18,10 @@ SMTP_PASSWORD=
SMTP_FROM_ADDRESS=noreply@space-booking.local
SMTP_ENABLED=false
# Frontend URL (used for OAuth callback redirects)
FRONTEND_URL=http://localhost:5173
# Google Calendar Integration
GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
GOOGLE_REDIRECT_URI=https://your-domain.com/api/integrations/google/callback
GOOGLE_REDIRECT_URI=http://localhost:8000/api/integrations/google/callback

View File

@@ -68,9 +68,10 @@ def get_space_bookings(
detail="Space not found",
)
# Query bookings in the time range
# Query bookings in the time range (only active bookings)
query = db.query(Booking).filter(
Booking.space_id == space_id,
Booking.status.in_(["approved", "pending"]),
Booking.start_datetime < end,
Booking.end_datetime > start,
)
@@ -292,6 +293,9 @@ def create_booking(
detail=errors[0], # Return first error
)
# Auto-approve if admin, otherwise pending
is_admin = current_user.role == "admin"
# Create booking (with UTC times)
booking = Booking(
user_id=user_id,
@@ -300,7 +304,8 @@ def create_booking(
end_datetime=end_datetime_utc,
title=booking_data.title,
description=booking_data.description,
status="pending",
status="approved" if is_admin else "pending",
approved_by=current_user.id if is_admin else None, # type: ignore[assignment]
created_at=datetime.utcnow(),
)
@@ -308,26 +313,27 @@ def create_booking(
db.commit()
db.refresh(booking)
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
for admin in admins:
create_notification(
db=db,
user_id=admin.id, # type: ignore[arg-type]
type="booking_created",
title="Noua Cerere de Rezervare",
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
# Send email notification to admin
background_tasks.add_task(
send_booking_notification,
booking,
"created",
admin.email,
current_user.full_name,
None,
)
if not is_admin:
# Notify all admins about the new booking request
admins = db.query(User).filter(User.role == "admin").all()
for admin in admins:
create_notification(
db=db,
user_id=admin.id, # type: ignore[arg-type]
type="booking_created",
title="Noua Cerere de Rezervare",
message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}",
booking_id=booking.id,
)
# Send email notification to admin
background_tasks.add_task(
send_booking_notification,
booking,
"created",
admin.email,
current_user.full_name,
None,
)
# Return with timezone conversion
return BookingResponse.from_booking_with_timezone(booking, user_timezone)
@@ -637,6 +643,56 @@ def cancel_booking(
admin_router = APIRouter(prefix="/admin/bookings", tags=["admin"])
@admin_router.get("/all", response_model=list[BookingPendingDetail])
def get_all_bookings(
status_filter: Annotated[str | None, Query(alias="status")] = None,
space_id: Annotated[int | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment]
) -> list[BookingPendingDetail]:
"""
Get all bookings across all users (admin only).
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
- **start** (optional): Only bookings starting from this datetime
- **limit** (optional): Max results (1-100, default 20)
"""
query = (
db.query(Booking)
.join(Space, Booking.space_id == Space.id)
.join(User, Booking.user_id == User.id)
)
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:
# Use end_datetime to include bookings still in progress (started but not ended)
query = query.filter(Booking.end_datetime > start)
bookings = (
query.order_by(Booking.start_datetime.asc())
.limit(limit)
.all()
)
return [BookingPendingDetail.model_validate(b) for b in bookings]
@admin_router.get("/pending", response_model=list[BookingPendingDetail])
def get_pending_bookings(
space_id: Annotated[int | None, Query()] = None,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -117,7 +117,7 @@ const handleNotificationClick = async (notification: Notification) => {
if (notification.booking_id) {
closeNotifications()
router.push('/my-bookings')
router.push('/history')
}
}

View File

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

View File

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

View File

@@ -26,6 +26,26 @@
: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>
@@ -33,7 +53,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { Zap } from 'lucide-vue-next'
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'
@@ -43,6 +63,11 @@ const props = defineProps<{
bookings: Booking[]
}>()
defineEmits<{
cancel: [booking: Booking]
refresh: []
}>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
@@ -175,6 +200,50 @@ onUnmounted(() => {
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 {

View File

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

View File

@@ -1,13 +1,13 @@
<template>
<aside class="sidebar" :class="{ collapsed, 'mobile-open': mobileOpen }">
<div class="sidebar-header">
<div class="sidebar-header" @click="handleHeaderClick" :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
<LayoutDashboard :size="24" class="sidebar-logo-icon" />
<span v-show="!collapsed" class="sidebar-title">Space Booking</span>
<span v-show="showLabels" class="sidebar-title">Space Booking</span>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span v-show="!collapsed" class="nav-section-label">Main</span>
<span v-show="showLabels" class="nav-section-label">Main</span>
<router-link
v-for="item in mainNav"
:key="item.to"
@@ -17,12 +17,12 @@
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
<div v-if="authStore.isAdmin" class="nav-section">
<span v-show="!collapsed" class="nav-section-label">Admin</span>
<span v-show="showLabels" class="nav-section-label">Admin</span>
<router-link
v-for="item in adminNav"
:key="item.to"
@@ -32,13 +32,13 @@
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
<span v-show="showLabels" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
</nav>
<div class="sidebar-footer">
<div v-show="!collapsed" class="user-info">
<div v-show="showLabels" class="user-info">
<div class="user-avatar">
{{ authStore.user?.email?.charAt(0).toUpperCase() }}
</div>
@@ -79,7 +79,6 @@ import {
User,
Settings2,
Users,
ClipboardCheck,
Sliders,
BarChart3,
ScrollText,
@@ -96,6 +95,9 @@ const router = useRouter()
const { collapsed, mobileOpen, toggle, closeMobile } = useSidebar()
const { theme, resolvedTheme, toggleTheme } = useTheme()
// On mobile, always show labels when sidebar is open (even if collapsed on desktop)
const showLabels = computed(() => !collapsed.value || mobileOpen.value)
const themeTitle = computed(() => {
if (theme.value === 'light') return 'Switch to dark mode'
if (theme.value === 'dark') return 'Switch to auto mode'
@@ -105,14 +107,13 @@ const themeTitle = computed(() => {
const mainNav = [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/spaces', icon: Building2, label: 'Spaces' },
{ to: '/my-bookings', icon: CalendarDays, label: 'My Bookings' },
{ to: '/history', icon: CalendarDays, label: 'History' },
{ to: '/profile', icon: User, label: 'Profile' },
]
const adminNav = [
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
{ to: '/users', icon: Users, label: 'Users' },
{ to: '/admin/pending', icon: ClipboardCheck, label: 'Pending' },
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },
{ to: '/admin/audit-log', icon: ScrollText, label: 'Audit Log' },
@@ -123,6 +124,13 @@ const isActive = (path: string) => {
return route.path.startsWith(path)
}
const handleHeaderClick = () => {
// Only toggle on desktop (≥768px)
if (window.innerWidth >= 768) {
toggle()
}
}
const handleLogout = () => {
authStore.logout()
router.push('/login')
@@ -166,6 +174,12 @@ const handleLogout = () => {
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
min-height: 60px;
cursor: pointer;
transition: background var(--transition-fast);
}
.sidebar-header:hover {
background: var(--sidebar-hover-bg);
}
.sidebar-logo-icon {
@@ -323,6 +337,14 @@ const handleLogout = () => {
width: var(--sidebar-width);
}
.sidebar-header {
cursor: default;
}
.sidebar-header:hover {
background: transparent;
}
.desktop-only {
display: none;
}

View File

@@ -0,0 +1,329 @@
<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, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { isoToLocalDateTime, localDateTimeToISO } from '@/utils/datetime'
import type { Booking } from '@/types'
const props = defineProps<{
booking: Booking | null
show: boolean
}>()
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}`
await bookingsApi.update(props.booking.id, {
title: editForm.value.title,
description: editForm.value.description,
start_datetime: localDateTimeToISO(startDateTime),
end_datetime: localDateTimeToISO(endDateTime)
})
emit('saved')
} catch (err) {
editError.value = handleApiError(err)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.edit-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.edit-modal {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.edit-modal h3 {
margin: 0 0 20px;
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group > label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.sublabel {
display: block;
margin-bottom: 4px;
font-weight: 400;
font-size: 12px;
color: var(--color-text-secondary);
}
.datetime-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.datetime-field {
display: flex;
flex-direction: column;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text-primary);
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-group input:disabled {
background: var(--color-bg-tertiary);
cursor: not-allowed;
}
.form-group textarea {
resize: vertical;
}
.error-msg {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
/* Modal transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .edit-modal,
.modal-fade-leave-active .edit-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .edit-modal,
.modal-fade-leave-to .edit-modal {
transform: scale(0.95);
opacity: 0;
}
@media (max-width: 640px) {
.edit-modal {
max-width: none;
width: calc(100% - 32px);
margin: 16px;
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,217 @@
<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' }}</div>
<div v-if="showUser && booking.user" class="row-user">
{{ booking.user.full_name }}
</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;
}
.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);
}
@media (max-width: 640px) {
.booking-row {
flex-wrap: wrap;
gap: 6px 10px;
padding: 10px 12px;
}
.row-time {
min-width: auto;
}
.row-space {
min-width: auto;
}
.row-user {
min-width: auto;
max-width: none;
}
.row-title {
flex-basis: 100%;
order: 10;
}
}
</style>

View File

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

View File

@@ -1,8 +1,51 @@
<template>
<div class="dashboard-calendar">
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading" class="loading">Loading calendar...</div>
<FullCalendar ref="calendarRef" v-show="!loading" :options="calendarOptions" />
<div class="calendar-wrapper" :class="{ 'calendar-loading': loading }">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div v-if="loading && !confirmModal.show" class="loading-overlay">Loading calendar...</div>
</div>
<!-- Reschedule Confirmation Modal -->
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
<div class="modal-content">
<h3>Confirm Reschedule</h3>
<p>Reschedule this booking?</p>
<div class="time-comparison">
<div class="old-time">
<strong>Old Time:</strong><br />
{{ formatModalDateTime(confirmModal.oldStart) }} {{ formatModalDateTime(confirmModal.oldEnd) }}
</div>
<div class="arrow">&rarr;</div>
<div class="new-time">
<strong>New Time:</strong><br />
{{ formatModalDateTime(confirmModal.newStart) }} {{ formatModalDateTime(confirmModal.newEnd) }}
</div>
</div>
<div class="modal-actions">
<button @click="confirmReschedule" :disabled="modalLoading" class="btn-primary">
{{ modalLoading ? 'Saving...' : 'Confirm' }}
</button>
<button @click="cancelReschedule" :disabled="modalLoading" class="btn-secondary">
Cancel
</button>
</div>
</div>
</div>
<!-- Booking Preview Modal -->
<BookingPreviewModal
:booking="selectedBooking"
:is-admin="isAdmin"
:show="showPreview"
@close="showPreview = false"
@approve="handlePreviewApprove"
@reject="handlePreviewReject"
@cancel="handlePreviewCancel"
@edit="handlePreviewEdit"
/>
</div>
</template>
@@ -11,20 +54,67 @@ 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, CalendarApi } from '@fullcalendar/core'
import { bookingsApi, handleApiError } from '@/services/api'
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')
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',
@@ -47,17 +137,6 @@ const events = computed<EventInput[]>(() => {
}))
})
// Watch events and update FullCalendar
watch(events, (newEvents) => {
nextTick(() => {
const calendarApi = calendarRef.value?.getApi()
if (calendarApi) {
calendarApi.removeAllEvents()
calendarApi.addEventSource(newEvents)
}
})
})
let currentStart: Date | null = null
let currentEnd: Date | null = null
@@ -69,7 +148,15 @@ const loadBookings = async (start: Date, end: Date) => {
try {
const startStr = start.toISOString()
const endStr = end.toISOString()
bookings.value = await bookingsApi.getMyCalendar(startStr, endStr)
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 {
@@ -84,22 +171,137 @@ const handleDatesSet = (arg: DatesSetArg) => {
loadBookings(arg.start, arg.end)
}
const calendarOptions: CalendarOptions = {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek'
},
// 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: events.value,
datesSet: handleDatesSet,
editable: false,
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',
@@ -109,8 +311,32 @@ const calendarOptions: CalendarOptions = {
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) {
@@ -141,10 +367,138 @@ defineExpose({ refresh })
margin-bottom: 16px;
}
.loading {
text-align: center;
.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 */
@@ -179,10 +533,74 @@ defineExpose({ refresh })
}
:deep(.fc-event) {
cursor: default;
cursor: pointer;
}
:deep(.fc-event-title) {
font-weight: 500;
}
:deep(.fc-event.fc-draggable) {
cursor: move;
}
/* List view theming */
:deep(.fc-list) {
border-color: var(--color-border);
}
:deep(.fc-list-day-cushion) {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
:deep(.fc-list-event td) {
border-color: var(--color-border);
color: var(--color-text-primary);
}
:deep(.fc-list-event:hover td) {
background: var(--color-bg-tertiary);
}
:deep(.fc-list-empty-cushion) {
color: var(--color-text-muted);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.dashboard-calendar {
padding: 12px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1rem;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 4px 8px;
font-size: 0.75rem;
}
.time-comparison {
flex-direction: column;
gap: 8px;
}
.arrow {
transform: rotate(90deg);
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -47,11 +47,15 @@ const router = createRouter({
meta: { requiresAuth: true }
},
{
path: '/my-bookings',
name: 'MyBookings',
path: '/history',
name: 'BookingHistory',
component: () => import('@/views/MyBookings.vue'),
meta: { requiresAuth: true }
},
{
path: '/my-bookings',
redirect: '/history'
},
{
path: '/profile',
name: 'UserProfile',
@@ -78,9 +82,7 @@ const router = createRouter({
},
{
path: '/admin/pending',
name: 'AdminPending',
component: () => import('@/views/AdminPending.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
redirect: '/history?status=pending'
},
{
path: '/admin/audit-log',

View File

@@ -12,6 +12,7 @@ import type {
Booking,
BookingCreate,
BookingUpdate,
BookingAdminCreate,
BookingTemplate,
BookingTemplateCreate,
Notification,
@@ -202,6 +203,17 @@ export const bookingsApi = {
// Admin Bookings API
export const adminBookingsApi = {
getAll: async (params?: {
status?: string
space_id?: number
user_id?: number
start?: string
limit?: number
}): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/all', { params })
return response.data
},
getPending: async (filters?: { space_id?: number; user_id?: number }): Promise<Booking[]> => {
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
return response.data
@@ -228,6 +240,11 @@ export const adminBookingsApi = {
): Promise<Booking> => {
const response = await api.put<Booking>(`/admin/bookings/${id}/reschedule`, data)
return response.data
},
create: async (data: BookingAdminCreate): Promise<Booking> => {
const response = await api.post<Booking>('/admin/bookings', data)
return response.data
}
}
@@ -354,7 +371,14 @@ export const googleCalendarApi = {
},
disconnect: async (): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>('/integrations/google/disconnect')
const response = await api.post<{ message: string }>('/integrations/google/disconnect')
return response.data
},
sync: async (): Promise<{ synced: number; created: number; updated: number; failed: number; total_bookings: number }> => {
const response = await api.post<{ synced: number; created: number; updated: number; failed: number; total_bookings: number }>(
'/integrations/google/sync'
)
return response.data
},

View File

@@ -91,6 +91,15 @@ export interface BookingUpdate {
end_datetime?: string // ISO format
}
export interface BookingAdminCreate {
space_id: number
user_id?: number
start_datetime: string // ISO format
end_datetime: string // ISO format
title: string
description?: string
}
export interface Notification {
id: number
user_id: number

View File

@@ -1,61 +1,82 @@
<template>
<div class="admin">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Admin Dashboard - Space Management</h2>
<h2>Space Management</h2>
<button class="btn btn-primary" @click="openCreateModal">
<Plus :size="16" />
Create New Space
New Space
</button>
</div>
<!-- Spaces List -->
<CollapsibleSection title="All Spaces" :icon="Building2">
<div v-if="loadingSpaces" class="loading">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty">
No spaces created yet. Create one above!
<!-- Stats Pills -->
<div class="stats-pills">
<span class="stat-pill stat-pill-primary">
<span class="stat-pill-number">{{ spaces.length }}</span>
<span class="stat-pill-label">Total Spaces</span>
</span>
<span class="stat-pill stat-pill-success">
<span class="stat-pill-number">{{ activeCount }}</span>
<span class="stat-pill-label">Active</span>
</span>
<span class="stat-pill stat-pill-danger">
<span class="stat-pill-number">{{ inactiveCount }}</span>
<span class="stat-pill-label">Inactive</span>
</span>
</div>
<!-- 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>
</div>
<p v-if="space.description" class="space-card-desc">{{ space.description }}</p>
</div>
<div v-else class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Capacity</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="space in spaces" :key="space.id">
<td>{{ space.name }}</td>
<td>{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</td>
<td>{{ space.capacity }}</td>
<td>
<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>
</div>
<!-- Create/Edit Space Modal -->
<div v-if="showModal" class="modal" @click.self="closeModal">
@@ -179,14 +200,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { spacesApi, handleApiError } from '@/services/api'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Building2, Plus } from 'lucide-vue-next'
import Breadcrumb from '@/components/Breadcrumb.vue'
import { Building2, Plus, Pencil, Power, Users as UsersIcon } from 'lucide-vue-next'
import type { Space } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin' }
]
const spaces = ref<Space[]>([])
const loadingSpaces = ref(false)
const activeCount = computed(() => spaces.value.filter(s => s.is_active).length)
const inactiveCount = computed(() => spaces.value.filter(s => !s.is_active).length)
const loading = ref(false)
const error = ref('')
const success = ref('')
@@ -320,9 +348,273 @@ onMounted(() => {
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
/* 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-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary);
}
.space-card-desc {
margin: 10px 0 0;
font-size: 13px;
color: var(--color-text-muted);
line-height: 1.4;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-inactive {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
/* Form Styles */
.space-form {
display: flex;
flex-direction: column;
@@ -355,12 +647,6 @@ onMounted(() => {
gap: 16px;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
.form-group {
display: flex;
flex-direction: column;
@@ -398,65 +684,6 @@ onMounted(() => {
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-warning {
background: var(--color-warning);
color: white;
}
.btn-warning:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-warning) 85%, black);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
@@ -473,69 +700,7 @@ onMounted(() => {
margin-top: 12px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-inactive {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.actions {
display: flex;
gap: 8px;
}
/* Modal */
.modal {
position: fixed;
top: 0;
@@ -551,8 +716,8 @@ onMounted(() => {
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
border-radius: var(--radius-lg);
padding: 28px;
max-width: 600px;
width: 90%;
max-height: 90vh;
@@ -566,14 +731,30 @@ onMounted(() => {
color: var(--color-text-primary);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-direction: column;
.space-cards {
grid-template-columns: 1fr;
}
.stats-pills {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.stat-pill {
justify-content: center;
}
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,13 +1,7 @@
<template>
<div class="space-detail">
<!-- Breadcrumbs -->
<nav class="breadcrumbs">
<router-link to="/">Home</router-link>
<span class="separator">/</span>
<router-link to="/spaces">Spaces</router-link>
<span class="separator">/</span>
<span class="current">{{ space?.name || 'Loading...' }}</span>
</nav>
<Breadcrumb :items="breadcrumbItems" />
<!-- Loading State -->
<div v-if="loading" class="loading">
@@ -41,14 +35,25 @@
</span>
</div>
</div>
<button
class="btn btn-primary btn-reserve"
:disabled="!space.is_active"
@click="handleReserve"
>
<Plus :size="18" />
{{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }}
</button>
<div class="header-actions">
<button
class="btn btn-primary btn-reserve"
:disabled="!space.is_active"
@click="handleReserve"
>
<Plus :size="18" />
{{ showBookingForm ? 'Cancel' : 'Reserve Space' }}
</button>
<button
v-if="isAdmin"
class="btn btn-secondary btn-reserve"
:disabled="!space.is_active"
@click="showAdminBookingForm = true"
>
<UserPlus :size="18" />
Book for User
</button>
</div>
</div>
<!-- Description -->
@@ -76,24 +81,48 @@
/>
</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>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import SpaceCalendar from '@/components/SpaceCalendar.vue'
import BookingForm from '@/components/BookingForm.vue'
import { Users, Plus } from 'lucide-vue-next'
import AdminBookingForm from '@/components/AdminBookingForm.vue'
import { useAuthStore } from '@/stores/auth'
import { Users, Plus, UserPlus } from 'lucide-vue-next'
import type { Space } from '@/types'
const route = useRoute()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.role === 'admin')
const breadcrumbItems = computed(() => [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Spaces', to: '/spaces' },
{ label: space.value?.name || 'Loading...' }
])
const space = ref<Space | null>(null)
const loading = ref(true)
const error = ref('')
const showBookingForm = ref(false)
const showAdminBookingForm = ref(false)
const calendarRef = ref<InstanceType<typeof SpaceCalendar> | null>(null)
// Format space type for display
@@ -150,42 +179,18 @@ const handleBookingSubmit = () => {
calendarRef.value?.refresh()
}
// Handle admin booking form submit
const handleAdminBookingSubmit = () => {
showAdminBookingForm.value = false
calendarRef.value?.refresh()
}
onMounted(() => {
loadSpace()
})
</script>
<style scoped>
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 14px;
color: var(--color-text-secondary);
}
.breadcrumbs a {
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.breadcrumbs a:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.breadcrumbs .separator {
color: var(--color-text-muted);
}
.breadcrumbs .current {
color: var(--color-text-primary);
font-weight: 500;
}
/* Loading State */
.loading {
display: flex;
@@ -335,8 +340,14 @@ onMounted(() => {
box-shadow: var(--shadow-md);
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn-reserve {
min-width: 180px;
min-width: 160px;
justify-content: center;
}
@@ -413,31 +424,6 @@ onMounted(() => {
font-size: 24px;
}
:deep(.fc .fc-toolbar) {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
:deep(.fc .fc-toolbar-chunk) {
width: 100%;
display: flex;
justify-content: center;
}
:deep(.fc .fc-toolbar-title) {
font-size: 1.2em;
margin: 0;
}
:deep(.fc .fc-button) {
padding: 6px 10px;
font-size: 0.85em;
}
:deep(.fc .fc-col-header-cell) {
font-size: 0.75em;
padding: 4px 2px;
}
/* Calendar mobile styles handled by SpaceCalendar component */
}
</style>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<template>
<div class="users">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>Admin Dashboard - User Management</h2>
<button class="btn btn-primary" @click="openCreateModal">
@@ -201,10 +202,17 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usersApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
import type { User } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Users' }
]
const users = ref<User[]>([])
const loadingUsers = ref(false)
const loading = ref(false)