// 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 (
-
{new Date(r.at).toLocaleTimeString('en-US',{hour:'numeric', minute:'2-digit'})}
{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);
// User editing state
const [editingUserId, setEditingUserId] = React.useState(null);
const [editName, setEditName] = React.useState('');
const [editRole, setEditRole] = React.useState('');
const startEditing = (u) => {
setEditingUserId(u.id);
setEditName(u.name);
setEditRole(u.role);
};
const cancelEditing = () => {
setEditingUserId(null);
};
const saveUserEdit = async (id) => {
await onUpdateUserRole(id, { name: editName, role: editRole });
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 () => {
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 ? (
setEditName(e.target.value)} autoFocus />
setEditRole(e.target.value)} />
) : (
<>
{u.name}
{isAdmin && (
)}
{u.role} · {u.email || (u.id + '@murchison-auto.co')}
>
)}
{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 => (
))}
)}
);
}
Object.assign(window, {
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
SettingsScreen,
DeletedScreen,
});