Created Cally

This commit is contained in:
NPS Agent
2026-05-21 15:49:36 +10:00
parent 98cf813f00
commit be26e8b78d
12 changed files with 772 additions and 3 deletions
+10
View File
@@ -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',
+4 -1
View File
@@ -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)}
+22
View File
@@ -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()
+25
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -7,3 +7,4 @@ python-jose[cryptography]
passlib passlib
bcrypt==4.0.1 bcrypt==4.0.1
python-multipart python-multipart
requests
+36 -1
View File
@@ -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]
+112
View File
@@ -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
+1
View File
@@ -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>,
BIN
View File
Binary file not shown.
+274
View File
@@ -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
View File
@@ -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 {