467 lines
16 KiB
React
467 lines
16 KiB
React
// Dashy — main app (API-backed)
|
|
|
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
"theme": "light",
|
|
"accent": "#2A6FDB",
|
|
"density": "cozy",
|
|
"showTags": true
|
|
}/*EDITMODE-END*/;
|
|
|
|
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
|
|
|
|
function useApiData(authed) {
|
|
const [data, setData] = React.useState({ tasks: [], users: [], audit: [], workspace: null, deletedTasks: [] });
|
|
const [loading, setLoading] = React.useState(true);
|
|
|
|
React.useEffect(() => {
|
|
let mounted = true;
|
|
const load = async () => {
|
|
try {
|
|
if (!authed) {
|
|
// Fetch only public data (workspace info and user list for login)
|
|
const [users, workspace] = await Promise.all([
|
|
api.getUsers().catch(() => []),
|
|
api.getWorkspace().catch(() => null)
|
|
]);
|
|
if (mounted) {
|
|
setData(prev => ({ ...prev, users, workspace }));
|
|
setLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const [tasks, users, audit, workspace, deletedTasks] = await Promise.all([
|
|
api.getTasks(),
|
|
api.getUsers(),
|
|
api.getAudit(),
|
|
api.getWorkspace(),
|
|
api.getDeletedTasks().catch(() => [])
|
|
]);
|
|
if (mounted) {
|
|
setData({ tasks, users, audit, workspace, deletedTasks });
|
|
setLoading(false);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load data:", e);
|
|
}
|
|
};
|
|
|
|
load();
|
|
const off = api.subscribe(load);
|
|
return () => { mounted = false; off(); };
|
|
}, [authed]);
|
|
|
|
return { ...data, loading };
|
|
}
|
|
|
|
function App() {
|
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
|
const [authed, setAuthed] = React.useState(!!api.token);
|
|
// Optional: Decode token to get meId if needed, but for prototype we just assume rod if not known yet,
|
|
// or decode JWT using a simple base64 parse.
|
|
const [meId, setMeId] = React.useState(() => {
|
|
if (api.token) {
|
|
try {
|
|
return JSON.parse(atob(api.token.split('.')[1])).sub;
|
|
} catch(e) {}
|
|
}
|
|
return 'rod';
|
|
});
|
|
|
|
const { tasks, users: dbUsers, audit, workspace, deletedTasks, loading } = useApiData(authed);
|
|
const [tab, setTab] = React.useState('overview');
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
const [showSearch, setShowSearch] = React.useState(false);
|
|
|
|
// Map API fields to frontend component expectations
|
|
const frontendTasks = React.useMemo(() => tasks.map(t => ({
|
|
...t,
|
|
assignee: t.assignee_id,
|
|
addedBy: t.added_by,
|
|
addedAt: t.added_at,
|
|
tags: t.tags.map(tagObj => tagObj.tag)
|
|
})), [tasks]);
|
|
|
|
const filteredTasks = React.useMemo(() => {
|
|
if (!searchQuery.trim()) return frontendTasks;
|
|
const q = searchQuery.toLowerCase();
|
|
return frontendTasks.filter(t =>
|
|
t.title.toLowerCase().includes(q) ||
|
|
(t.description && t.description.toLowerCase().includes(q)) ||
|
|
t.tags.some(tag => tag.toLowerCase().includes(q))
|
|
);
|
|
}, [frontendTasks, searchQuery]);
|
|
|
|
const frontendAudit = React.useMemo(() => audit.map(a => ({
|
|
...a,
|
|
actor: a.actor,
|
|
action: a.action,
|
|
summary: a.summary,
|
|
target: a.target,
|
|
at: a.at
|
|
})), [audit]);
|
|
|
|
React.useEffect(() => {
|
|
window.dbUsers = dbUsers;
|
|
window.workspace = workspace;
|
|
}, [dbUsers, workspace]);
|
|
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e) => {
|
|
const isTyping = ['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.isContentEditable;
|
|
|
|
if (e.key === 'Escape') {
|
|
setAdding(null);
|
|
setOpenTaskId(null);
|
|
setShowLogs(false);
|
|
setShowSettings(false);
|
|
} else if (e.key.toLowerCase() === 'n' && !isTyping) {
|
|
e.preventDefault();
|
|
setAdding(meId);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [meId]);
|
|
|
|
const [adding, setAdding] = React.useState(null);
|
|
const [openTaskId, setOpenTaskId] = React.useState(null);
|
|
const [showLogs, setShowLogs] = React.useState(false);
|
|
const [showSettings, setShowSettings] = React.useState(false);
|
|
|
|
const [headsUp, setHeadsUp] = React.useState([
|
|
{ id: 'h1', kind: 'unsuccessful', taskId: 't7',
|
|
title: 'WO #2188 auto-marked Unsuccessful',
|
|
sub: 'Two missed bookings — assigned to Kirra for review' },
|
|
{ id: 'h2', kind: 'billing', taskId: 't4',
|
|
title: 'Form response switched to Billing',
|
|
sub: 'K. Wynne · originally Service Booking' },
|
|
]);
|
|
|
|
if (!authed) {
|
|
return <LoginScreen dbUsers={dbUsers} workspace={workspace} onLogin={async (id, pwd) => {
|
|
const data = await api.login(id, pwd);
|
|
// Extract actual User ID from token payload
|
|
try {
|
|
const payload = JSON.parse(atob(data.access_token.split('.')[1]));
|
|
setMeId(payload.sub);
|
|
} catch(e) {
|
|
setMeId(id);
|
|
}
|
|
setAuthed(true);
|
|
api.addAudit({ actor: id, action: 'login', summary: 'Signed in' }).catch(console.error);
|
|
}} />;
|
|
}
|
|
|
|
if (loading) return <BootSplash />;
|
|
|
|
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
|
|
const merge = (id) => {
|
|
const base = findUser(id);
|
|
if (!base && !userMap[id]) return null;
|
|
const live = userMap[id] || {};
|
|
if (!base) {
|
|
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
|
|
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
|
}
|
|
return { ...base, name: live.name || base.name, role: live.role || base.role,
|
|
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
|
};
|
|
const me = merge(meId) || merge('rod');
|
|
const isAdmin = me && me.account_type === 'admin';
|
|
const openTask = tasks.find(x => x.id === openTaskId);
|
|
|
|
const addTask = async ({ title, description, assignee, priority }) => {
|
|
try {
|
|
await api.createTask({
|
|
title, description, assignee_id: assignee, priority,
|
|
added_by: meId, source: 'manual', status: 'open', tags: []
|
|
});
|
|
setAdding(null);
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to create task");
|
|
}
|
|
};
|
|
|
|
const moveTask = async (taskId, toUserId, position = null, status = null) => {
|
|
try {
|
|
const updates = { assignee_id: toUserId };
|
|
if (position !== null) updates.position = position;
|
|
if (status !== null) updates.status = status;
|
|
await api.updateTask(taskId, updates);
|
|
|
|
const u = (merge(toUserId)||{}).name;
|
|
const summary = status ? `Moved task to ${u} and set to ${status}` : `Moved task to ${u}`;
|
|
await api.addAudit({ actor: meId, action: 'task_moved', summary, target: taskId });
|
|
} catch(e) {}
|
|
};
|
|
|
|
const setPriority = async (taskId, p) => {
|
|
try {
|
|
await api.updateTask(taskId, { priority: p });
|
|
} catch(e) {}
|
|
};
|
|
|
|
const completeTask = async (taskId) => {
|
|
try {
|
|
await api.updateTask(taskId, { status: 'closed' });
|
|
await api.addAudit({ actor: meId, action: 'task_completed', summary: 'Marked task as completed', target: taskId });
|
|
setOpenTaskId(null);
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to complete task: " + e.message);
|
|
}
|
|
};
|
|
|
|
const reopenTask = async (taskId) => {
|
|
try {
|
|
await api.updateTask(taskId, { status: 'open' });
|
|
await api.addAudit({ actor: meId, action: 'task_reopened', summary: 'Reopened task', target: taskId });
|
|
setOpenTaskId(null);
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to reopen task: " + e.message);
|
|
}
|
|
};
|
|
|
|
const editTaskDesc = async (taskId, newDesc) => {
|
|
try {
|
|
await api.updateTask(taskId, { description: newDesc });
|
|
await api.addAudit({ actor: meId, action: 'task_edited', summary: 'Updated task description', target: taskId });
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to update description: " + e.message);
|
|
}
|
|
};
|
|
|
|
const deleteTask = async (taskId) => {
|
|
try {
|
|
const t = tasks.find(x => x.id === taskId);
|
|
await api.deleteTask(taskId);
|
|
await api.addAudit({ actor: meId, action: 'task_deleted', summary: 'Permanently deleted task' + (t ? ': ' + t.title : ''), target: taskId });
|
|
setOpenTaskId(null);
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to delete task: " + e.message);
|
|
}
|
|
};
|
|
|
|
const restoreTask = async (taskId) => {
|
|
try {
|
|
await api.restoreTask(taskId);
|
|
await api.addAudit({ actor: meId, action: 'task_restored', summary: 'Restored task from trash', target: taskId });
|
|
if (tab === 'deleted') setTab('overview');
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to restore task: " + e.message);
|
|
}
|
|
};
|
|
|
|
const addNote = async (taskId, body) => {
|
|
try {
|
|
await api.createTaskNote(taskId, body);
|
|
await api.addAudit({ actor: meId, action: 'note_added', summary: 'Added a note to the task', target: taskId });
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to add note");
|
|
}
|
|
};
|
|
|
|
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
|
|
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
|
|
|
|
const mappedOpenTask = frontendTasks.find(x => x.id === openTaskId);
|
|
|
|
return (
|
|
<div className="app">
|
|
<TopBar
|
|
me={me}
|
|
dbUsers={dbUsers}
|
|
isAdmin={isAdmin}
|
|
tab={tab}
|
|
setTab={setTab}
|
|
onAdd={() => setAdding(meId)}
|
|
onLogs={() => setShowLogs(true)}
|
|
onProfile={() => setShowSettings(true)}
|
|
workspace={workspace}
|
|
searchQuery={searchQuery}
|
|
setSearchQuery={setSearchQuery}
|
|
showSearch={showSearch}
|
|
onToggleSearch={() => { setShowSearch(!showSearch); if (showSearch) setSearchQuery(''); }}
|
|
/>
|
|
|
|
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
|
|
|
|
<main className="main">
|
|
{tab === 'overview' && (
|
|
<OverviewScreen
|
|
tasks={filteredTasks} density={t.density}
|
|
dbUsers={dbUsers}
|
|
onOpen={(task) => setOpenTaskId(task.id)}
|
|
onAddFor={(uid) => setAdding(uid)}
|
|
onMoveTask={moveTask}
|
|
/>
|
|
)}
|
|
{tab === 'deleted' && (
|
|
<DeletedScreen
|
|
tasks={deletedTasks.map(t => ({
|
|
...t,
|
|
assignee: t.assignee_id,
|
|
addedBy: t.added_by,
|
|
addedAt: t.added_at,
|
|
tags: t.tags.map(tagObj => tagObj.tag)
|
|
}))}
|
|
onRestore={restoreTask}
|
|
/>
|
|
)}
|
|
{tab === 'calendar' && (
|
|
<CallyScreen />
|
|
)}
|
|
{tab !== 'overview' && tab !== 'deleted' && tab !== 'calendar' && (
|
|
<UserScreen
|
|
user={merge(tab)} tasks={filteredTasks} density={t.density}
|
|
onOpen={(task) => setOpenTaskId(task.id)}
|
|
onAddFor={(uid) => setAdding(uid)}
|
|
onMoveTask={moveTask}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} dbUsers={dbUsers} />
|
|
{mappedOpenTask && (
|
|
<TaskDetail task={mappedOpenTask} allAudit={frontendAudit} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} onComplete={() => completeTask(mappedOpenTask.id)} onReopen={() => reopenTask(mappedOpenTask.id)} onEditDesc={(newDesc) => editTaskDesc(mappedOpenTask.id, newDesc)} onDeleteTask={() => deleteTask(mappedOpenTask.id)} onAddNote={(body) => addNote(mappedOpenTask.id, body)} />
|
|
)}
|
|
{showLogs && (
|
|
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
|
|
<AuditScreen entries={frontendAudit} onOpen={openTaskFromAnywhere} />
|
|
</Modal>
|
|
)}
|
|
{showSettings && (
|
|
<SettingsScreen
|
|
user={me}
|
|
dbUsers={dbUsers}
|
|
isAdmin={isAdmin}
|
|
onClose={() => setShowSettings(false)}
|
|
onSave={async (edits) => {
|
|
try {
|
|
await api.updateUser(meId, edits);
|
|
setShowSettings(false);
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Failed to save changes: " + e.message);
|
|
}
|
|
}}
|
|
onLogout={() => {
|
|
api.logout();
|
|
setAuthed(false);
|
|
setShowSettings(false);
|
|
}}
|
|
onSwitchUser={async (id) => {
|
|
try {
|
|
await api.login(id, "password123");
|
|
setMeId(id);
|
|
setShowSettings(false);
|
|
} catch(e) {
|
|
alert("Failed to switch user");
|
|
}
|
|
}}
|
|
onCreateUser={async (u) => {
|
|
try {
|
|
const id = u.name.split(' ')[0].toLowerCase() + Math.floor(Math.random()*100);
|
|
await api.createUser({
|
|
id,
|
|
name: u.name,
|
|
role: u.role,
|
|
email: u.email,
|
|
phone: u.phone,
|
|
hue: Math.floor(Math.random() * 360),
|
|
initials: u.name.split(' ').map(s=>s[0]).join('').slice(0,2).toUpperCase(),
|
|
account_type: u.account_type,
|
|
password: "password123"
|
|
});
|
|
await api.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id });
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to create user: " + e.message);
|
|
}
|
|
}}
|
|
onDeleteUser={async (id) => {
|
|
try {
|
|
const u = userMap[id];
|
|
await api.deleteUser(id);
|
|
await api.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null });
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to delete user: " + e.message);
|
|
}
|
|
}}
|
|
onUpdateUserRole={async (id, edits) => {
|
|
try {
|
|
await api.updateUser(id, edits);
|
|
await api.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null });
|
|
} catch(e) {
|
|
console.error(e);
|
|
alert("Failed to update user: " + e.message);
|
|
}
|
|
}}
|
|
onChangePassword={async (oldPwd, newPwd) => {
|
|
await api.changePassword(meId, oldPwd, newPwd);
|
|
await api.addAudit({ actor: meId, action: 'password_changed', summary: 'Updated password', target: meId });
|
|
}}
|
|
workspace={workspace}
|
|
onUpdateWorkspace={async (edits) => {
|
|
try {
|
|
await api.updateWorkspace(edits);
|
|
await api.addAudit({ actor: meId, action: 'workspace_updated', summary: 'Updated workspace settings', target: null });
|
|
} catch(e) {
|
|
alert("Failed to update workspace: " + e.message);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<DashyTweaks t={t} setTweak={setTweak} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BootSplash() {
|
|
return (
|
|
<div className="boot">
|
|
<div className="boot__pulse" />
|
|
<div className="boot__label mono">loading from API…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DashyTweaks({ t, setTweak }) {
|
|
return (
|
|
<TweaksPanel title="Tweaks">
|
|
<TweakSection title="Appearance">
|
|
<TweakRadio label="Theme" value={t.theme}
|
|
options={[{value:'light',label:'Light'},{value:'dark',label:'Dark'}]}
|
|
onChange={v => setTweak('theme', v)} />
|
|
<TweakColor label="Accent" value={t.accent} options={ACCENTS} onChange={v => setTweak('accent', v)} />
|
|
<TweakRadio label="Density" value={t.density}
|
|
options={[{value:'compact',label:'Compact'},{value:'cozy',label:'Cozy'}]}
|
|
onChange={v => setTweak('density', v)} />
|
|
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
|
|
</TweakSection>
|
|
</TweaksPanel>
|
|
);
|
|
}
|
|
|
|
function ThemeBridge() {
|
|
const [t] = useTweaks(TWEAK_DEFAULTS);
|
|
React.useEffect(() => {
|
|
document.documentElement.dataset.theme = t.theme;
|
|
document.documentElement.style.setProperty('--accent', t.accent);
|
|
document.documentElement.dataset.showTags = t.showTags ? 'on' : 'off';
|
|
}, [t.theme, t.accent, t.showTags]);
|
|
return null;
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<><ThemeBridge /><App /></>);
|