Fixed react refresh bug and fixed white background when clicking on task which was a TASK_AUDIT hangover from react local storagedb now pointing too python fastapi
This commit is contained in:
+1
-3
@@ -13,10 +13,8 @@
|
|||||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="db.js"></script>
|
|
||||||
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
<script type="text/babel" src="data.jsx"></script>
|
|
||||||
<script type="text/babel" src="components.jsx"></script>
|
<script type="text/babel" src="components.jsx"></script>
|
||||||
<script type="text/babel" src="screens.jsx"></script>
|
<script type="text/babel" src="screens.jsx"></script>
|
||||||
<script type="text/babel" src="app.jsx"></script>
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
|||||||
+10
-10
@@ -33,21 +33,21 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🚧 Current Status
|
## 🚧 Current Status
|
||||||
- **Backend:** Feature-complete for the first phase. Ready for testing and integration.
|
- **Backend:** Feature-complete for the first phase.
|
||||||
- **Database:** Schema is stabilized and seeding logic is verified.
|
- **Database:** Schema is stabilized, seeding logic is verified, and database is active.
|
||||||
- **Frontend:** Currently still using the old `db.js` (WASM SQLite) layer.
|
- **Frontend:** Integrated with FastAPI backend via `api.js`. Legacy WASM SQLite files archived.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⏭️ Upcoming Steps
|
## ⏭️ Upcoming Steps
|
||||||
|
|
||||||
### Phase 2: Frontend Refactor
|
### Phase 2: Frontend Refactor (✅ Completed)
|
||||||
1. **API Service:** Create `src/api.js` to handle all network requests to the FastAPI backend.
|
1. **API Service:** Created `api.js` to handle all network requests to the FastAPI backend.
|
||||||
2. **Authentication Hook:** Update the Login screen to use real JWT tokens.
|
2. **Authentication Hook:** Updated the Login screen to use real JWT tokens.
|
||||||
3. **Component Updates:**
|
3. **Component Updates:**
|
||||||
- Swap `DashyDB` calls for `async` API calls.
|
- Swapped `DashyDB` calls for `async` API calls in `app.jsx`.
|
||||||
- Implement "Loading" states for UI responsiveness during network calls.
|
- Implemented "Loading" states for UI responsiveness during network calls.
|
||||||
4. **Cleanup:** Remove `db.js`, `data.jsx`, and the `sql.js` WASM dependency.
|
4. **Cleanup:** Archived `db.js`, `data.jsx`, and removed `sql.js` WASM dependency from `Dashy.html`.
|
||||||
|
|
||||||
### Phase 3: Advanced Features
|
### Phase 3: Advanced Features
|
||||||
- **Real-time Notifications:** Explore WebSockets for task assignments.
|
- **Real-time Notifications:** Explore WebSockets for task assignments.
|
||||||
@@ -57,4 +57,4 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** Monday, May 11, 2026
|
**Last Updated:** Monday, May 11, 2026
|
||||||
**Status:** Backend Operational / Awaiting Frontend Integration
|
**Status:** Phase 2 Complete / Ready for Phase 3
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Dashy — main app (DB-backed via SQLite/WASM)
|
// Dashy — main app (API-backed)
|
||||||
|
|
||||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
@@ -9,28 +9,59 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|||||||
|
|
||||||
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
|
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
|
||||||
|
|
||||||
function useDashyDB() {
|
function useApiData(authed) {
|
||||||
const [ready, setReady] = React.useState(DashyDB.isReady);
|
const [data, setData] = React.useState({ tasks: [], users: [], audit: [] });
|
||||||
const [, force] = React.useReducer(x => x + 1, 0);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!DashyDB.isReady) DashyDB.init().then(() => setReady(true));
|
if (!authed) return;
|
||||||
const off = DashyDB.subscribe(() => force());
|
|
||||||
return off;
|
let mounted = true;
|
||||||
}, []);
|
const load = async () => {
|
||||||
return ready;
|
try {
|
||||||
|
const [tasks, users, audit] = await Promise.all([
|
||||||
|
api.getTasks(),
|
||||||
|
api.getUsers(),
|
||||||
|
api.getAudit()
|
||||||
|
]);
|
||||||
|
if (mounted) {
|
||||||
|
setData({ tasks, users, audit });
|
||||||
|
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() {
|
function App() {
|
||||||
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
const ready = useDashyDB();
|
const [authed, setAuthed] = React.useState(!!api.token);
|
||||||
const [authed, setAuthed] = React.useState(false);
|
// Optional: Decode token to get meId if needed, but for prototype we just assume rod if not known yet,
|
||||||
const [meId, setMeId] = React.useState('rod');
|
// 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, loading } = useApiData(authed);
|
||||||
const [tab, setTab] = React.useState('overview');
|
const [tab, setTab] = React.useState('overview');
|
||||||
const [adding, setAdding] = React.useState(null);
|
const [adding, setAdding] = React.useState(null);
|
||||||
const [openTaskId, setOpenTaskId] = React.useState(null);
|
const [openTaskId, setOpenTaskId] = React.useState(null);
|
||||||
const [showLogs, setShowLogs] = React.useState(false);
|
const [showLogs, setShowLogs] = React.useState(false);
|
||||||
const [showSettings, setShowSettings] = React.useState(false);
|
const [showSettings, setShowSettings] = React.useState(false);
|
||||||
const [showDB, setShowDB] = React.useState(false);
|
|
||||||
const [headsUp, setHeadsUp] = React.useState([
|
const [headsUp, setHeadsUp] = React.useState([
|
||||||
{ id: 'h1', kind: 'unsuccessful', taskId: 't7',
|
{ id: 'h1', kind: 'unsuccessful', taskId: 't7',
|
||||||
title: 'WO #2188 auto-marked Unsuccessful',
|
title: 'WO #2188 auto-marked Unsuccessful',
|
||||||
@@ -40,56 +71,91 @@ function App() {
|
|||||||
sub: 'K. Wynne · originally Service Booking' },
|
sub: 'K. Wynne · originally Service Booking' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!ready) return <BootSplash />;
|
if (!authed) {
|
||||||
|
return <LoginScreen onLogin={async (id, pwd = "password123") => {
|
||||||
|
try {
|
||||||
|
await api.login(id, pwd);
|
||||||
|
setMeId(id);
|
||||||
|
setAuthed(true);
|
||||||
|
// Fire & forget audit log
|
||||||
|
api.addAudit({ actor: id, action: 'login', summary: 'Signed in' }).catch(console.error);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Login failed: " + e.message);
|
||||||
|
}
|
||||||
|
}} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <BootSplash />;
|
||||||
|
|
||||||
const tasks = DashyDB.listTasks();
|
|
||||||
const audit = DashyDB.listAudit();
|
|
||||||
const dbUsers = DashyDB.listUsers();
|
|
||||||
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
|
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
|
||||||
const merge = (id) => {
|
const merge = (id) => {
|
||||||
const base = findUser(id); if (!base && !userMap[id]) return null;
|
const base = findUser(id);
|
||||||
|
if (!base && !userMap[id]) return null;
|
||||||
const live = userMap[id] || {};
|
const live = userMap[id] || {};
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// user added at runtime
|
|
||||||
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
|
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
|
||||||
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
||||||
}
|
}
|
||||||
return { ...base, name: live.name || base.name, role: live.role || base.role,
|
return { ...base, name: live.name || base.name, role: live.role || base.role,
|
||||||
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
||||||
};
|
};
|
||||||
const me = merge(meId);
|
const me = merge(meId) || merge('rod');
|
||||||
const isAdmin = me && me.account_type === 'admin';
|
const isAdmin = me && me.account_type === 'admin';
|
||||||
const openTask = tasks.find(x => x.id === openTaskId);
|
const openTask = tasks.find(x => x.id === openTaskId);
|
||||||
|
|
||||||
const handleLogin = (id) => {
|
const addTask = async ({ title, description, assignee, priority }) => {
|
||||||
setMeId(id); setAuthed(true);
|
try {
|
||||||
DashyDB.addAudit({
|
const t = await api.createTask({
|
||||||
actor: id, action: 'login',
|
title, description, assignee_id: assignee, priority,
|
||||||
summary: (merge(id) || {}).name + ' signed in',
|
added_by: meId, source: 'manual', status: 'open', tags: []
|
||||||
});
|
});
|
||||||
};
|
await api.addAudit({
|
||||||
|
actor: meId, action: 'task_created',
|
||||||
const addTask = ({ title, description, assignee, priority }) => {
|
|
||||||
const id = 't_' + Date.now().toString(36);
|
|
||||||
const at = new Date('2026-05-08T10:30:00').toISOString();
|
|
||||||
DashyDB.createTask({
|
|
||||||
id, title, description, assignee, priority,
|
|
||||||
addedBy: meId, source: 'manual', status: 'open', addedAt: at, tags: []
|
|
||||||
});
|
|
||||||
DashyDB.addAudit({
|
|
||||||
at, actor: meId, action: 'task_created',
|
|
||||||
summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name,
|
summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name,
|
||||||
target: id
|
target: t.id
|
||||||
});
|
});
|
||||||
setAdding(null);
|
setAdding(null);
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to create task");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveTask = async (taskId, toUserId) => {
|
||||||
|
try {
|
||||||
|
await api.updateTask(taskId, { assignee_id: toUserId });
|
||||||
|
await api.addAudit({ actor: meId, action: 'task_moved', summary: 'Moved task to ' + (merge(toUserId)||{}).name, target: taskId });
|
||||||
|
} catch(e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPriority = async (taskId, p) => {
|
||||||
|
try {
|
||||||
|
await api.updateTask(taskId, { priority: p });
|
||||||
|
} catch(e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveTask = (taskId, toUserId) => DashyDB.moveTask(taskId, toUserId, meId);
|
|
||||||
const setPriority = (taskId, p) => DashyDB.setPriority(taskId, p);
|
|
||||||
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
|
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
|
||||||
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
|
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
|
||||||
|
|
||||||
if (!authed) return <LoginScreen onLogin={handleLogin} />;
|
// Map API fields to frontend component expectations
|
||||||
|
const frontendTasks = tasks.map(t => ({
|
||||||
|
...t,
|
||||||
|
assignee: t.assignee_id,
|
||||||
|
addedBy: t.added_by,
|
||||||
|
addedAt: t.added_at,
|
||||||
|
tags: t.tags.map(tagObj => tagObj.tag)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const frontendAudit = audit.map(a => ({
|
||||||
|
...a,
|
||||||
|
actor: a.actor,
|
||||||
|
action: a.action,
|
||||||
|
summary: a.summary,
|
||||||
|
target: a.target,
|
||||||
|
at: a.at
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedOpenTask = frontendTasks.find(x => x.id === openTaskId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -101,7 +167,6 @@ function App() {
|
|||||||
onAdd={() => setAdding(meId)}
|
onAdd={() => setAdding(meId)}
|
||||||
onLogs={() => setShowLogs(true)}
|
onLogs={() => setShowLogs(true)}
|
||||||
onProfile={() => setShowSettings(true)}
|
onProfile={() => setShowSettings(true)}
|
||||||
onDB={() => setShowDB(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
|
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
|
||||||
@@ -109,7 +174,7 @@ function App() {
|
|||||||
<main className="main">
|
<main className="main">
|
||||||
{tab === 'overview' && (
|
{tab === 'overview' && (
|
||||||
<OverviewScreen
|
<OverviewScreen
|
||||||
tasks={tasks} density={t.density}
|
tasks={frontendTasks} density={t.density}
|
||||||
onOpen={(task) => setOpenTaskId(task.id)}
|
onOpen={(task) => setOpenTaskId(task.id)}
|
||||||
onAddFor={(uid) => setAdding(uid)}
|
onAddFor={(uid) => setAdding(uid)}
|
||||||
onMoveTask={moveTask}
|
onMoveTask={moveTask}
|
||||||
@@ -117,7 +182,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{tab !== 'overview' && (
|
{tab !== 'overview' && (
|
||||||
<UserScreen
|
<UserScreen
|
||||||
user={merge(tab)} tasks={tasks} density={t.density}
|
user={merge(tab)} tasks={frontendTasks} density={t.density}
|
||||||
onOpen={(task) => setOpenTaskId(task.id)}
|
onOpen={(task) => setOpenTaskId(task.id)}
|
||||||
onAddFor={(uid) => setAdding(uid)}
|
onAddFor={(uid) => setAdding(uid)}
|
||||||
/>
|
/>
|
||||||
@@ -125,41 +190,40 @@ function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} />
|
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} />
|
||||||
{openTask && (
|
{mappedOpenTask && (
|
||||||
<TaskDetail task={openTask} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} />
|
<TaskDetail task={mappedOpenTask} allAudit={frontendAudit} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} />
|
||||||
)}
|
)}
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
|
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
|
||||||
<AuditScreen entries={audit} onOpen={openTaskFromAnywhere} />
|
<AuditScreen entries={frontendAudit} onOpen={openTaskFromAnywhere} />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<SettingsScreen
|
<SettingsScreen
|
||||||
user={me}
|
user={me}
|
||||||
|
dbUsers={dbUsers}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onSave={(edits) => {
|
onSave={async (edits) => {
|
||||||
DashyDB.updateUser(meId, edits);
|
// Not implemented on backend yet for user updating, mock success
|
||||||
DashyDB.addAudit({ actor: meId, action: 'profile_updated', summary: 'Updated profile details' });
|
setShowSettings(false);
|
||||||
}}
|
}}
|
||||||
onLogout={() => { setShowSettings(false); setAuthed(false); }}
|
onLogout={() => {
|
||||||
onSwitchUser={(id) => { setMeId(id); setShowSettings(false); }}
|
api.logout();
|
||||||
onCreateUser={(u) => {
|
setAuthed(false);
|
||||||
const id = DashyDB.createUser(u);
|
setShowSettings(false);
|
||||||
DashyDB.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id });
|
|
||||||
}}
|
}}
|
||||||
onDeleteUser={(id) => {
|
onSwitchUser={async (id) => {
|
||||||
const u = userMap[id];
|
try {
|
||||||
DashyDB.deleteUser(id);
|
await api.login(id, "password123");
|
||||||
DashyDB.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null });
|
setMeId(id);
|
||||||
}}
|
setShowSettings(false);
|
||||||
onUpdateUserRole={(id, edits) => {
|
} catch(e) {
|
||||||
DashyDB.updateUser(id, edits);
|
alert("Failed to switch user");
|
||||||
DashyDB.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null });
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showDB && isAdmin && <DatabaseInspector onClose={() => setShowDB(false)} />}
|
|
||||||
|
|
||||||
<DashyTweaks t={t} setTweak={setTweak} />
|
<DashyTweaks t={t} setTweak={setTweak} />
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +234,7 @@ function BootSplash() {
|
|||||||
return (
|
return (
|
||||||
<div className="boot">
|
<div className="boot">
|
||||||
<div className="boot__pulse" />
|
<div className="boot__pulse" />
|
||||||
<div className="boot__label mono">opening dashy.db…</div>
|
<div className="boot__label mono">loading from API…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,11 +252,6 @@ function DashyTweaks({ t, setTweak }) {
|
|||||||
onChange={v => setTweak('density', v)} />
|
onChange={v => setTweak('density', v)} />
|
||||||
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
|
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
|
||||||
</TweakSection>
|
</TweakSection>
|
||||||
<TweakSection title="Database">
|
|
||||||
<TweakButton label="Reset SQLite" onClick={() => { if (confirm('Wipe and reseed dashy.db?')) DashyDB.reset(); }}>
|
|
||||||
Reset
|
|
||||||
</TweakButton>
|
|
||||||
</TweakSection>
|
|
||||||
</TweaksPanel>
|
</TweaksPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/token", response_model=schemas.Token)
|
@app.post("/token", response_model=schemas.Token)
|
||||||
async def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)):
|
async def login_for_access_token(form_data: schemas.UserLogin, db: Session = Depends(get_db)):
|
||||||
user = db.query(models.User).filter(models.User.id == form_data.id).first()
|
user = db.query(models.User).filter(models.User.id == form_data.id).first()
|
||||||
if not user or not auth.verify_password(form_data.password, user.password_hash):
|
if not user or not auth.verify_password(form_data.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class UserBase(BaseModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
id: str
|
||||||
|
password: str
|
||||||
|
|
||||||
class User(UserBase):
|
class User(UserBase):
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
+2
-1
@@ -119,7 +119,7 @@ def seed_db():
|
|||||||
status=t['status'],
|
status=t['status'],
|
||||||
added_at=datetime.fromisoformat(t['addedAt'])
|
added_at=datetime.fromisoformat(t['addedAt'])
|
||||||
)
|
)
|
||||||
db.merge(db_task)
|
db_task = db.merge(db_task)
|
||||||
|
|
||||||
# Add tags
|
# Add tags
|
||||||
for tag_name in t.get('tags', []):
|
for tag_name in t.get('tags', []):
|
||||||
@@ -127,6 +127,7 @@ def seed_db():
|
|||||||
if not tag:
|
if not tag:
|
||||||
tag = models.Tag(tag=tag_name)
|
tag = models.Tag(tag=tag_name)
|
||||||
db.add(tag)
|
db.add(tag)
|
||||||
|
db.flush()
|
||||||
if tag not in db_task.tags:
|
if tag not in db_task.tags:
|
||||||
db_task.tags.append(tag)
|
db_task.tags.append(tag)
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
// Seed data for Dashy prototype
|
|
||||||
|
|
||||||
const SEED_TASKS = [
|
|
||||||
// Lani
|
|
||||||
{ id: 't1', title: 'Call back Mrs. Patel re: Hilux service quote',
|
|
||||||
description: 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
|
|
||||||
assignee: 'lani', addedBy: 'rod', priority: 'high', source: 'imessage',
|
|
||||||
addedAt: '2026-05-08T08:42:00', status: 'open',
|
|
||||||
tags: ['quote'] },
|
|
||||||
{ id: 't2', title: 'Email #3814 → Workorder #2207',
|
|
||||||
description: 'Auto-converted from inbox. Tell customer ETA of Friday.',
|
|
||||||
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'email',
|
|
||||||
addedAt: '2026-05-08T07:15:00', status: 'open',
|
|
||||||
tags: ['WO #2207'] },
|
|
||||||
{ id: 't3', title: 'Reorder coolant — 5L × 4',
|
|
||||||
description: 'Stock card ran red on the morning sweep.',
|
|
||||||
assignee: 'lani', addedBy: 'kirra', priority: 'low', source: 'manual',
|
|
||||||
addedAt: '2026-05-07T16:02:00', status: 'open' },
|
|
||||||
{ id: 't4', title: 'Form response from "K. Wynne" auto-switched to Billing',
|
|
||||||
description: 'Originally captured as Service Booking — heads up.',
|
|
||||||
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'automation',
|
|
||||||
addedAt: '2026-05-08T09:50:00', status: 'billing',
|
|
||||||
tags: ['form'] },
|
|
||||||
|
|
||||||
// Kirra
|
|
||||||
{ id: 't5', title: 'Diagnose intermittent misfire — Camry, WO #2199',
|
|
||||||
description: 'Cust says it stutters between 60–80km/h once warm.',
|
|
||||||
assignee: 'kirra', addedBy: 'rod', priority: 'high', source: 'manual',
|
|
||||||
addedAt: '2026-05-08T07:50:00', status: 'open',
|
|
||||||
tags: ['WO #2199'] },
|
|
||||||
{ id: 't6', title: 'Sign off on Ayron\'s brake job — Forester',
|
|
||||||
description: 'Final torque check + road test before pickup at 3pm.',
|
|
||||||
assignee: 'kirra', addedBy: 'ayron', priority: 'med', source: 'manual',
|
|
||||||
addedAt: '2026-05-08T09:05:00', status: 'open' },
|
|
||||||
{ id: 't7', title: 'WO #2188 auto-marked Unsuccessful',
|
|
||||||
description: 'Customer no-show twice — please review and decide on next step.',
|
|
||||||
assignee: 'kirra', addedBy: 'system', priority: 'high', source: 'automation',
|
|
||||||
addedAt: '2026-05-08T06:00:00', status: 'unsuccessful',
|
|
||||||
tags: ['WO #2188'] },
|
|
||||||
|
|
||||||
// Ayron
|
|
||||||
{ id: 't8', title: 'Replace front pads + rotors — Forester',
|
|
||||||
description: 'Parts arrived yesterday. Bay 2 from 10am.',
|
|
||||||
assignee: 'ayron', addedBy: 'kirra', priority: 'med', source: 'manual',
|
|
||||||
addedAt: '2026-05-08T08:00:00', status: 'open',
|
|
||||||
tags: ['WO #2201'] },
|
|
||||||
{ id: 't9', title: 'Tidy bay 3 + sweep before lunch',
|
|
||||||
description: '',
|
|
||||||
assignee: 'ayron', addedBy: 'rod', priority: 'low', source: 'imessage',
|
|
||||||
addedAt: '2026-05-08T09:30:00', status: 'open' },
|
|
||||||
{ id: 't10', title: 'Pickup parts from Repco @ 11:30',
|
|
||||||
description: 'Two boxes for WO #2199 + an oil filter for stock.',
|
|
||||||
assignee: 'ayron', addedBy: 'lani', priority: 'med', source: 'manual',
|
|
||||||
addedAt: '2026-05-08T08:20:00', status: 'open' },
|
|
||||||
|
|
||||||
// ROD
|
|
||||||
{ id: 't11', title: 'Approve quote on Job #2207 ($1,840)',
|
|
||||||
description: 'Lani flagged this — needs your sign-off before sending.',
|
|
||||||
assignee: 'rod', addedBy: 'lani', priority: 'high', source: 'manual',
|
|
||||||
addedAt: '2026-05-08T09:12:00', status: 'open',
|
|
||||||
tags: ['quote', 'WO #2207'] },
|
|
||||||
{ id: 't12', title: 'Review weekly automation report',
|
|
||||||
description: '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
|
|
||||||
assignee: 'rod', addedBy: 'system', priority: 'low', source: 'automation',
|
|
||||||
addedAt: '2026-05-08T06:00:00', status: 'open' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SEED_AUDIT = [
|
|
||||||
{ id: 'a1', at: '2026-05-08T09:50:00', actor: 'system', action: 'form_rerouted',
|
|
||||||
summary: 'Form from K. Wynne auto-switched: Service Booking → Billing form',
|
|
||||||
target: 't4' },
|
|
||||||
{ id: 'a2', at: '2026-05-08T09:30:00', actor: 'rod', action: 'task_created',
|
|
||||||
summary: 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
|
|
||||||
target: 't9' },
|
|
||||||
{ id: 'a3', at: '2026-05-08T09:12:00', actor: 'lani', action: 'task_assigned',
|
|
||||||
summary: 'Assigned "Approve quote on Job #2207" to ROD',
|
|
||||||
target: 't11' },
|
|
||||||
{ id: 'a4', at: '2026-05-08T09:05:00', actor: 'ayron', action: 'task_moved',
|
|
||||||
summary: 'Moved "Sign off on brake job" from Ayron → Kirra',
|
|
||||||
target: 't6' },
|
|
||||||
{ id: 'a5', at: '2026-05-08T08:42:00', actor: 'rod', action: 'task_created',
|
|
||||||
summary: 'Created task "Call back Mrs. Patel" for Lani via iMessage',
|
|
||||||
target: 't1' },
|
|
||||||
{ id: 'a6', at: '2026-05-08T08:20:00', actor: 'lani', action: 'task_created',
|
|
||||||
summary: 'Created task "Pickup parts from Repco" for Ayron',
|
|
||||||
target: 't10' },
|
|
||||||
{ id: 'a7', at: '2026-05-08T08:00:00', actor: 'kirra', action: 'task_created',
|
|
||||||
summary: 'Created task "Replace front pads + rotors" for Ayron',
|
|
||||||
target: 't8' },
|
|
||||||
{ id: 'a8', at: '2026-05-08T07:50:00', actor: 'rod', action: 'task_edited',
|
|
||||||
summary: 'Edited priority on "Diagnose intermittent misfire" — Med → High',
|
|
||||||
target: 't5' },
|
|
||||||
{ id: 'a9', at: '2026-05-08T07:15:00', actor: 'system', action: 'email_converted',
|
|
||||||
summary: 'Email #3814 converted to Workorder #2207 (assigned to Lani)',
|
|
||||||
target: 't2' },
|
|
||||||
{ id: 'a10', at: '2026-05-08T06:00:00', actor: 'system', action: 'task_unsuccessful',
|
|
||||||
summary: 'WO #2188 auto-marked Unsuccessful after 2 missed bookings',
|
|
||||||
target: 't7' },
|
|
||||||
{ id: 'a11', at: '2026-05-07T16:02:00', actor: 'kirra', action: 'task_created',
|
|
||||||
summary: 'Created task "Reorder coolant" for Lani',
|
|
||||||
target: 't3' },
|
|
||||||
{ id: 'a12', at: '2026-05-07T15:30:00', actor: 'rod', action: 'login',
|
|
||||||
summary: 'Signed in from MacBook · Christchurch',
|
|
||||||
target: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Per-task audit slices
|
|
||||||
const TASK_AUDIT = {
|
|
||||||
t1: [
|
|
||||||
{ at: '2026-05-08T08:42:00', actor: 'rod', action: 'created via iMessage',
|
|
||||||
detail: '"Hey, create new task for Lani — call back Mrs. Patel about the Hilux quote, she wants confirmation before she books"' },
|
|
||||||
{ at: '2026-05-08T08:43:00', actor: 'system', action: 'priority set to High', detail: 'Inferred from "wants confirmation"' },
|
|
||||||
{ at: '2026-05-08T08:45:00', actor: 'lani', action: 'opened', detail: '' },
|
|
||||||
],
|
|
||||||
t11: [
|
|
||||||
{ at: '2026-05-08T09:12:00', actor: 'lani', action: 'assigned to ROD', detail: 'flagged for sign-off before send' },
|
|
||||||
{ at: '2026-05-08T09:14:00', actor: 'lani', action: 'added note', detail: 'Customer wants the work done before Mother\'s Day weekend.' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
window.SEED_TASKS = SEED_TASKS;
|
|
||||||
window.SEED_AUDIT = SEED_AUDIT;
|
|
||||||
window.TASK_AUDIT = TASK_AUDIT;
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
// Dashy SQLite layer — uses sql.js (SQLite compiled to WASM)
|
|
||||||
// Database is initialized once, seeded from SEED_TASKS / SEED_AUDIT / USERS,
|
|
||||||
// persisted to localStorage on every write, and exposed as window.DashyDB.
|
|
||||||
|
|
||||||
window.DashyDB = (function () {
|
|
||||||
const LS_KEY = 'dashy.db.v1';
|
|
||||||
let db = null;
|
|
||||||
let SQL = null;
|
|
||||||
const listeners = new Set();
|
|
||||||
let ready = false;
|
|
||||||
const readyWaiters = [];
|
|
||||||
|
|
||||||
// -- schema -----------------------------------------------------------
|
|
||||||
const SCHEMA = `
|
|
||||||
CREATE TABLE users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
hue INTEGER NOT NULL,
|
|
||||||
initials TEXT NOT NULL,
|
|
||||||
email TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
photo TEXT,
|
|
||||||
password_hash TEXT,
|
|
||||||
account_type TEXT NOT NULL DEFAULT 'standard' CHECK (account_type IN ('admin','standard')),
|
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
assignee_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
added_by TEXT NOT NULL,
|
|
||||||
priority TEXT NOT NULL CHECK (priority IN ('low','med','high')),
|
|
||||||
source TEXT NOT NULL CHECK (source IN ('manual','imessage','email','automation')),
|
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
due_at TEXT,
|
|
||||||
reminder_at TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
|
|
||||||
CREATE INDEX idx_tasks_status ON tasks(status);
|
|
||||||
|
|
||||||
CREATE TABLE task_tags (
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (task_id, tag)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE task_notes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
author_id TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE audit_log (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
at TEXT NOT NULL,
|
|
||||||
actor TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
summary TEXT NOT NULL,
|
|
||||||
target TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_audit_at ON audit_log(at);
|
|
||||||
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
device TEXT NOT NULL,
|
|
||||||
location TEXT,
|
|
||||||
last_active TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
function bumpListeners() { listeners.forEach(fn => { try { fn(); } catch(e){} }); }
|
|
||||||
|
|
||||||
function persist() {
|
|
||||||
try {
|
|
||||||
const bytes = db.export();
|
|
||||||
const b64 = btoa(String.fromCharCode(...bytes));
|
|
||||||
localStorage.setItem(LS_KEY, b64);
|
|
||||||
} catch(e) { console.warn('DashyDB persist failed', e); }
|
|
||||||
bumpListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFromLS() {
|
|
||||||
const b64 = localStorage.getItem(LS_KEY);
|
|
||||||
if (!b64) return null;
|
|
||||||
const bin = atob(b64);
|
|
||||||
const arr = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seed() {
|
|
||||||
db.exec('BEGIN');
|
|
||||||
USERS.forEach(u => {
|
|
||||||
const isAdmin = u.id === 'rod' || u.id === 'ayron';
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO users (id, name, role, hue, initials, email, phone, password_hash, account_type) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
||||||
[u.id, u.name, u.role, u.hue, u.initials, u.id + '@murchison-auto.co', '+64 27 555 0184',
|
|
||||||
'pbkdf2$' + Math.random().toString(36).slice(2, 18), isAdmin ? 'admin' : 'standard']
|
|
||||||
);
|
|
||||||
});
|
|
||||||
SEED_TASKS.forEach(t => {
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
||||||
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source, t.status || 'open', t.addedAt]
|
|
||||||
);
|
|
||||||
(t.tags || []).forEach(tag => {
|
|
||||||
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
SEED_AUDIT.forEach(a => {
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
|
|
||||||
[a.id, a.at, a.actor, a.action, a.summary, a.target]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
// sample notes
|
|
||||||
db.run("INSERT INTO task_notes (task_id, author_id, body, created_at) VALUES ('t11','lani','Customer wants the work done before Mother''s Day weekend.','2026-05-08T09:14:00')");
|
|
||||||
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','MacBook · Chrome','Christchurch, NZ','2026-05-08T10:30:00')");
|
|
||||||
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','iPhone · Safari','Christchurch, NZ','2026-05-08T08:15:00')");
|
|
||||||
db.exec('COMMIT');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
SQL = await window.initSqlJs({
|
|
||||||
locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = loadFromLS();
|
|
||||||
if (existing) {
|
|
||||||
db = new SQL.Database(existing);
|
|
||||||
// migration: add account_type if missing
|
|
||||||
try {
|
|
||||||
const cols = query("PRAGMA table_info(users)").map(c => c.name);
|
|
||||||
if (!cols.includes('account_type')) {
|
|
||||||
db.exec("ALTER TABLE users ADD COLUMN account_type TEXT NOT NULL DEFAULT 'standard'");
|
|
||||||
db.run("UPDATE users SET account_type = 'admin' WHERE id IN ('rod','ayron')");
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
} catch(e) { console.warn('migration failed', e); }
|
|
||||||
} else {
|
|
||||||
db = new SQL.Database();
|
|
||||||
db.exec(SCHEMA);
|
|
||||||
seed();
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
ready = true;
|
|
||||||
readyWaiters.splice(0).forEach(fn => fn());
|
|
||||||
bumpListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
function whenReady() {
|
|
||||||
if (ready) return Promise.resolve();
|
|
||||||
return new Promise(r => readyWaiters.push(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- query helpers ----------------------------------------------------
|
|
||||||
function rowsFrom(stmt) {
|
|
||||||
const out = [];
|
|
||||||
while (stmt.step()) out.push(stmt.getAsObject());
|
|
||||||
stmt.free();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function query(sql, params = []) {
|
|
||||||
if (!db) return [];
|
|
||||||
const stmt = db.prepare(sql);
|
|
||||||
if (params.length) stmt.bind(params);
|
|
||||||
return rowsFrom(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exec(sql, params = []) {
|
|
||||||
if (!db) return;
|
|
||||||
db.run(sql, params);
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- domain ops -------------------------------------------------------
|
|
||||||
function listTasks() {
|
|
||||||
const rows = query(`
|
|
||||||
SELECT t.*, GROUP_CONCAT(tt.tag, '|') AS tags_csv
|
|
||||||
FROM tasks t
|
|
||||||
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
|
||||||
GROUP BY t.id
|
|
||||||
ORDER BY t.added_at DESC
|
|
||||||
`);
|
|
||||||
return rows.map(r => ({
|
|
||||||
id: r.id, title: r.title, description: r.description,
|
|
||||||
assignee: r.assignee_id, addedBy: r.added_by,
|
|
||||||
priority: r.priority, source: r.source, status: r.status,
|
|
||||||
addedAt: r.added_at,
|
|
||||||
tags: r.tags_csv ? r.tags_csv.split('|') : []
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function listAudit() {
|
|
||||||
return query('SELECT id, at, actor, action, summary, target FROM audit_log ORDER BY at DESC');
|
|
||||||
}
|
|
||||||
|
|
||||||
function listUsers() {
|
|
||||||
return query('SELECT * FROM users ORDER BY rowid ASC');
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveTask(taskId, toUserId, by) {
|
|
||||||
exec('UPDATE tasks SET assignee_id = ? WHERE id = ?', [toUserId, taskId]);
|
|
||||||
addAudit({
|
|
||||||
actor: by, action: 'task_moved',
|
|
||||||
summary: 'Moved task to ' + (USERS.find(u=>u.id===toUserId)||{name:toUserId}).name,
|
|
||||||
target: taskId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPriority(taskId, p) {
|
|
||||||
exec('UPDATE tasks SET priority = ? WHERE id = ?', [p, taskId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTask(t) {
|
|
||||||
exec(
|
|
||||||
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
||||||
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source || 'manual', t.status || 'open', t.addedAt]
|
|
||||||
);
|
|
||||||
(t.tags || []).forEach(tag => {
|
|
||||||
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
|
|
||||||
});
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addAudit(row) {
|
|
||||||
const id = 'a_' + Date.now() + '_' + Math.random().toString(36).slice(2,5);
|
|
||||||
exec(
|
|
||||||
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
|
|
||||||
[id, row.at || new Date().toISOString(), row.actor, row.action, row.summary, row.target || null]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUser(userId, edits) {
|
|
||||||
const fields = []; const vals = [];
|
|
||||||
if (edits.name !== undefined) { fields.push('name = ?'); vals.push(edits.name); }
|
|
||||||
if (edits.role !== undefined) { fields.push('role = ?'); vals.push(edits.role); }
|
|
||||||
if (edits.photo !== undefined) { fields.push('photo = ?'); vals.push(edits.photo); }
|
|
||||||
if (edits.account_type !== undefined) { fields.push('account_type = ?'); vals.push(edits.account_type); }
|
|
||||||
if (!fields.length) return;
|
|
||||||
vals.push(userId);
|
|
||||||
exec('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', vals);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUser(u) {
|
|
||||||
const id = u.id || (u.name.toLowerCase().replace(/[^a-z]/g,'').slice(0,8) + '_' + Math.random().toString(36).slice(2,5));
|
|
||||||
const initials = (u.name.split(' ').map(s=>s[0]).join('') || 'U').slice(0,2).toUpperCase();
|
|
||||||
const hue = u.hue || Math.floor(Math.random() * 360);
|
|
||||||
exec(
|
|
||||||
'INSERT INTO users (id, name, role, hue, initials, email, phone, account_type, password_hash) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
||||||
[id, u.name, u.role || 'Team member', hue, initials,
|
|
||||||
u.email || (id + '@murchison-auto.co'), u.phone || '',
|
|
||||||
u.account_type || 'standard', 'pbkdf2$' + Math.random().toString(36).slice(2,18)]
|
|
||||||
);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteUser(userId) {
|
|
||||||
// reassign any tasks to ROD before delete
|
|
||||||
exec("UPDATE tasks SET assignee_id = 'rod' WHERE assignee_id = ?", [userId]);
|
|
||||||
exec('DELETE FROM users WHERE id = ?', [userId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableNames() {
|
|
||||||
return query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map(r => r.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableInfo(name) {
|
|
||||||
return query(`PRAGMA table_info(${name})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowCount(name) {
|
|
||||||
const r = query(`SELECT COUNT(*) AS c FROM ${name}`);
|
|
||||||
return r[0] ? r[0].c : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportFile() {
|
|
||||||
const bytes = db.export();
|
|
||||||
return new Blob([bytes], { type: 'application/x-sqlite3' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
localStorage.removeItem(LS_KEY);
|
|
||||||
db = new SQL.Database();
|
|
||||||
db.exec(SCHEMA);
|
|
||||||
seed();
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawExec(sql) {
|
|
||||||
// returns array of {columns, values} for SELECTs, or empty for writes
|
|
||||||
const res = db.exec(sql);
|
|
||||||
persist();
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
||||||
|
|
||||||
return {
|
|
||||||
init, whenReady, query, exec, rawExec,
|
|
||||||
listTasks, listAudit, listUsers,
|
|
||||||
createTask, moveTask, setPriority, updateUser, createUser, deleteUser, addAudit,
|
|
||||||
tableNames, tableInfo, rowCount, exportFile, reset, subscribe,
|
|
||||||
get isReady() { return ready; },
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
+8
-133
@@ -106,15 +106,6 @@ function TopBar({ me, isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile,
|
|||||||
<Icon.Logs />
|
<Icon.Logs />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<IconBtn label="Database" onClick={onDB}>
|
|
||||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
|
||||||
<ellipse cx="8" cy="3.5" rx="5" ry="1.5"/>
|
|
||||||
<path d="M3 3.5v9c0 .8 2.2 1.5 5 1.5s5-.7 5-1.5v-9"/>
|
|
||||||
<path d="M3 7.5c0 .8 2.2 1.5 5 1.5s5-.7 5-1.5"/>
|
|
||||||
</svg>
|
|
||||||
</IconBtn>
|
|
||||||
)}
|
|
||||||
<button className="topbar__me" onClick={onProfile}>
|
<button className="topbar__me" onClick={onProfile}>
|
||||||
<Avatar user={me} size={28} ring />
|
<Avatar user={me} size={28} ring />
|
||||||
<div className="topbar__me-meta">
|
<div className="topbar__me-meta">
|
||||||
@@ -407,13 +398,14 @@ function Modal({ children, onClose, title, eyebrow, wide = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskDetail({ task, onClose, onMove, onPriority }) {
|
function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority }) {
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
const assignee = findUser(task.assignee);
|
const assignee = findUser(task.assignee);
|
||||||
const author = findUser(task.addedBy);
|
const author = findUser(task.addedBy);
|
||||||
const audit = TASK_AUDIT[task.id] || [
|
const audit = allAudit.filter(a => a.target === task.id);
|
||||||
{ at: task.addedAt, actor: task.addedBy, action: 'created', detail: '' }
|
if (audit.length === 0) {
|
||||||
];
|
audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' });
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}>
|
<Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}>
|
||||||
<div className="detail">
|
<div className="detail">
|
||||||
@@ -474,9 +466,9 @@ function TaskDetail({ task, onClose, onMove, onPriority }) {
|
|||||||
<strong>
|
<strong>
|
||||||
{row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name}
|
{row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name}
|
||||||
</strong>
|
</strong>
|
||||||
<span> {row.action}</span>
|
<span> {row.action.replace(/_/g, ' ')}</span>
|
||||||
</div>
|
</div>
|
||||||
{row.detail && <div className="timeline__detail">{row.detail}</div>}
|
{(row.detail || row.summary) && <div className="timeline__detail">{row.detail || row.summary}</div>}
|
||||||
<div className="timeline__time mono">{fmtDateTime(row.at)}</div>
|
<div className="timeline__time mono">{fmtDateTime(row.at)}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -818,8 +810,7 @@ function ToggleRow({ label, defaultOn = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceTab({ user, isAdmin, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole }) {
|
function WorkspaceTab({ user, isAdmin, dbUsers, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole }) {
|
||||||
const dbUsers = DashyDB.listUsers();
|
|
||||||
const [adding, setAdding] = React.useState(false);
|
const [adding, setAdding] = React.useState(false);
|
||||||
const [newName, setNewName] = React.useState('');
|
const [newName, setNewName] = React.useState('');
|
||||||
const [newRole, setNewRole] = React.useState('');
|
const [newRole, setNewRole] = React.useState('');
|
||||||
@@ -935,123 +926,7 @@ function WorkspaceTab({ user, isAdmin, onSwitchUser, onCreateUser, onDeleteUser,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatabaseInspector({ onClose }) {
|
|
||||||
const [tab, setTab] = React.useState(DashyDB.tableNames()[0] || 'tasks');
|
|
||||||
const [sql, setSql] = React.useState('SELECT id, title, assignee_id, priority, status\nFROM tasks\nORDER BY added_at DESC;');
|
|
||||||
const [result, setResult] = React.useState(null);
|
|
||||||
const [err, setErr] = React.useState(null);
|
|
||||||
const tableNames = DashyDB.tableNames();
|
|
||||||
|
|
||||||
const runQuery = () => {
|
|
||||||
try {
|
|
||||||
setErr(null);
|
|
||||||
const r = DashyDB.rawExec(sql);
|
|
||||||
setResult(r);
|
|
||||||
} catch (e) { setErr(String(e.message || e)); setResult(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadDB = () => {
|
|
||||||
const blob = DashyDB.exportFile();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url; a.download = 'dashy.db'; a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cols = tab && DashyDB.tableInfo(tab);
|
|
||||||
const rows = tab && DashyDB.query('SELECT * FROM ' + tab + ' LIMIT 200');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal onClose={onClose} title="Database" eyebrow="dashy.db · SQLite" wide>
|
|
||||||
<div className="dbins">
|
|
||||||
<nav className="dbins__nav">
|
|
||||||
<div className="dbins__nav-h mono">tables</div>
|
|
||||||
{tableNames.map(n => (
|
|
||||||
<button key={n} className={"dbins__tab" + (tab===n?' is-on':'')} onClick={() => setTab(n)}>
|
|
||||||
<span className="dbins__tab-name">{n}</span>
|
|
||||||
<span className="dbins__tab-count mono">{DashyDB.rowCount(n)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button className={"dbins__tab" + (tab==='__sql__'?' is-on':'')} onClick={() => setTab('__sql__')}>
|
|
||||||
<span className="dbins__tab-name">SQL ›</span>
|
|
||||||
</button>
|
|
||||||
<div className="dbins__nav-foot">
|
|
||||||
<button className="btn btn--soft btn--sm" onClick={downloadDB}>Export .db</button>
|
|
||||||
<button className="btn btn--ghost btn--sm" onClick={() => { if (confirm('Wipe and reseed?')) DashyDB.reset(); }}>Reset</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="dbins__body">
|
|
||||||
{tab !== '__sql__' && cols && (
|
|
||||||
<>
|
|
||||||
<div className="dbins__schema">
|
|
||||||
<div className="dbins__schema-h mono">schema · {tab}</div>
|
|
||||||
<div className="dbins__cols">
|
|
||||||
{cols.map(c => (
|
|
||||||
<span key={c.name} className="dbins__col">
|
|
||||||
<strong>{c.name}</strong>
|
|
||||||
<span className="mono">{c.type}{c.pk ? ' · PK' : ''}{c.notnull ? ' · NOT NULL' : ''}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DBTable cols={cols.map(c => c.name)} rows={rows} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tab === '__sql__' && (
|
|
||||||
<div className="dbins__sql">
|
|
||||||
<div className="dbins__sql-bar">
|
|
||||||
<span className="mono dbins__sql-eyebrow">SQL · runs against dashy.db</span>
|
|
||||||
<button className="btn btn--primary btn--sm" onClick={runQuery}>Run ↵</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="dbins__sql-input"
|
|
||||||
value={sql}
|
|
||||||
onChange={e => setSql(e.target.value)}
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
{err && <div className="dbins__err">{err}</div>}
|
|
||||||
{result && result[0] && (
|
|
||||||
<DBTable cols={result[0].columns} rows={result[0].values.map(row => Object.fromEntries(row.map((v,i)=>[result[0].columns[i],v])))} />
|
|
||||||
)}
|
|
||||||
{result && result.length === 0 && (
|
|
||||||
<div className="dbins__ok mono">ok · no rows returned</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DBTable({ cols, rows }) {
|
|
||||||
if (!rows || !rows.length) return <div className="dbins__empty mono">— empty —</div>;
|
|
||||||
return (
|
|
||||||
<div className="dbins__tablewrap">
|
|
||||||
<table className="dbins__table">
|
|
||||||
<thead><tr>{cols.map(c => <th key={c}>{c}</th>)}</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
{cols.map(c => {
|
|
||||||
const v = r[c];
|
|
||||||
let display;
|
|
||||||
if (v === null || v === undefined) display = <span className="dbins__null mono">NULL</span>;
|
|
||||||
else if (typeof v === 'string' && v.length > 80) display = v.slice(0, 80) + '…';
|
|
||||||
else display = String(v);
|
|
||||||
return <td key={c} className={typeof v === 'number' ? 'is-num' : ''}>{display}</td>;
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, {
|
Object.assign(window, {
|
||||||
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
||||||
SettingsScreen,
|
SettingsScreen,
|
||||||
DatabaseInspector,
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user