1426 lines
58 KiB
React
1426 lines
58 KiB
React
// 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 (
|
||
<div className="login">
|
||
<div className="login__card">
|
||
<div className="login__brand">
|
||
<BrandMark />
|
||
<span className="login__wordmark">Dashy</span>
|
||
</div>
|
||
<h1 className="login__title">Sign in to Dashy</h1>
|
||
<p className="login__sub">Enter your details to access the <span className="mono">{workspace ? workspace.name : 'loading…'}</span> workspace</p>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', marginTop: '1rem' }}>
|
||
<label className="field">
|
||
<span className="field__label">Username</span>
|
||
<input
|
||
className="field__input"
|
||
value={username}
|
||
onChange={e => setUsername(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
|
||
placeholder="Username"
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span className="field__label">Password</span>
|
||
<input
|
||
className="field__input"
|
||
type="password"
|
||
value={password}
|
||
onChange={e => setPassword(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
|
||
placeholder="••••••••"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{error && <div className="mono" style={{ color: 'var(--prio-high-dot)', marginTop: '0.25rem', fontSize: '12px' }}>{error}</div>}
|
||
|
||
<button className="btn btn--primary btn--full" style={{ marginTop: '0.5rem' }} onClick={submit} disabled={busy}>
|
||
{busy ? 'Signing in…' : 'Sign in'}
|
||
<Icon.Arrow />
|
||
</button>
|
||
|
||
<p className="login__foot">
|
||
<span className="mono">⌘ ⏎</span> to submit · <a href="#" onClick={e => e.preventDefault()}>Forgot password</a>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="login__side">
|
||
<div className="login__side-eyebrow mono">04 May → 08 May</div>
|
||
<div className="login__side-stats">
|
||
<Stat n="42" label="tasks closed this week" />
|
||
<Stat n="14" label="auto-created from email" />
|
||
<Stat n="6" label="from iMessage → Dashy" />
|
||
<Stat n="2" label="form responses re-routed" />
|
||
</div>
|
||
<div className="login__side-foot mono">v0.1 · build 26.05.08</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({ n, label }) {
|
||
return (
|
||
<div className="stat">
|
||
<div className="stat__n">{n}</div>
|
||
<div className="stat__label">{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BrandMark({ size = 22 }) {
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect x="2" y="2" width="9" height="13" rx="2" fill="currentColor" />
|
||
<rect x="13" y="2" width="9" height="6" rx="2" fill="currentColor" opacity="0.55" />
|
||
<rect x="2" y="17" width="9" height="5" rx="2" fill="currentColor" opacity="0.55" />
|
||
<rect x="13" y="10" width="9" height="12" rx="2" fill="currentColor" opacity="0.85" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile, workspace, searchQuery, setSearchQuery, showSearch, onToggleSearch }) {
|
||
return (
|
||
<header className="topbar">
|
||
<div className="topbar__left">
|
||
{showSearch ? (
|
||
<div className="topbar__search">
|
||
<Icon.Search />
|
||
<input
|
||
className="topbar__search-input"
|
||
autoFocus
|
||
placeholder="Search tasks, descriptions, tags…"
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
/>
|
||
<IconBtn label="Close search" onClick={onToggleSearch}>
|
||
<Icon.Close />
|
||
</IconBtn>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<span className="topbar__brand"><BrandMark /><span>Dashy</span></span>
|
||
<span className="topbar__divider" />
|
||
<span className="topbar__workspace">{workspace ? workspace.name : 'loading…'}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{!showSearch && (
|
||
<nav className="tabs" role="tablist">
|
||
<Tab id="overview" label="Overview" tab={tab} setTab={setTab} />
|
||
{dbUsers.map(u => (
|
||
<Tab key={u.id} id={u.id} label={u.name} tab={tab} setTab={setTab} user={u} />
|
||
))}
|
||
{isAdmin && <Tab id="deleted" label="Deleted" tab={tab} setTab={setTab} />}
|
||
</nav>
|
||
)}
|
||
|
||
<div className="topbar__right">
|
||
{!showSearch && (
|
||
<>
|
||
<button className="btn btn--soft" onClick={onAdd}>
|
||
<Icon.Plus /> New task
|
||
</button>
|
||
<IconBtn label="Search" onClick={onToggleSearch}>
|
||
<Icon.Search />
|
||
</IconBtn>
|
||
</>
|
||
)}
|
||
{isAdmin && (
|
||
<IconBtn label="Audit log" onClick={onLogs}>
|
||
<Icon.Logs />
|
||
</IconBtn>
|
||
)}
|
||
<button className="topbar__me" onClick={onProfile}>
|
||
<Avatar user={me} size={28} ring />
|
||
<div className="topbar__me-meta">
|
||
<span className="topbar__me-name">
|
||
{me.name}
|
||
{isAdmin && <span className="admin-badge" title="Admin">●</span>}
|
||
</span>
|
||
<span className="topbar__me-role">{isAdmin ? 'Admin · ' : ''}{me.role}</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function Tab({ id, label, tab, setTab, user }) {
|
||
const active = tab === id;
|
||
return (
|
||
<button
|
||
role="tab"
|
||
aria-selected={active}
|
||
className={"tab" + (active ? " is-active" : "")}
|
||
onClick={() => setTab(id)}
|
||
>
|
||
{user && <span className="tab__dot" style={{ background: `oklch(70% 0.10 ${user.hue})` }} />}
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function HeadsUp({ items, onDismiss, onOpenTask }) {
|
||
if (!items.length) return null;
|
||
return (
|
||
<div className="heads-up" role="status">
|
||
{items.map(it => (
|
||
<div key={it.id} className={"heads-up__item heads-up__item--" + it.kind}>
|
||
<span className="heads-up__icon">
|
||
{it.kind === 'unsuccessful' ? '!' : it.kind === 'billing' ? '⇄' : '✦'}
|
||
</span>
|
||
<div className="heads-up__body">
|
||
<div className="heads-up__title">{it.title}</div>
|
||
<div className="heads-up__sub">{it.sub}</div>
|
||
</div>
|
||
<button className="heads-up__open" onClick={() => onOpenTask(it.taskId)}>
|
||
View <Icon.Arrow />
|
||
</button>
|
||
<button className="heads-up__close" onClick={() => onDismiss(it.id)} aria-label="Dismiss">
|
||
<Icon.Close />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="board">
|
||
{dbUsers.map(u => (
|
||
<Column
|
||
key={u.id}
|
||
user={u}
|
||
// Do not filter out the dragging task; TaskCard handles its own visibility via the 'dragging' prop.
|
||
// This ensures the element stays mounted and onDragEnd fires reliably.
|
||
tasks={byUser[u.id]}
|
||
onOpen={onOpen}
|
||
onAdd={() => 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);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section
|
||
className={"column" + (dragOver && tasks.length === 0 ? " column--over" : "")}
|
||
onDragOver={(e) => { 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); }}
|
||
>
|
||
<header className="column__head">
|
||
<div className="column__head-l">
|
||
{user ? (
|
||
<>
|
||
<Avatar user={user} size={28} />
|
||
<div className="column__head-meta">
|
||
<h2 className="column__name">{user.name}</h2>
|
||
<span className="column__role">{user.role}</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
{icon && <span className="column__icon">{icon}</span>}
|
||
<div className="column__head-meta">
|
||
<h2 className="column__name">{title}</h2>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="column__head-r">
|
||
<span className="column__count mono">{tasks.length}</span>
|
||
{onAdd && (
|
||
<button className="add-btn" onClick={onAdd} aria-label={"Add task"}>
|
||
<Icon.Plus />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="column__list" style={{ flex: 1, position: 'relative', opacity: faded ? 0.45 : 1 }}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
if (e.target === e.currentTarget) {
|
||
onDragOverTask(null, 'bottom');
|
||
}
|
||
}}>
|
||
{tasks.length === 0 && !dragOver && (
|
||
<div className="column__empty">
|
||
<span className="mono">— inbox zero —</span>
|
||
</div>
|
||
)}
|
||
|
||
{tasks.length === 0 && dragOver && (
|
||
<div className="drop-placeholder" />
|
||
)}
|
||
|
||
{tasks.map(t => {
|
||
const isOver = dragOverTaskId === t.id;
|
||
const isTop = isOver && dropSide === 'top';
|
||
const isBottom = isOver && dropSide === 'bottom';
|
||
|
||
return (
|
||
<div key={t.id} style={{
|
||
position: 'relative',
|
||
transition: 'padding 0.18s cubic-bezier(0.2, 0.8, 0.2, 1)',
|
||
paddingTop: isTop ? '60px' : '0px',
|
||
paddingBottom: isBottom ? '60px' : '0px'
|
||
}}>
|
||
{isOver && (
|
||
<div className="drop-placeholder" style={{
|
||
position: 'absolute',
|
||
left: 0, right: 0,
|
||
top: isTop ? '4px' : 'auto',
|
||
bottom: isBottom ? '4px' : 'auto',
|
||
margin: 0
|
||
}} />
|
||
)}
|
||
<TaskCard
|
||
task={t}
|
||
onOpen={onOpen}
|
||
density={density}
|
||
dragging={draggingId === t.id}
|
||
onDragStart={onDragStartCard}
|
||
onDragEnd={onDragEndCard}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const mid = rect.top + rect.height / 2;
|
||
onDragOverTask(t.id, e.clientY < mid ? 'top' : 'bottom');
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="user-view">
|
||
<div className="user-view__hero">
|
||
<Avatar user={user} size={64} />
|
||
<div className="user-view__hero-meta">
|
||
<h1 className="user-view__name">{user.name}</h1>
|
||
<p className="user-view__role">{user.role} · <span className="mono">{open.length + flagged.length} open tasks</span></p>
|
||
</div>
|
||
<div className="user-view__hero-cta">
|
||
<button className="btn btn--primary" onClick={() => onAddFor(user.id)}>
|
||
<Icon.Plus /> New task for {user.name}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="board board--user">
|
||
<Column
|
||
title="Needs review" icon="⚠" colId="flagged"
|
||
tasks={flagged}
|
||
onOpen={onOpen} density={density}
|
||
dragOver={dragOverCol === 'flagged'}
|
||
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}
|
||
/>
|
||
<Column
|
||
title="Open" icon="○" colId="open"
|
||
tasks={open}
|
||
onOpen={onOpen} density={density}
|
||
dragOver={dragOverCol === 'open'}
|
||
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}
|
||
/>
|
||
<Column
|
||
title="Completed" icon="✓" colId="closed" faded={true}
|
||
tasks={closed.filter(t => !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}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Section({ title, sub, children }) {
|
||
return (
|
||
<section className="section">
|
||
<header className="section__head">
|
||
<h2 className="section__title">{title}</h2>
|
||
{sub && <span className="section__sub mono">{sub}</span>}
|
||
</header>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Modal onClose={onClose} title="New task" eyebrow={"From " + me.name}>
|
||
<form onSubmit={submit} className="modal__form">
|
||
<label className="field">
|
||
<span className="field__label">Task</span>
|
||
<input
|
||
className="field__input field__input--big"
|
||
autoFocus
|
||
value={title}
|
||
onChange={e => setTitle(e.target.value)}
|
||
placeholder="What needs doing?"
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span className="field__label">Notes <span className="mono">— optional</span></span>
|
||
<textarea
|
||
className="field__textarea"
|
||
rows={3}
|
||
value={desc}
|
||
onChange={e => setDesc(e.target.value)}
|
||
placeholder="Any context, links, work-order numbers…"
|
||
/>
|
||
</label>
|
||
|
||
<div className="modal__row">
|
||
<div className="field">
|
||
<span className="field__label">Assign to</span>
|
||
<div className="picker">
|
||
{dbUsers.map(u => (
|
||
<button
|
||
type="button"
|
||
key={u.id}
|
||
className={"picker__item" + (assignee === u.id ? " is-on" : "")}
|
||
onClick={() => setAssignee(u.id)}
|
||
>
|
||
<Avatar user={u} size={20} />
|
||
{u.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<span className="field__label">Priority</span>
|
||
<div className="picker">
|
||
{Object.entries(PRIORITY).map(([k, v]) => (
|
||
<button
|
||
type="button"
|
||
key={k}
|
||
className={"picker__item" + (priority === k ? " is-on" : "")}
|
||
onClick={() => setPriority(k)}
|
||
>
|
||
<span className="prio__dot" style={{ background: v.dot }} />
|
||
{v.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer className="modal__foot">
|
||
<p className="modal__hint mono">
|
||
tip — say <span className="kbd">"Hey Siri, message Dashy"</span> to add a task from your phone
|
||
</p>
|
||
<div className="modal__actions">
|
||
<button type="button" className="btn btn--ghost" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="btn btn--primary" disabled={!title.trim()}>
|
||
Add task <Icon.Arrow />
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function Modal({ children, onClose, title, eyebrow, wide = false }) {
|
||
return (
|
||
<div className="modal-scrim" onClick={onClose}>
|
||
<div className={"modal" + (wide ? " modal--wide" : "")} onClick={e => e.stopPropagation()}>
|
||
<header className="modal__head">
|
||
<div>
|
||
{eyebrow && <span className="modal__eyebrow mono">{eyebrow}</span>}
|
||
<h2 className="modal__title">{title}</h2>
|
||
</div>
|
||
<IconBtn label="Close" onClick={onClose}><Icon.Close /></IconBtn>
|
||
</header>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}>
|
||
<div className="detail">
|
||
<div className="detail__main">
|
||
{task.status === 'unsuccessful' && (
|
||
<div className="detail__alert detail__alert--warn">
|
||
<strong>Auto-marked Unsuccessful.</strong> Two missed bookings detected by the scheduler. Decide on next step or revert.
|
||
<div className="detail__alert-actions">
|
||
<button className="btn btn--ghost btn--sm">Revert to Open</button>
|
||
<button className="btn btn--soft btn--sm" onClick={onComplete}>Close as unsuccessful</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{task.status === 'billing' && (
|
||
<div className="detail__alert detail__alert--info">
|
||
<strong>Form re-routed to Billing.</strong> Originally captured as Service Booking — keywords matched billing template.
|
||
</div>
|
||
)}
|
||
|
||
<div className="detail__desc-section" style={{ marginBottom: '1.5rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||
<h3 className="detail__h" style={{ margin: 0 }}>Description</h3>
|
||
{!editingDesc && (
|
||
<IconBtn label="Edit description" onClick={() => setEditingDesc(true)}>
|
||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M12.5 3.5l-8 8V14h2.5l8-8-2.5-2.5z" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 5.5l2 2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</IconBtn>
|
||
)}
|
||
</div>
|
||
{editingDesc ? (
|
||
<div className="detail__desc-edit">
|
||
<textarea
|
||
className="field__textarea"
|
||
value={descValue}
|
||
onChange={e => setDescValue(e.target.value)}
|
||
autoFocus
|
||
rows={4}
|
||
/>
|
||
<div className="detail__alert-actions" style={{ marginTop: '0.5rem' }}>
|
||
<button className="btn btn--ghost btn--sm" onClick={() => { setDescValue(task.description || ''); setEditingDesc(false); }}>Cancel</button>
|
||
<button className="btn btn--primary btn--sm" onClick={() => { onEditDesc(descValue); setEditingDesc(false); }}>Save</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="detail__desc" style={{ marginTop: 0 }}>{task.description || <span className="mono" style={{opacity: 0.5}}>No description</span>}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="detail__notes">
|
||
<h3 className="detail__h">Notes</h3>
|
||
<ul className="notes">
|
||
{task.notes && task.notes.map(note => {
|
||
const noteAuthor = findUser(note.author_id);
|
||
return (
|
||
<li key={note.id} className="notes__item">
|
||
<span className="notes__bullet"><Icon.Pin /></span>
|
||
<div>
|
||
<div>{note.body}</div>
|
||
<div className="notes__meta mono">
|
||
{noteAuthor ? noteAuthor.name : 'Unknown'} · {fmtDateTime(note.created_at)}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
<div className="notes__add">
|
||
<input
|
||
className="field__input"
|
||
placeholder="Add a note…"
|
||
value={noteValue}
|
||
onChange={e => setNoteValue(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleAddNote(); }}
|
||
/>
|
||
<button className="btn btn--soft btn--sm" onClick={handleAddNote}>Add</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="detail__audit">
|
||
<h3 className="detail__h">Activity</h3>
|
||
<ol className="timeline">
|
||
{audit.slice().reverse().map((row, i) => (
|
||
<li key={i} className="timeline__row">
|
||
<div className="timeline__rail">
|
||
<span className="timeline__dot" data-actor={row.actor} />
|
||
</div>
|
||
<div className="timeline__body">
|
||
<div className="timeline__line">
|
||
<strong>
|
||
{row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name}
|
||
</strong>
|
||
<span> {row.action.replace(/_/g, ' ')}</span>
|
||
</div>
|
||
{(row.detail || row.summary) && <div className="timeline__detail">{row.detail || row.summary}</div>}
|
||
<div className="timeline__time mono">{fmtDateTime(row.at)}</div>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className="detail__side">
|
||
{task.status === 'closed' ? (
|
||
<button className="btn btn--ghost btn--full" style={{ marginBottom: '1.5rem' }} onClick={onReopen}>
|
||
<Icon.Arrow /> Reopen task
|
||
</button>
|
||
) : (
|
||
<button className="btn btn--primary btn--full" style={{ marginBottom: '1.5rem' }} onClick={onComplete}>
|
||
<Icon.Check /> Mark as completed
|
||
</button>
|
||
)}
|
||
|
||
<Field label="Assigned to">
|
||
<div className="picker">
|
||
{dbUsers.map(u => (
|
||
<button key={u.id}
|
||
className={"picker__item" + (task.assignee === u.id ? " is-on" : "")}
|
||
onClick={() => onMove(task.id, u.id)}>
|
||
<Avatar user={u} size={20} />
|
||
{u.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
|
||
<Field label="Priority">
|
||
<div className="picker">
|
||
{Object.entries(PRIORITY).map(([k, v]) => (
|
||
<button key={k}
|
||
className={"picker__item" + (task.priority === k ? " is-on" : "")}
|
||
onClick={() => onPriority(task.id, k)}>
|
||
<span className="prio__dot" style={{ background: v.dot }} />
|
||
{v.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
|
||
<Field label="Source">
|
||
<div className="meta-row">
|
||
{task.source === 'imessage' && <><Icon.iMessage /> iMessage from {author && author.name}</>}
|
||
{task.source === 'email' && <><span className="mono">✉</span> Auto-created from email</>}
|
||
{task.source === 'automation' && <><span className="mono">⚙</span> System automation</>}
|
||
{task.source === 'manual' && <>Created by {author && author.name}</>}
|
||
</div>
|
||
</Field>
|
||
|
||
<Field label="Added">
|
||
<div className="meta-row mono">{fmtDateTime(task.addedAt)}</div>
|
||
</Field>
|
||
|
||
<Field label="Reminder">
|
||
<div className="meta-row">Thu, May 7 · 4:00 pm</div>
|
||
</Field>
|
||
|
||
<div style={{ marginTop: '2rem', paddingTop: '1.5rem', borderTop: '1px solid var(--border-subtle)' }}>
|
||
<button
|
||
className="btn btn--ghost btn--full"
|
||
style={{ color: 'var(--prio-high-dot)', borderColor: 'transparent' }}
|
||
onClick={() => { if (confirm('Are you sure you want to permanently delete this task?')) onDeleteTask(); }}
|
||
>
|
||
<Icon.Close /> Delete task permanently
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }) {
|
||
return (
|
||
<div className="side-field">
|
||
<div className="side-field__label mono">{label}</div>
|
||
<div className="side-field__body">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="audit">
|
||
<header className="audit__head" style={{ gap: '20px' }}>
|
||
<div style={{ flex: 1, minWidth: '300px' }}>
|
||
<h1 className="audit__title">Audit log</h1>
|
||
<p className="audit__sub">History of the workspace · <span className="mono">last 7 days</span></p>
|
||
|
||
<div style={{ marginTop: '1rem', display: 'flex', gap: '8px' }}>
|
||
<div className="topbar__search" style={{ maxWidth: 'none', flex: 1 }}>
|
||
<Icon.Search />
|
||
<input
|
||
className="topbar__search-input"
|
||
placeholder="Search audit trail…"
|
||
value={auditSearch}
|
||
onChange={e => setAuditSearch(e.target.value)}
|
||
/>
|
||
{auditSearch && <IconBtn label="Clear" onClick={() => setAuditSearch('')}><Icon.Close /></IconBtn>}
|
||
</div>
|
||
|
||
<select
|
||
className="field__input"
|
||
style={{ width: '180px', padding: '0 8px', height: '32px', fontSize: '12.5px' }}
|
||
value={eventFilter}
|
||
onChange={e => setEventFilter(e.target.value)}
|
||
>
|
||
{EVENT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="audit__filter" style={{ alignSelf: 'flex-end' }}>
|
||
<FilterChip on={actorFilter==='all'} onClick={() => setActorFilter('all')}>All actors</FilterChip>
|
||
<FilterChip on={actorFilter==='system'} onClick={() => setActorFilter('system')}>System</FilterChip>
|
||
{dbUsers.map(u => (
|
||
<FilterChip key={u.id} on={actorFilter===u.id} onClick={() => setActorFilter(u.id)}>
|
||
<Avatar user={u} size={16} /> {u.name}
|
||
</FilterChip>
|
||
))}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="audit__list">
|
||
{Object.entries(groups).map(([day, rows]) => (
|
||
<div key={day} className="audit__group">
|
||
<div className="audit__day mono">{day}</div>
|
||
<ul className="audit__rows">
|
||
{rows.map(r => {
|
||
const actor = r.actor === 'system' ? null : findUser(r.actor);
|
||
return (
|
||
<li key={r.id} className="audit__row">
|
||
<span className="audit__time mono">{new Date(r.at).toLocaleTimeString('en-US',{hour:'numeric', minute:'2-digit'})}</span>
|
||
<span className="audit__actor">
|
||
{actor ? <Avatar user={actor} size={20} /> : <span className="audit__sys">SYS</span>}
|
||
<span>{actor ? actor.name : 'System'}</span>
|
||
</span>
|
||
<span className="audit__action mono">{r.action.replace(/_/g, ' ')}</span>
|
||
<span className="audit__summary">{r.summary}</span>
|
||
{r.target && (
|
||
<button className="audit__link" onClick={() => onOpen(r.target)}>open <Icon.Arrow /></button>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilterChip({ on, onClick, children }) {
|
||
return (
|
||
<button className={"filter-chip" + (on ? " is-on" : "")} onClick={onClick}>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Modal onClose={onClose} title="Account & settings" eyebrow={"Signed in as " + user.name} wide>
|
||
<div className="settings">
|
||
<nav className="settings__nav">
|
||
<button className={"settings__nav-item" + (tab==='profile'?' is-on':'')} onClick={() => setTab('profile')}>
|
||
<span className="settings__nav-icon">○</span> Profile
|
||
</button>
|
||
<button className={"settings__nav-item" + (tab==='security'?' is-on':'')} onClick={() => setTab('security')}>
|
||
<span className="settings__nav-icon">⊙</span> Password
|
||
</button>
|
||
<button className={"settings__nav-item" + (tab==='notifications'?' is-on':'')} onClick={() => setTab('notifications')}>
|
||
<span className="settings__nav-icon">◇</span> Notifications
|
||
</button>
|
||
<button className={"settings__nav-item" + (tab==='workspace'?' is-on':'')} onClick={() => setTab('workspace')}>
|
||
<span className="settings__nav-icon">▣</span> Workspace
|
||
</button>
|
||
<div className="settings__nav-foot">
|
||
<button className="settings__logout" onClick={onLogout}>
|
||
<Icon.Arrow /> Log out
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<div className="settings__body">
|
||
{tab === 'profile' && (
|
||
<>
|
||
<h3 className="settings__h">Profile picture</h3>
|
||
<div className="settings__photo">
|
||
<div className="settings__photo-preview" style={photo ? {backgroundImage:`url(${photo})`} : {}}>
|
||
{!photo && <Avatar user={{...user, name, initials: (name||'??').split(' ').map(s=>s[0]).join('').slice(0,2).toUpperCase()}} size={88} />}
|
||
</div>
|
||
<div className="settings__photo-actions">
|
||
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={handleFile} />
|
||
<button className="btn btn--soft btn--sm" onClick={() => fileInputRef.current && fileInputRef.current.click()}>
|
||
Upload photo
|
||
</button>
|
||
{photo && (
|
||
<button className="btn btn--ghost btn--sm" onClick={() => setPhoto(null)}>
|
||
Remove
|
||
</button>
|
||
)}
|
||
<p className="settings__hint mono">PNG or JPG, square crop recommended · max 2MB</p>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 className="settings__h">Personal details</h3>
|
||
<div className="settings__grid">
|
||
<label className="field">
|
||
<span className="field__label">Display name</span>
|
||
<input className="field__input" value={name} onChange={e => setName(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Role / title {!isAdmin && <span className="mono lock">— admin only</span>}</span>
|
||
<input className="field__input" value={role} onChange={e => setRole(e.target.value)} disabled={!isAdmin} />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Email</span>
|
||
<input className="field__input" value={email} onChange={e => setEmail(e.target.value)} placeholder="yourname@example.com" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Phone</span>
|
||
<input className="field__input" value={phone} onChange={e => setPhone(e.target.value)} placeholder="+64 ..." />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="settings__save-row">
|
||
{saved && <span className="settings__saved mono"><Icon.Check /> Saved</span>}
|
||
<button className="btn btn--ghost" onClick={() => { setName(user.name); setRole(user.role); setPhoto(user.photo||null); setEmail(user.email||''); setPhone(user.phone||''); }} disabled={!dirty}>Discard</button>
|
||
<button className="btn btn--primary" onClick={save} disabled={!dirty}>Save changes</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'security' && (
|
||
<>
|
||
<h3 className="settings__h">Change password</h3>
|
||
<div className="settings__col">
|
||
<label className="field">
|
||
<span className="field__label">Current password</span>
|
||
<input className="field__input" type="password" value={pwOld} onChange={e => setPwOld(e.target.value)} placeholder="••••••••" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">New password</span>
|
||
<input className="field__input" type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Confirm new password</span>
|
||
<input className="field__input" type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} />
|
||
</label>
|
||
|
||
<div className="settings__pw-strength">
|
||
<span className="settings__pw-bar" data-strength={pwNew.length > 11 ? 'strong' : pwNew.length > 7 ? 'med' : pwNew.length > 0 ? 'weak' : 'none'} />
|
||
<span className="mono settings__pw-label">
|
||
{pwNew.length === 0 ? 'enter a new password' : pwNew.length > 11 ? 'strong' : pwNew.length > 7 ? 'medium — add a symbol or number' : 'weak — needs more characters'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{pwError && <div className="settings__pw-error mono" style={{ color: 'var(--prio-high-dot)', marginTop: '0.5rem' }}>{pwError}</div>}
|
||
|
||
<div className="settings__save-row">
|
||
{pwSaved && <span className="settings__saved mono"><Icon.Check /> Password updated</span>}
|
||
<button className="btn btn--ghost" onClick={() => { setPwOld(''); setPwNew(''); setPwConfirm(''); setPwError(''); }}>Cancel</button>
|
||
<button className="btn btn--primary" disabled={!pwOld || !pwNew || pwNew !== pwConfirm || pwBusy} onClick={submitPasswordChange}>{pwBusy ? 'Updating…' : 'Update password'}</button>
|
||
</div>
|
||
|
||
<div className="settings__divider" />
|
||
<h3 className="settings__h">Sign-in & sessions</h3>
|
||
<ul className="settings__sessions">
|
||
<li className="settings__session">
|
||
<div>
|
||
<div className="settings__session-where">MacBook · Chrome · this device</div>
|
||
<div className="settings__session-when mono">Active now · Christchurch, NZ</div>
|
||
</div>
|
||
<span className="chip">current</span>
|
||
</li>
|
||
<li className="settings__session">
|
||
<div>
|
||
<div className="settings__session-where">iPhone · Safari</div>
|
||
<div className="settings__session-when mono">Last active 2h ago</div>
|
||
</div>
|
||
<button className="btn btn--ghost btn--sm">Sign out</button>
|
||
</li>
|
||
</ul>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'notifications' && (
|
||
<>
|
||
<h3 className="settings__h">When should we ping you?</h3>
|
||
<ul className="settings__toggles">
|
||
<ToggleRow label="A new task is assigned to me" defaultOn />
|
||
<ToggleRow label="Someone @mentions me in a note" defaultOn />
|
||
<ToggleRow label="A task I created is completed" />
|
||
<ToggleRow label="Daily digest at 8am" defaultOn />
|
||
<ToggleRow label="Heads-up alerts (auto-flagged tasks)" defaultOn />
|
||
</ul>
|
||
|
||
<h3 className="settings__h">Channels</h3>
|
||
<ul className="settings__toggles">
|
||
<ToggleRow label="In-app heads-up" defaultOn />
|
||
<ToggleRow label="Push to phone (Dashy app)" defaultOn />
|
||
<ToggleRow label="Email" />
|
||
<ToggleRow label="iMessage relay" />
|
||
</ul>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'workspace' && (
|
||
<WorkspaceTab
|
||
user={user} isAdmin={isAdmin} dbUsers={dbUsers}
|
||
onSwitchUser={onSwitchUser}
|
||
onCreateUser={onCreateUser}
|
||
onDeleteUser={onDeleteUser}
|
||
onUpdateUserRole={onUpdateUserRole}
|
||
workspace={workspace}
|
||
onUpdateWorkspace={onUpdateWorkspace}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function ToggleRow({ label, defaultOn = false }) {
|
||
const [on, setOn] = React.useState(defaultOn);
|
||
return (
|
||
<li className="toggle-row">
|
||
<span>{label}</span>
|
||
<button className={"switch" + (on ? " is-on" : "")} onClick={() => setOn(v => !v)} aria-pressed={on}>
|
||
<span className="switch__thumb" />
|
||
</button>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<h3 className="settings__h">Switch user</h3>
|
||
<p className="settings__sub">Test other accounts without signing out.</p>
|
||
<div className="settings__users">
|
||
{dbUsers.map(u => (
|
||
<button key={u.id} className={"login__user" + (u.id===user.id?" is-picked":"")} onClick={() => onSwitchUser(u.id)}>
|
||
<Avatar user={u} size={36} />
|
||
<div className="login__user-meta">
|
||
<span className="login__user-name">
|
||
{u.name}
|
||
{u.account_type === 'admin' && <span className="admin-badge">●</span>}
|
||
</span>
|
||
<span className="login__user-role">{u.account_type === 'admin' ? 'Admin · ' : ''}{u.role}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="settings__divider" />
|
||
|
||
<div className="settings__h-row">
|
||
<h3 className="settings__h">Team members {!isAdmin && <span className="mono lock">— admin only</span>}</h3>
|
||
{isAdmin && !adding && (
|
||
<button className="btn btn--soft btn--sm" onClick={() => setAdding(true)}>+ Add user</button>
|
||
)}
|
||
</div>
|
||
|
||
{isAdmin && adding && (
|
||
<div className="member-form">
|
||
<div className="settings__grid">
|
||
<label className="field">
|
||
<span className="field__label">Name</span>
|
||
<input className="field__input" value={newName} onChange={e => setNewName(e.target.value)} autoFocus placeholder="Jamie Smith" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Role / title</span>
|
||
<input className="field__input" value={newRole} onChange={e => setNewRole(e.target.value)} placeholder="Apprentice" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Email</span>
|
||
<input className="field__input" value={newEmail} onChange={e => setNewEmail(e.target.value)} placeholder="jamie@murchison-auto.co" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Phone</span>
|
||
<input className="field__input" value={newPhone} onChange={e => setNewPhone(e.target.value)} placeholder="+64 27 ..." />
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<span className="field__label">Account type</span>
|
||
<div className="picker">
|
||
<button type="button" className={"picker__item"+(newType==='standard'?' is-on':'')} onClick={() => setNewType('standard')}>Standard</button>
|
||
<button type="button" className={"picker__item"+(newType==='admin'?' is-on':'')} onClick={() => setNewType('admin')}>Admin</button>
|
||
</div>
|
||
<span className="settings__hint mono">
|
||
{newType === 'admin'
|
||
? 'Can see audit log, manage users, edit roles & workspace'
|
||
: 'Can manage own tasks; cannot see logs or DB'}
|
||
</span>
|
||
</div>
|
||
<div className="settings__save-row">
|
||
<button className="btn btn--ghost" onClick={() => setAdding(false)}>Cancel</button>
|
||
<button className="btn btn--primary" onClick={submit} disabled={!newName.trim()}>Add user</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ul className="member-list">
|
||
{dbUsers.map(u => (
|
||
<li key={u.id} className="member-row">
|
||
<Avatar user={u} size={32} />
|
||
<div className="member-row__meta" style={{ flex: 1 }}>
|
||
{editingUserId === u.id ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||
<input className="field__input field__input--sm" value={editName} onChange={e => setEditName(e.target.value)} autoFocus />
|
||
<input className="field__input field__input--sm" value={editRole} onChange={e => setEditRole(e.target.value)} />
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="member-row__name" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
{u.name}
|
||
{isAdmin && (
|
||
<button className="btn btn--ghost btn--sm" style={{ padding: '2px', height: 'auto', minWidth: 0, opacity: 0.5 }} onClick={() => startEditing(u)}>
|
||
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M12.5 3.5l-8 8V14h2.5l8-8-2.5-2.5z" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 5.5l2 2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="member-row__role mono">{u.role} · {u.email || (u.id + '@murchison-auto.co')}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
{editingUserId === u.id ? (
|
||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||
<button className="btn btn--ghost btn--sm" onClick={cancelEditing}>Cancel</button>
|
||
<button className="btn btn--primary btn--sm" onClick={() => saveUserEdit(u.id)}>Save</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{isAdmin ? (
|
||
<div className="picker member-row__type">
|
||
<button className={"picker__item"+(u.account_type==='standard'?' is-on':'')}
|
||
onClick={() => onUpdateUserRole(u.id, { account_type: 'standard' })}>Standard</button>
|
||
<button className={"picker__item"+(u.account_type==='admin'?' is-on':'')}
|
||
onClick={() => onUpdateUserRole(u.id, { account_type: 'admin' })}>Admin</button>
|
||
</div>
|
||
) : (
|
||
<span className="chip">{u.account_type}</span>
|
||
)}
|
||
{isAdmin && u.id !== user.id && (
|
||
<button className="member-row__del" onClick={() => {
|
||
if (confirm('Remove ' + u.name + '? Their tasks will move to ROD.')) onDeleteUser(u.id);
|
||
}} title="Remove user">×</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div className="settings__divider" />
|
||
<h3 className="settings__h">Workspace {!isAdmin && <span className="mono lock">— admin only</span>}</h3>
|
||
<div className="settings__grid">
|
||
<label className="field">
|
||
<span className="field__label">Workspace name</span>
|
||
<input className="field__input" value={wsName} onChange={e => setWsName(e.target.value)} disabled={!isAdmin} />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field__label">Timezone</span>
|
||
<input className="field__input" value={wsTz} onChange={e => setWsTz(e.target.value)} disabled={!isAdmin} />
|
||
</label>
|
||
</div>
|
||
{isAdmin && (
|
||
<div className="settings__save-row" style={{ marginTop: '1rem' }}>
|
||
{wsSaved && <span className="settings__saved mono"><Icon.Check /> Saved</span>}
|
||
<button className="btn btn--ghost" onClick={() => { setWsName(workspace.name); setWsTz(workspace.timezone); }} disabled={!wsDirty}>Discard</button>
|
||
<button className="btn btn--primary" onClick={handleUpdateWorkspace} disabled={!wsDirty}>Update workspace</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DeletedScreen({ tasks, onRestore }) {
|
||
return (
|
||
<div className="audit">
|
||
<header className="audit__head">
|
||
<div>
|
||
<h1 className="audit__title">Trash</h1>
|
||
<p className="audit__sub">Recently deleted tasks · <span className="mono">Admins only</span></p>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="audit__list">
|
||
{tasks.length === 0 ? (
|
||
<div className="column__empty" style={{ marginTop: '4rem' }}>
|
||
<span className="mono">— trash is empty —</span>
|
||
</div>
|
||
) : (
|
||
<div className="user-view__grid" style={{ padding: '0 2rem' }}>
|
||
{tasks.map(t => (
|
||
<div key={t.id} style={{ position: 'relative' }}>
|
||
<TaskCard task={t} />
|
||
<button
|
||
className="btn btn--primary btn--sm"
|
||
style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}
|
||
onClick={() => onRestore(t.id)}
|
||
>
|
||
Restore
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
Object.assign(window, {
|
||
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
||
SettingsScreen,
|
||
DeletedScreen,
|
||
});
|