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
+274
View File
@@ -128,6 +128,15 @@ function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogou
)}
</div>
{!showSearch && (
<button
className={"tab cally-tab" + (tab === 'calendar' ? ' is-active' : '')}
onClick={() => setTab('calendar')}
>
Cally
</button>
)}
{!showSearch && (
<nav className="tabs" role="tablist">
<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, {
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
SettingsScreen,
DeletedScreen,
CallyScreen,
});