Created Cally
This commit is contained in:
@@ -208,6 +208,16 @@ class ApiService {
|
|||||||
return data;
|
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) {
|
async addAudit(auditData) {
|
||||||
const data = await this.request('/audit', {
|
const data = await this.request('/audit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ function App() {
|
|||||||
onRestore={restoreTask}
|
onRestore={restoreTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab !== 'overview' && tab !== 'deleted' && (
|
{tab === 'calendar' && (
|
||||||
|
<CallyScreen />
|
||||||
|
)}
|
||||||
|
{tab !== 'overview' && tab !== 'deleted' && tab !== 'calendar' && (
|
||||||
<UserScreen
|
<UserScreen
|
||||||
user={merge(tab)} tasks={filteredTasks} density={t.density}
|
user={merge(tab)} tasks={filteredTasks} density={t.density}
|
||||||
onOpen={(task) => setOpenTaskId(task.id)}
|
onOpen={(task) => setOpenTaskId(task.id)}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
+50
-1
@@ -7,11 +7,14 @@ from typing import List, Dict
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import asyncio
|
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 .database import engine, get_db
|
||||||
|
from .cally_database import engine as cally_engine, get_cally_db
|
||||||
|
|
||||||
models.Base.metadata.create_all(bind=engine)
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
cally_models.Base.metadata.create_all(bind=cally_engine)
|
||||||
|
|
||||||
class EventNotifier:
|
class EventNotifier:
|
||||||
def __init__(self):
|
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"}))
|
await manager.broadcast(json.dumps({"type": "refresh"}))
|
||||||
return ws
|
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])
|
@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)):
|
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()
|
return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ python-jose[cryptography]
|
|||||||
passlib
|
passlib
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart
|
python-multipart
|
||||||
|
requests
|
||||||
|
|||||||
+36
-1
@@ -1,6 +1,6 @@
|
|||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, date as date_type
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -136,3 +136,38 @@ class Token(BaseModel):
|
|||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(BaseModel):
|
||||||
username: Optional[str] = None
|
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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -183,6 +183,7 @@ const Icon = {
|
|||||||
Close: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round"/></svg>,
|
Close: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round"/></svg>,
|
||||||
Check: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
Check: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
Arrow: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 8h10m-4-4 4 4-4 4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
Arrow: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 8h10m-4-4 4 4-4 4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
Refresh: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M13.6 2.4A7.8 7.8 0 1 0 14.8 9m-1.2-6.6v4.2h-4.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
Dot: () => <svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="2.5" fill="currentColor"/></svg>,
|
Dot: () => <svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="2.5" fill="currentColor"/></svg>,
|
||||||
Pin: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M10 2 6 6H3l4 4-3 4 4-3 4 4v-3l4-4-2-2-2-4Z" strokeLinejoin="round"/></svg>,
|
Pin: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M10 2 6 6H3l4 4-3 4 4-3 4 4v-3l4-4-2-2-2-4Z" strokeLinejoin="round"/></svg>,
|
||||||
iMessage: () => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M2.5 7.5c0-2.8 2.5-5 5.5-5s5.5 2.2 5.5 5-2.5 5-5.5 5c-.6 0-1.2-.1-1.7-.2L3 13.5l.7-2.5c-.7-.9-1.2-1.9-1.2-3Z" strokeLinejoin="round"/></svg>,
|
iMessage: () => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M2.5 7.5c0-2.8 2.5-5 5.5-5s5.5 2.2 5.5 5-2.5 5-5.5 5c-.6 0-1.2-.1-1.7-.2L3 13.5l.7-2.5c-.7-.9-1.2-1.9-1.2-3Z" strokeLinejoin="round"/></svg>,
|
||||||
|
|||||||
+274
@@ -128,6 +128,15 @@ function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogou
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!showSearch && (
|
||||||
|
<button
|
||||||
|
className={"tab cally-tab" + (tab === 'calendar' ? ' is-active' : '')}
|
||||||
|
onClick={() => setTab('calendar')}
|
||||||
|
>
|
||||||
|
Cally
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!showSearch && (
|
{!showSearch && (
|
||||||
<nav className="tabs" role="tablist">
|
<nav className="tabs" role="tablist">
|
||||||
<Tab id="overview" label="Overview" tab={tab} setTab={setTab} />
|
<Tab id="overview" label="Overview" tab={tab} setTab={setTab} />
|
||||||
@@ -1445,8 +1454,273 @@ function DeletedScreen({ tasks, onRestore }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CallyScreen() {
|
||||||
|
const [data, setData] = React.useState({ staff: [], schedule: [], jobs: [] });
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [syncing, setSyncing] = React.useState(false);
|
||||||
|
const [fullscreen, setFullscreen] = React.useState(false);
|
||||||
|
const [hoveredDay, setHoveredDay] = React.useState(null);
|
||||||
|
|
||||||
|
// Drag and drop ordering
|
||||||
|
const [staffOrder, setStaffOrder] = React.useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('cally_staff_order');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const [viewDate, setViewDate] = React.useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
const monday = new Date(d.setDate(diff));
|
||||||
|
monday.setHours(0,0,0,0);
|
||||||
|
return monday;
|
||||||
|
});
|
||||||
|
|
||||||
|
const daysToShow = 28;
|
||||||
|
const dateRange = React.useMemo(() => {
|
||||||
|
return Array.from({ length: daysToShow }, (_, i) => {
|
||||||
|
const d = new Date(viewDate);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}, [viewDate]);
|
||||||
|
|
||||||
|
const loadData = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const start = dateRange[0].toISOString().split('T')[0];
|
||||||
|
const end = dateRange[dateRange.length - 1].toISOString().split('T')[0];
|
||||||
|
const res = await api.getSchedule(start, end);
|
||||||
|
setData(res);
|
||||||
|
|
||||||
|
// Auto-append any new staff to the ordering list
|
||||||
|
setStaffOrder(prev => {
|
||||||
|
const existing = new Set(prev);
|
||||||
|
const newUuids = res.staff.map(s => s.uuid).filter(id => !existing.has(id));
|
||||||
|
if (newUuids.length > 0) {
|
||||||
|
const updated = [...prev, ...newUuids];
|
||||||
|
localStorage.setItem('cally_staff_order', JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
return api.subscribe(loadData);
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
await api.triggerSync();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Sync failed: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shiftWeek = (weeks) => {
|
||||||
|
setViewDate(prev => {
|
||||||
|
const next = new Date(prev);
|
||||||
|
next.setDate(next.getDate() + (weeks * 7));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(e => {
|
||||||
|
console.error(`Error attempting to enable fullscreen: ${e.message}`);
|
||||||
|
});
|
||||||
|
setFullscreen(true);
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
setFullscreen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeys = (e) => {
|
||||||
|
if (e.key === 'ArrowLeft') shiftWeek(-1);
|
||||||
|
if (e.key === 'ArrowRight') shiftWeek(1);
|
||||||
|
if (e.key === 'f') toggleFullscreen();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeys);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeys);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDayData = (staffUuid, date) => {
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
return data.schedule.find(s => s.staff_uuid === staffUuid && s.date === dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getJobsForDay = (dayData) => {
|
||||||
|
if (!dayData || !dayData.job_uuids) return [];
|
||||||
|
const uuids = dayData.job_uuids.split(',');
|
||||||
|
return data.jobs.filter(j => uuids.includes(j.uuid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (e, staffUuid, date, dayData) => {
|
||||||
|
if (!dayData || dayData.job_count === 0) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const jobs = getJobsForDay(dayData);
|
||||||
|
setHoveredDay({
|
||||||
|
staffUuid,
|
||||||
|
dateStr: date.toLocaleDateString('en-AU', { weekday: 'short', day: 'numeric', month: 'short' }),
|
||||||
|
x: rect.left + window.scrollX,
|
||||||
|
y: rect.bottom + window.scrollY,
|
||||||
|
jobs
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedStaff = React.useMemo(() => {
|
||||||
|
return [...data.staff].sort((a, b) => {
|
||||||
|
const idxA = staffOrder.indexOf(a.uuid);
|
||||||
|
const idxB = staffOrder.indexOf(b.uuid);
|
||||||
|
return (idxA > -1 ? idxA : 999) - (idxB > -1 ? idxB : 999);
|
||||||
|
});
|
||||||
|
}, [data.staff, staffOrder]);
|
||||||
|
|
||||||
|
const onDragStart = (e, uuid) => {
|
||||||
|
e.dataTransfer.setData('text/plain', uuid);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e, targetUuid) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedUuid = e.dataTransfer.getData('text/plain');
|
||||||
|
if (draggedUuid === targetUuid) return;
|
||||||
|
|
||||||
|
setStaffOrder(prev => {
|
||||||
|
const newOrder = [...prev];
|
||||||
|
const fromIdx = newOrder.indexOf(draggedUuid);
|
||||||
|
const toIdx = newOrder.indexOf(targetUuid);
|
||||||
|
if (fromIdx > -1 && toIdx > -1) {
|
||||||
|
newOrder.splice(fromIdx, 1);
|
||||||
|
newOrder.splice(toIdx, 0, draggedUuid);
|
||||||
|
localStorage.setItem('cally_staff_order', JSON.stringify(newOrder));
|
||||||
|
}
|
||||||
|
return newOrder;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && data.staff.length === 0) return <div className="cally-screen is-loading">Loading schedule…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"cally-screen" + (fullscreen ? " is-fullscreen" : "")}>
|
||||||
|
<header className="cally__head">
|
||||||
|
<div className="cally__title-group">
|
||||||
|
<h1 className="cally__title">Cally</h1>
|
||||||
|
<span className="cally__range mono">
|
||||||
|
{dateRange[0].toLocaleDateString('en-AU', { month: 'short', day: 'numeric' })} – {dateRange[dateRange.length-1].toLocaleDateString('en-AU', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cally__nav-center">
|
||||||
|
<button className="btn btn--soft btn--sm" onClick={() => shiftWeek(-1)} title="Back (Left Arrow)"><Icon.Arrow style={{ transform: 'rotate(180deg)' }} /></button>
|
||||||
|
<button className="btn btn--soft btn--sm" onClick={() => setViewDate(new Date())}>Today</button>
|
||||||
|
<button className="btn btn--soft btn--sm" onClick={() => shiftWeek(1)} title="Next (Right Arrow)"><Icon.Arrow /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cally__actions">
|
||||||
|
<button className={"btn btn--ghost btn--sm" + (syncing ? " is-busy" : "")} onClick={handleSync}>
|
||||||
|
<Icon.Refresh /> {syncing ? 'Syncing…' : 'Sync ServiceM8'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn--ghost btn--sm" onClick={toggleFullscreen}>
|
||||||
|
{fullscreen ? <><Icon.Close /> Exit Fullscreen</> : <><Icon.Plus style={{transform: 'rotate(45deg)'}}/> Fullscreen</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="cally__viewport">
|
||||||
|
<div className="cally__grid">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="cally__row is-head">
|
||||||
|
<div className="cally__cell is-label">Staff</div>
|
||||||
|
{dateRange.map((d, i) => {
|
||||||
|
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
|
||||||
|
const isToday = d.toDateString() === new Date().toDateString();
|
||||||
|
return (
|
||||||
|
<div key={i} className={"cally__cell is-date" + (isWeekend ? " is-weekend" : "") + (isToday ? " is-today" : "")}>
|
||||||
|
<span className="cally__day">{d.toLocaleDateString('en-AU', { weekday: 'short' })}</span>
|
||||||
|
<span className="cally__num">{d.getDate()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Staff Rows */}
|
||||||
|
{orderedStaff.map(s => (
|
||||||
|
<div key={s.uuid} className="cally__row">
|
||||||
|
<div
|
||||||
|
className="cally__cell is-label"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, s.uuid)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => onDrop(e, s.uuid)}
|
||||||
|
style={{ cursor: 'grab' }}
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<span className="cally__staff-name">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
{dateRange.map((d, i) => {
|
||||||
|
const dayData = getDayData(s.uuid, d);
|
||||||
|
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
|
||||||
|
return (
|
||||||
|
<div key={i}
|
||||||
|
className={"cally__cell is-slot" + (dayData?.is_busy ? " is-busy" : "") + (isWeekend ? " is-weekend" : "")}
|
||||||
|
onMouseEnter={(e) => handleMouseEnter(e, s.uuid, d, dayData)}
|
||||||
|
onMouseLeave={() => setHoveredDay(null)}
|
||||||
|
>
|
||||||
|
{dayData?.job_count > 0 && <span className="cally__job-count">{dayData.job_count}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hoveredDay && (
|
||||||
|
<div className="cally__tooltip" style={{ top: hoveredDay.y + 4, left: hoveredDay.x }}>
|
||||||
|
<div className="cally__tooltip-header">{hoveredDay.dateStr}</div>
|
||||||
|
<ul className="cally__tooltip-list">
|
||||||
|
{hoveredDay.jobs.map(j => (
|
||||||
|
<li key={j.uuid} className="cally__tooltip-item">
|
||||||
|
<span className="cally__tooltip-id">{j.job_id}</span>
|
||||||
|
<span className="cally__tooltip-addr">{j.address}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(window, {
|
Object.assign(window, {
|
||||||
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
||||||
SettingsScreen,
|
SettingsScreen,
|
||||||
DeletedScreen,
|
DeletedScreen,
|
||||||
|
CallyScreen,
|
||||||
});
|
});
|
||||||
|
|||||||
+237
@@ -248,6 +248,242 @@ input, textarea { font: inherit; color: inherit; }
|
|||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cally-tab {
|
||||||
|
margin-right: 4px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
color: var(--accent);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.cally-tab:hover {
|
||||||
|
background: color-mix(in oklch, var(--accent) 18%, var(--bg));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.cally-tab.is-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CALLY CALENDAR === */
|
||||||
|
.cally-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally-screen.is-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__title-group { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.cally__title { font-size: 18px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.cally__range { font-size: 18px; font-weight: 700; color: var(--fg); letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
.cally__nav-center { display: flex; align-items: center; gap: 8px; justify-content: center; }
|
||||||
|
|
||||||
|
.cally__actions { display: flex; gap: 8px; justify-self: end; }
|
||||||
|
|
||||||
|
.cally__viewport {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
min-width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.cally__row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.cally__cell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 48px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cally__cell:last-child { border-right: none; }
|
||||||
|
|
||||||
|
.cally__cell.is-label {
|
||||||
|
width: 160px;
|
||||||
|
flex: none;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 0 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
border-right: 2px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__staff-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__cell.is-head {
|
||||||
|
flex: none;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__cell.is-date {
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__day { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--fg-faint); margin-bottom: 2px; }
|
||||||
|
.cally__num { font-size: 15px; font-weight: 600; color: var(--fg-muted); }
|
||||||
|
|
||||||
|
.cally__cell.is-today .cally__num {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__cell.is-slot {
|
||||||
|
background: #ffffff;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__job-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10000;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
min-width: 180px;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pop 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pop {
|
||||||
|
from { opacity: 0; transform: scale(0.95) translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__tooltip-header {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg-soft);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__tooltip-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__tooltip-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__tooltip-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__tooltip-addr {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__cell.is-weekend {
|
||||||
|
background: oklch(98% 0.005 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally__cell.is-busy {
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 6px,
|
||||||
|
oklch(63% 0.18 28 / 0.4) 6px,
|
||||||
|
oklch(63% 0.18 28 / 0.4) 10px
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid oklch(63% 0.18 28 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cally-screen.is-loading {
|
||||||
|
height: 300px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--fg-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
.tab__dot { width: 7px; height: 7px; border-radius: 999px; }
|
.tab__dot { width: 7px; height: 7px; border-radius: 999px; }
|
||||||
|
|
||||||
.topbar__right { display: flex; align-items: center; gap: 6px; }
|
.topbar__right { display: flex; align-items: center; gap: 6px; }
|
||||||
@@ -296,6 +532,7 @@ input, textarea { font: inherit; color: inherit; }
|
|||||||
|
|
||||||
/* === MAIN === */
|
/* === MAIN === */
|
||||||
.main { flex: 1; overflow: auto; padding: 20px; min-height: 0; }
|
.main { flex: 1; overflow: auto; padding: 20px; min-height: 0; }
|
||||||
|
.main:has(.cally-screen) { padding: 0; }
|
||||||
|
|
||||||
/* === BOARD (overview) === */
|
/* === BOARD (overview) === */
|
||||||
.board {
|
.board {
|
||||||
|
|||||||
Reference in New Issue
Block a user