Created Cally
This commit is contained in:
+274
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user