diff --git a/api.js b/api.js index bfbbbb6..badfc8e 100644 --- a/api.js +++ b/api.js @@ -208,6 +208,16 @@ class ApiService { return data; } + async getSchedule(startDate, endDate) { + return this.request(`/calendar/schedule?start_date=${startDate}&end_date=${endDate}`); + } + + async triggerSync() { + const data = await this.request('/calendar/sync', { method: 'POST' }); + this.notify(); + return data; + } + async addAudit(auditData) { const data = await this.request('/audit', { method: 'POST', diff --git a/app.jsx b/app.jsx index 5ea70ab..885bcf8 100644 --- a/app.jsx +++ b/app.jsx @@ -315,7 +315,10 @@ function App() { onRestore={restoreTask} /> )} - {tab !== 'overview' && tab !== 'deleted' && ( + {tab === 'calendar' && ( + + )} + {tab !== 'overview' && tab !== 'deleted' && tab !== 'calendar' && ( setOpenTaskId(task.id)} diff --git a/backend/cally_database.py b/backend/cally_database.py new file mode 100644 index 0000000..defefdf --- /dev/null +++ b/backend/cally_database.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +# Separate database for Cally to isolate ServiceM8 data +DB_PATH = os.getenv("CALLY_DB_PATH", "./cally.db") +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False, "timeout": 30} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_cally_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/cally_models.py b/backend/cally_models.py new file mode 100644 index 0000000..60d25e0 --- /dev/null +++ b/backend/cally_models.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime +from sqlalchemy.sql import func +from .cally_database import Base + +class Sm8Staff(Base): + __tablename__ = "sm8_staff" + uuid = Column(String, primary_key=True) + name = Column(String, nullable=False) + active = Column(Boolean, default=True) + +class Sm8Job(Base): + __tablename__ = "sm8_jobs" + uuid = Column(String, primary_key=True) + job_id = Column(String, nullable=False) # e.g. #1234 + address = Column(String) + +class ScheduleDay(Base): + __tablename__ = "schedule_days" + id = Column(Integer, primary_key=True, index=True) + staff_uuid = Column(String, nullable=False) + date = Column(Date, nullable=False) + is_busy = Column(Boolean, default=False) + job_count = Column(Integer, default=0) + job_uuids = Column(String) # Comma-separated list of job UUIDs + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/main.py b/backend/main.py index 4602a79..89a14e1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,11 +7,14 @@ from typing import List, Dict import uuid import json import asyncio +from datetime import date as date_type -from . import models, schemas, auth, database +from . import models, schemas, auth, database, cally_models, cally_database, servicem8 from .database import engine, get_db +from .cally_database import engine as cally_engine, get_cally_db models.Base.metadata.create_all(bind=engine) +cally_models.Base.metadata.create_all(bind=cally_engine) class EventNotifier: def __init__(self): @@ -310,6 +313,52 @@ async def update_workspace(ws_update: schemas.WorkspaceUpdate, db: Session = Dep await manager.broadcast(json.dumps({"type": "refresh"})) return ws +@app.get("/calendar/schedule", response_model=schemas.CalendarScheduleResponse) +def get_calendar_schedule( + start_date: date_type, + end_date: date_type, + db: Session = Depends(get_cally_db), + current_user: models.User = Depends(auth.get_current_user) +): + try: + staff = db.query(cally_models.Sm8Staff).filter(cally_models.Sm8Staff.active == True).all() + + # If no staff, trigger an initial sync + if not staff: + servicem8.sync_from_servicem8(db) + staff = db.query(cally_models.Sm8Staff).filter(cally_models.Sm8Staff.active == True).all() + + schedule = db.query(cally_models.ScheduleDay).filter( + cally_models.ScheduleDay.date >= start_date, + cally_models.ScheduleDay.date <= end_date + ).all() + + # Collect all job UUIDs from the schedule to fetch their details + job_uuids = set() + for s in schedule: + if s.job_uuids: + job_uuids.update(s.job_uuids.split(',')) + + jobs = db.query(cally_models.Sm8Job).filter(cally_models.Sm8Job.uuid.in_(list(job_uuids))).all() + + return {"staff": staff, "schedule": schedule, "jobs": jobs} + except Exception as e: + import traceback + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/calendar/sync") +async def sync_calendar( + db: Session = Depends(get_cally_db), + current_user: models.User = Depends(auth.get_current_user) +): + success = servicem8.sync_from_servicem8(db) + if not success: + raise HTTPException(status_code=500, detail="Failed to sync from ServiceM8") + + await manager.broadcast(json.dumps({"type": "refresh"})) + return {"message": "Sync successful"} + @app.get("/audit", response_model=List[schemas.AuditLog]) def read_audit(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all() diff --git a/backend/requirements.txt b/backend/requirements.txt index 9e64384..a226ca1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ python-jose[cryptography] passlib bcrypt==4.0.1 python-multipart +requests diff --git a/backend/schemas.py b/backend/schemas.py index 0ccd8c6..de497a0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, EmailStr from typing import List, Optional -from datetime import datetime +from datetime import datetime, date as date_type class UserBase(BaseModel): id: str @@ -136,3 +136,38 @@ class Token(BaseModel): class TokenData(BaseModel): username: Optional[str] = None + +# Cally / ServiceM8 Schemas +class Sm8StaffBase(BaseModel): + uuid: str + name: str + active: bool = True + +class Sm8Staff(Sm8StaffBase): + class Config: + from_attributes = True + +class Sm8Job(BaseModel): + uuid: str + job_id: str + address: Optional[str] = None + class Config: + from_attributes = True + +class ScheduleDayBase(BaseModel): + staff_uuid: str + date: date_type + is_busy: bool + job_count: int = 0 + job_uuids: Optional[str] = None + +class ScheduleDay(ScheduleDayBase): + id: int + updated_at: datetime + class Config: + from_attributes = True + +class CalendarScheduleResponse(BaseModel): + staff: List[Sm8Staff] + schedule: List[ScheduleDay] + jobs: List[Sm8Job] diff --git a/backend/servicem8.py b/backend/servicem8.py new file mode 100644 index 0000000..dc83bd1 --- /dev/null +++ b/backend/servicem8.py @@ -0,0 +1,112 @@ +import requests +import os +from datetime import datetime, date, timedelta +from sqlalchemy.orm import Session +from . import cally_models +from dotenv import load_dotenv + +load_dotenv() + +SM8_TOKEN = os.getenv("SERVICEM8_ACCESS_TOKEN") +BASE_URL = "https://api.servicem8.com/api_1.0" + +def get_sm8_headers(): + return { + "X-Api-Key": SM8_TOKEN, + "Accept": "application/json", + "Content-Type": "application/json" + } + +def sync_from_servicem8(db: Session): + headers = get_sm8_headers() + + # 1. Sync Staff + staff_resp = requests.get(f"{BASE_URL}/staff.json", headers=headers) + if staff_resp.status_code == 200: + staff_list = staff_resp.json() + for s in staff_list: + # We only care about active staff who are shown on the schedule + if s.get('active') == 1 and s.get('hide_from_schedule') == 0: + first = s.get('first', '') + last = s.get('last', '') + db_staff = cally_models.Sm8Staff( + uuid=s.get('uuid'), + name=f"{first} {last}".strip() or "Unknown Staff", + active=True + ) + db.merge(db_staff) + db.commit() + + # 2. Sync Schedule (Fetch last 30 days and next 60 days) + start_date = (date.today() - timedelta(days=31)).isoformat() + # ServiceM8 API v1.0 OData does not support 'ge', but supports 'gt' + # We use 'and' to combine filters + params = { + "$filter": f"activity_was_scheduled eq 1 and start_date gt '{start_date} 00:00:00'" + } + + activity_resp = requests.get(f"{BASE_URL}/jobactivity.json", headers=headers, params=params) + if activity_resp.status_code == 200: + activities = activity_resp.json() + + # Clear existing cached schedule in the range to avoid duplicates + db.query(cally_models.ScheduleDay).delete() + + # Track busy days: {staff_uuid: {date_str: [job_uuids]}} + busy_map = {} + unique_job_uuids = set() + + for a in activities: + staff_uuid = a.get('staff_uuid') + start_dt_str = a.get('start_date') + job_uuid = a.get('job_uuid') + if not staff_uuid or not start_dt_str or not job_uuid: + continue + + # Extract date from 'YYYY-MM-DD HH:MM:SS' + activity_date_str = start_dt_str.split(' ')[0] + + if staff_uuid not in busy_map: + busy_map[staff_uuid] = {} + + if activity_date_str not in busy_map[staff_uuid]: + busy_map[staff_uuid][activity_date_str] = [] + + if job_uuid not in busy_map[staff_uuid][activity_date_str]: + busy_map[staff_uuid][activity_date_str].append(job_uuid) + unique_job_uuids.add(job_uuid) + + # 3. Sync Job Details for found activities + # We need human-readable IDs (#123) from the 'job' table + for job_uuid in unique_job_uuids: + # Check if we already have this job detail + existing_job = db.query(cally_models.Sm8Job).filter(cally_models.Sm8Job.uuid == job_uuid).first() + if not existing_job: + job_resp = requests.get(f"{BASE_URL}/job/{job_uuid}.json", headers=headers) + if job_resp.status_code == 200: + j = job_resp.json() + db_job = cally_models.Sm8Job( + uuid=job_uuid, + job_id=j.get('generated_job_id', 'Unknown'), + address=j.get('job_address', '') + ) + db.add(db_job) + db.commit() + + # 4. Save Schedule to DB + for staff_uuid, dates_dict in busy_map.items(): + for d_str, job_list in dates_dict.items(): + d_obj = date.fromisoformat(d_str) + db_day = cally_models.ScheduleDay( + staff_uuid=staff_uuid, + date=d_obj, + is_busy=True, + job_count=len(job_list), + job_uuids=",".join(job_list) + ) + db.add(db_day) + + db.commit() + return True + + return False diff --git a/components.jsx b/components.jsx index c7a2b0a..45b0eaa 100644 --- a/components.jsx +++ b/components.jsx @@ -183,6 +183,7 @@ const Icon = { Close: () => , Check: () => , Arrow: () => , + Refresh: () => , Dot: () => , Pin: () => , iMessage: () => , diff --git a/dashy.db b/dashy.db index d95039b..eba5188 100644 Binary files a/dashy.db and b/dashy.db differ diff --git a/screens.jsx b/screens.jsx index c827cb9..523f304 100644 --- a/screens.jsx +++ b/screens.jsx @@ -128,6 +128,15 @@ function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogou )} + {!showSearch && ( + + )} + {!showSearch && (