// Screens for Dashy
function LoginScreen({ onLogin, dbUsers = [], workspace }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = async () => {
if (!username) { setError('Enter your username'); return; }
if (!password) { setError('Enter your password'); return; }
setError(''); setBusy(true);
try {
await onLogin(username, password);
} catch (e) {
setError('Incorrect username or password');
} finally {
setBusy(false);
}
};
return (
Dashy
Sign in to Dashy
Enter your details to access the {workspace ? workspace.name : 'loading…'} workspace
{error &&
{error}
}
⌘ ⏎ to submit · e.preventDefault()}>Forgot password
04 May → 08 May
v0.1 · build 26.05.08
);
}
function Stat({ n, label }) {
return (
);
}
function BrandMark({ size = 22 }) {
return (
);
}
function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile, workspace, searchQuery, setSearchQuery, showSearch, onToggleSearch }) {
return (
);
}
function Tab({ id, label, tab, setTab, user }) {
const active = tab === id;
return (
);
}
function HeadsUp({ items, onDismiss, onOpenTask }) {
if (!items.length) return null;
return (
{items.map(it => (
{it.kind === 'unsuccessful' ? '!' : it.kind === 'billing' ? '⇄' : '✦'}
))}
);
}
function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers = [] }) {
const byUser = Object.fromEntries(dbUsers.map(u => [u.id, []]));
tasks.forEach(t => { if (byUser[t.assignee] && t.status !== 'closed') byUser[t.assignee].push(t); });
const [draggingTask, setDraggingTask] = React.useState(null);
const [dragOverCol, setDragOverCol] = React.useState(null);
const [dragOverTaskId, setDragOverTaskId] = React.useState(null);
const [dropSide, setDropSide] = React.useState('bottom'); // 'top' or 'bottom'
return (
{dbUsers.map(u => (
onAddFor(u.id)}
density={density}
dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id}
onDragOver={(uid) => setDragOverCol(uid)}
onDragLeave={() => { setDragOverCol(null); setDragOverTaskId(null); }}
onDragStartCard={(t) => {
// Use a tiny timeout to ensure the drag image is captured before we hide the element
setTimeout(() => setDraggingTask(t), 0);
}}
onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }}
draggingId={draggingTask && draggingTask.id}
dragOverTaskId={dragOverTaskId}
dropSide={dropSide}
onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }}
onDropTask={(toId) => {
if (!draggingTask) return;
// Calculate position using the list that DOES NOT include the dragging task
const colTasks = byUser[toId].filter(t => t.id !== draggingTask.id);
let newPos = 0;
if (dragOverTaskId) {
const idx = colTasks.findIndex(t => t.id === dragOverTaskId);
if (dropSide === 'top') {
const prev = colTasks[idx - 1];
newPos = prev ? (colTasks[idx].position + prev.position) / 2 : colTasks[idx].position / 2;
} else {
const next = colTasks[idx + 1];
newPos = next ? (colTasks[idx].position + next.position) / 2 : colTasks[idx].position + 1000;
}
} else {
// Dropped on empty area or no specific task
const last = colTasks[colTasks.length - 1];
newPos = last ? last.position + 1000 : 1000;
}
onMoveTask && onMoveTask(draggingTask.id, toId, newPos);
setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null);
}}
/>
))}
);
}
function Column({ user, title, icon, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId, dragOverTaskId, dropSide, onDragOverTask, colId, faded }) {
const columnId = colId || (user ? user.id : title);
return (
{ e.preventDefault(); onDragOver && onDragOver(columnId); }}
onDragLeave={(e) => {
if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) {
onDragLeave && onDragLeave(columnId);
}
}}
onDrop={(e) => { e.preventDefault(); onDropTask && onDropTask(columnId); }}
>
{
e.preventDefault();
if (e.target === e.currentTarget) {
onDragOverTask(null, 'bottom');
}
}}>
{tasks.length === 0 && !dragOver && (
— inbox zero —
)}
{tasks.length === 0 && dragOver && (
)}
{tasks.map(t => {
const isOver = dragOverTaskId === t.id;
const isTop = isOver && dropSide === 'top';
const isBottom = isOver && dropSide === 'bottom';
return (
{isOver && (
)}
{
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onDragOverTask(t.id, e.clientY < mid ? 'top' : 'bottom');
}}
/>
);
})}
);
}
function UserScreen({ user, tasks, onOpen, onAddFor, density, onMoveTask }) {
const mine = tasks.filter(t => t.assignee === user.id);
const open = mine.filter(t => t.status === 'open');
const flagged = mine.filter(t => t.status === 'unsuccessful' || t.status === 'billing');
const closed = mine.filter(t => t.status === 'closed');
const [draggingTask, setDraggingTask] = React.useState(null);
const [dragOverCol, setDragOverCol] = React.useState(null);
const [dragOverTaskId, setDragOverTaskId] = React.useState(null);
const [dropSide, setDropSide] = React.useState('bottom');
const onDrop = (status) => {
if (!draggingTask) return;
const targetTasks = status === 'flagged' ? flagged : (status === 'open' ? open : closed);
const cleanTarget = targetTasks.filter(t => t.id !== draggingTask.id);
let newPos = 0;
if (dragOverTaskId) {
const idx = cleanTarget.findIndex(t => t.id === dragOverTaskId);
if (dropSide === 'top') {
const prev = cleanTarget[idx - 1];
newPos = prev ? (cleanTarget[idx].position + prev.position) / 2 : cleanTarget[idx].position / 2;
} else {
const next = cleanTarget[idx + 1];
newPos = next ? (cleanTarget[idx].position + next.position) / 2 : cleanTarget[idx].position + 1000;
}
} else {
const last = cleanTarget[cleanTarget.length - 1];
newPos = last ? last.position + 1000 : 1000;
}
// Determine target status
let targetStatus = status;
if (status === 'flagged') targetStatus = 'unsuccessful'; // Default flagged status
if (status === 'closed') targetStatus = 'closed';
if (status === 'open') targetStatus = 'open';
onMoveTask && onMoveTask(draggingTask.id, user.id, newPos, targetStatus);
setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null);
};
return (
{user.name}
{user.role} · {open.length + flagged.length} open tasks
{ setDragOverCol(null); setDragOverTaskId(null); }}
onDragStartCard={(t) => {
setTimeout(() => setDraggingTask(t), 0);
}}
onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }}
draggingId={draggingTask?.id}
dragOverTaskId={dragOverTaskId} dropSide={dropSide}
onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }}
onDropTask={onDrop}
/>
{ setDragOverCol(null); setDragOverTaskId(null); }}
onDragStartCard={(t) => {
setTimeout(() => setDraggingTask(t), 0);
}}
onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }}
draggingId={draggingTask?.id}
dragOverTaskId={dragOverTaskId} dropSide={dropSide}
onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }}
onDropTask={onDrop}
/>
!draggingTask || t.id !== draggingTask.id)}
onOpen={onOpen} density={density}
dragOver={dragOverCol === 'closed'}
onDragOver={setDragOverCol}
onDragLeave={() => { setDragOverCol(null); setDragOverTaskId(null); }}
onDragStartCard={(t) => {
setTimeout(() => setDraggingTask(t), 0);
}}
onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }}
draggingId={draggingTask?.id}
dragOverTaskId={dragOverTaskId} dropSide={dropSide}
onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }}
onDropTask={onDrop}
/>
);
}
function Section({ title, sub, children }) {
return (
);
}
function AddTaskModal({ open, onClose, onSubmit, defaultAssignee, me, dbUsers = [] }) {
const [title, setTitle] = React.useState('');
const [desc, setDesc] = React.useState('');
const [assignee, setAssignee] = React.useState(defaultAssignee || 'lani');
const [priority, setPriority] = React.useState('med');
React.useEffect(() => { if (open) setAssignee(defaultAssignee || 'lani'); }, [open, defaultAssignee]);
React.useEffect(() => { if (open) { setTitle(''); setDesc(''); setPriority('med'); } }, [open]);
if (!open) return null;
const submit = (e) => {
e && e.preventDefault();
if (!title.trim()) return;
onSubmit({ title: title.trim(), description: desc.trim(), assignee, priority });
};
return (
);
}
function Modal({ children, onClose, title, eyebrow, wide = false }) {
return (
e.stopPropagation()}>
{children}
);
}
function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComplete, onReopen, onEditDesc, onDeleteTask, onAddNote }) {
const [editingDesc, setEditingDesc] = React.useState(false);
const [descValue, setDescValue] = React.useState(task ? task.description || '' : '');
const [noteValue, setNoteValue] = React.useState('');
React.useEffect(() => {
setDescValue(task ? task.description || '' : '');
}, [task]);
if (!task) return null;
const assignee = findUser(task.assignee);
const author = findUser(task.addedBy);
const audit = allAudit.filter(a => a.target === task.id);
if (audit.length === 0) {
audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' });
}
const handleAddNote = async () => {
if (!noteValue.trim()) return;
await onAddNote(noteValue);
setNoteValue('');
};
return (
{task.status === 'unsuccessful' && (
Auto-marked Unsuccessful. Two missed bookings detected by the scheduler. Decide on next step or revert.
)}
{task.status === 'billing' && (
Form re-routed to Billing. Originally captured as Service Booking — keywords matched billing template.
)}
Description
{!editingDesc && (
setEditingDesc(true)}>
)}
{editingDesc ? (
) : (
{task.description || No description}
)}
Notes
{task.notes && task.notes.map(note => {
const noteAuthor = findUser(note.author_id);
return (
-
{note.body}
{noteAuthor ? noteAuthor.name : 'Unknown'} · {fmtDateTime(note.created_at)}
);
})}
setNoteValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddNote(); }}
/>
Activity
{audit.slice().reverse().map((row, i) => (
-
{row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name}
{row.action.replace(/_/g, ' ')}
{(row.detail || row.summary) &&
{row.detail || row.summary}
}
{fmtDateTime(row.at)}
))}
);
}
function Field({ label, children }) {
return (
);
}
function AuditScreen({ entries, onOpen }) {
const [actorFilter, setActorFilter] = React.useState('all');
const [eventFilter, setEventFilter] = React.useState('all');
const [auditSearch, setAuditSearch] = React.useState('');
const EVENT_TYPES = [
{ value: 'all', label: 'All events' },
{ value: 'task', label: 'Tasks (all)' },
{ value: 'task_created', label: 'Created' },
{ value: 'task_moved', label: 'Moved' },
{ value: 'task_completed', label: 'Completed' },
{ value: 'task_deleted', label: 'Deleted' },
{ value: 'task_restored', label: 'Restored' },
{ value: 'user', label: 'User management' },
{ value: 'workspace_updated', label: 'Workspace' },
];
const groups = React.useMemo(() => {
let filtered = entries;
// 1. Actor Filter
if (actorFilter !== 'all') {
filtered = actorFilter === 'system'
? filtered.filter(e => e.actor === 'system')
: filtered.filter(e => e.actor === actorFilter);
}
// 2. Event Type Filter
if (eventFilter !== 'all') {
if (eventFilter === 'task') {
filtered = filtered.filter(e => e.action.startsWith('task_'));
} else if (eventFilter === 'user') {
filtered = filtered.filter(e => e.action.startsWith('user_') || e.action === 'password_changed');
} else {
filtered = filtered.filter(e => e.action === eventFilter);
}
}
// 3. Search Query
if (auditSearch.trim()) {
const q = auditSearch.toLowerCase();
filtered = filtered.filter(e =>
e.summary.toLowerCase().includes(q) ||
e.action.toLowerCase().includes(q) ||
(e.actor !== 'system' && (findUser(e.actor) || {}).name?.toLowerCase().includes(q))
);
}
const out = {};
filtered.forEach(e => {
const day = new Date(e.at).toDateString();
(out[day] = out[day] || []).push(e);
});
return out;
}, [actorFilter, eventFilter, auditSearch, entries]);
return (
{Object.entries(groups).map(([day, rows]) => (
{day}
{rows.map(r => {
const actor = r.actor === 'system' ? null : findUser(r.actor);
return (
-
{(() => {
let safeTz = undefined;
if (window.workspace && window.workspace.timezone) {
try { Intl.DateTimeFormat(undefined, { timeZone: window.workspace.timezone }); safeTz = window.workspace.timezone; } catch(e) {}
}
return new Date(typeof r.at === 'string' && !r.at.endsWith('Z') && !r.at.includes('+') ? r.at + 'Z' : r.at).toLocaleTimeString('en-US',{hour:'numeric', minute:'2-digit', timeZone: safeTz});
})()}
{actor ? : SYS}
{actor ? actor.name : 'System'}
{r.action.replace(/_/g, ' ')}
{r.summary}
{r.target && (
)}
);
})}
))}
);
}
function FilterChip({ on, onClick, children }) {
return (
);
}
function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, onChangePassword, workspace, onUpdateWorkspace }) {
const [name, setName] = React.useState(user.name);
const [role, setRole] = React.useState(user.role);
const [email, setEmail] = React.useState(user.email || '');
const [phone, setPhone] = React.useState(user.phone || '');
const [photo, setPhoto] = React.useState(user.photo || null);
const [tab, setTab] = React.useState('profile');
const [pwOld, setPwOld] = React.useState('');
const [pwNew, setPwNew] = React.useState('');
const [pwConfirm, setPwConfirm] = React.useState('');
const [pwSaved, setPwSaved] = React.useState(false);
const [pwError, setPwError] = React.useState('');
const [pwBusy, setPwBusy] = React.useState(false);
const [saved, setSaved] = React.useState(false);
const submitPasswordChange = async () => {
setPwError('');
if (pwNew !== pwConfirm) { setPwError('New passwords do not match'); return; }
setPwBusy(true);
try {
await onChangePassword(pwOld, pwNew);
setPwOld(''); setPwNew(''); setPwConfirm('');
setPwSaved(true);
setTimeout(() => setPwSaved(false), 2000);
} catch (e) {
setPwError(e.message || 'Failed to update password');
} finally {
setPwBusy(false);
}
};
React.useEffect(() => {
setName(user.name);
setRole(user.role);
setEmail(user.email || '');
setPhone(user.phone || '');
setPhoto(user.photo || null);
}, [user.id]);
const fileInputRef = React.useRef(null);
const handleFile = (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = ev => setPhoto(ev.target.result);
reader.readAsDataURL(f);
};
const save = () => {
onSave({ name, role, photo, email, phone });
setSaved(true);
setTimeout(() => setSaved(false), 1600);
};
const dirty = name !== user.name || role !== user.role || photo !== (user.photo || null) || email !== (user.email || '') || phone !== (user.phone || '');
return (
{tab === 'profile' && (
<>
Profile picture
{!photo &&
s[0]).join('').slice(0,2).toUpperCase()}} size={88} />}
{photo && (
)}
PNG or JPG, square crop recommended · max 2MB
Personal details
{saved && Saved}
>
)}
{tab === 'security' && (
<>
Change password
{pwError &&
{pwError}
}
{pwSaved && Password updated}
Sign-in & sessions
-
MacBook · Chrome · this device
Active now · Christchurch, NZ
current
-
iPhone · Safari
Last active 2h ago
>
)}
{tab === 'notifications' && (
<>
When should we ping you?
Channels
>
)}
{tab === 'workspace' && (
)}
);
}
function ToggleRow({ label, defaultOn = false }) {
const [on, setOn] = React.useState(defaultOn);
return (
{label}
);
}
function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, workspace, onUpdateWorkspace }) {
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [newRole, setNewRole] = React.useState('');
const [newEmail, setNewEmail] = React.useState('');
const [newPhone, setNewPhone] = React.useState('');
const [newType, setNewType] = React.useState('standard');
const [wsName, setWsName] = React.useState(workspace ? workspace.name : '');
const [wsTz, setWsTz] = React.useState(workspace ? workspace.timezone : '');
const [wsSaved, setWsSaved] = React.useState(false);
const [wsError, setWsError] = React.useState('');
// User editing state
const [editingUserId, setEditingUserId] = React.useState(null);
const [editName, setEditName] = React.useState('');
const [editRole, setEditRole] = React.useState('');
const [editEmail, setEditEmail] = React.useState('');
const [editPhone, setEditPhone] = React.useState('');
const startEditing = (u) => {
setEditingUserId(u.id);
setEditName(u.name);
setEditRole(u.role);
setEditEmail(u.email || '');
setEditPhone(u.phone || '');
};
const cancelEditing = () => {
setEditingUserId(null);
};
const saveUserEdit = async (id) => {
await onUpdateUserRole(id, { name: editName, role: editRole, email: editEmail, phone: editPhone });
setEditingUserId(null);
};
React.useEffect(() => {
if (workspace) {
setWsName(workspace.name);
setWsTz(workspace.timezone);
}
}, [workspace]);
const submit = () => {
if (!newName.trim()) return;
onCreateUser({
name: newName.trim(),
role: newRole.trim() || 'Team member',
account_type: newType,
email: newEmail.trim(),
phone: newPhone.trim()
});
setNewName(''); setNewRole(''); setNewEmail(''); setNewPhone(''); setNewType('standard'); setAdding(false);
};
const handleUpdateWorkspace = async () => {
setWsError(''); // Clear previous errors
if (wsTz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: wsTz });
} catch (e) {
setWsError('Not a proper timezone (e.g., Asia/Tokyo)');
return; // Stop the save process
}
}
await onUpdateWorkspace({ name: wsName, timezone: wsTz });
setWsSaved(true);
setTimeout(() => setWsSaved(false), 2000);
};
const wsDirty = workspace && (wsName !== workspace.name || wsTz !== workspace.timezone);
return (
<>
Switch user
Test other accounts without signing out.
{dbUsers.map(u => (
))}
Team members {!isAdmin && — admin only}
{isAdmin && !adding && (
)}
{isAdmin && adding && (
)}
{dbUsers.map(u => (
-
{editingUserId === u.id ? (
) : (
<>
{isAdmin ? (
) : (
{u.account_type}
)}
{isAdmin && u.id !== user.id && (
)}
>
)}
))}
Workspace {!isAdmin && — admin only}
{isAdmin && (
{wsSaved && Saved}
)}
>
);
}
function DeletedScreen({ tasks, onRestore }) {
return (
{tasks.length === 0 ? (
— trash is empty —
) : (
{tasks.map(t => (
))}
)}
);
}
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 Loading schedule…
;
return (
Cally
{dateRange[0].toLocaleDateString('en-AU', { month: 'short', day: 'numeric' })} – {dateRange[dateRange.length-1].toLocaleDateString('en-AU', { month: 'short', day: 'numeric', year: 'numeric' })}
{/* Header Row */}
Staff
{dateRange.map((d, i) => {
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
const isToday = d.toDateString() === new Date().toDateString();
return (
{d.toLocaleDateString('en-AU', { weekday: 'short' })}
{d.getDate()}
);
})}
{/* Staff Rows */}
{orderedStaff.map(s => (
onDragStart(e, s.uuid)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, s.uuid)}
style={{ cursor: 'grab' }}
title="Drag to reorder"
>
{s.name}
{dateRange.map((d, i) => {
const dayData = getDayData(s.uuid, d);
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
return (
handleMouseEnter(e, s.uuid, d, dayData)}
onMouseLeave={() => setHoveredDay(null)}
>
{dayData?.job_count > 0 && {dayData.job_count}}
);
})}
))}
{hoveredDay && (
{hoveredDay.dateStr}
{hoveredDay.jobs.map(j => (
-
{j.job_id}
{j.address}
))}
)}
);
}
Object.assign(window, {
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
SettingsScreen,
DeletedScreen,
CallyScreen,
});