Added esc functionality to open windows, added 'n' keybind to create new tasks, fixed Accounts and settings page to allow for edits to be made as well as profile picture to be updated

This commit is contained in:
NPS Agent
2026-05-13 09:55:19 +09:30
parent cf3e4cfe41
commit abb6402f86
7 changed files with 135 additions and 30 deletions
+4 -1
View File
@@ -66,6 +66,9 @@
22. **Persistent Task Reordering:** Implemented drag-and-drop reordering within and between columns. 22. **Persistent Task Reordering:** Implemented drag-and-drop reordering within and between columns.
- **Backend:** Added a `position` (Float) column to the `tasks` table and updated API endpoints to support position updates and sorted fetching. - **Backend:** Added a `position` (Float) column to the `tasks` table and updated API endpoints to support position updates and sorted fetching.
- **UI:** Enhanced the Kanban board with a "drop-between-cards" detection logic and a visual blue drop indicator. Positions are persisted to the database instantly on drop. - **UI:** Enhanced the Kanban board with a "drop-between-cards" detection logic and a visual blue drop indicator. Positions are persisted to the database instantly on drop.
23. **Keyboard Shortcuts:** Added global support for keyboard navigation:
- **`Escape`**: Instantly close any open modal (Task Detail, Add Task, Settings, or Audit Logs).
- **`n`**: Open the "Add Task" modal from the main dashboard (disabled while typing in inputs).
### Phase 3: Advanced Features ### Phase 3: Advanced Features
- **Real-time Notifications:** Explore WebSockets for task assignments. - **Real-time Notifications:** Explore WebSockets for task assignments.
@@ -74,5 +77,5 @@
--- ---
**Last Updated:** Monday, May 11, 2026 **Last Updated:** Wednesday, May 13, 2026
**Status:** Phase 2 Complete / Ready for Phase 3 **Status:** Phase 2 Complete / Ready for Phase 3
+13
View File
@@ -168,6 +168,19 @@ class ApiService {
return data; return data;
} }
async getTaskNotes(taskId) {
return this.request(`/tasks/${taskId}/notes`);
}
async createTaskNote(taskId, body) {
const data = await this.request(`/tasks/${taskId}/notes`, {
method: 'POST',
body: JSON.stringify({ body }),
});
this.notify();
return data;
}
async addAudit(auditData) { async addAudit(auditData) {
const data = await this.request('/audit', { const data = await this.request('/audit', {
method: 'POST', method: 'POST',
+36 -2
View File
@@ -104,6 +104,25 @@ function App() {
React.useEffect(() => { React.useEffect(() => {
window.dbUsers = dbUsers; window.dbUsers = dbUsers;
}, [dbUsers]); }, [dbUsers]);
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 [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);
@@ -243,6 +262,16 @@ function App() {
} }
}; };
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 dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); }; const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
@@ -302,7 +331,7 @@ function App() {
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} dbUsers={dbUsers} /> <AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} dbUsers={dbUsers} />
{mappedOpenTask && ( {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)} /> <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 && ( {showLogs && (
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide> <Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
@@ -316,8 +345,13 @@ function App() {
isAdmin={isAdmin} isAdmin={isAdmin}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
onSave={async (edits) => { onSave={async (edits) => {
// Not implemented on backend yet for user updating, mock success try {
await api.updateUser(meId, edits);
setShowSettings(false); setShowSettings(false);
} catch (e) {
console.error(e);
alert("Failed to save changes: " + e.message);
}
}} }}
onLogout={() => { onLogout={() => {
api.logout(); api.logout();
+35 -15
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.sql import func
from typing import List from typing import List
import uuid import uuid
@@ -50,9 +51,8 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current
initials=user.initials, initials=user.initials,
email=user.email, email=user.email,
phone=user.phone, phone=user.phone,
photo=user.photo, password_hash=auth.get_password_hash(user.password) if user.password else None,
account_type=user.account_type, account_type=user.account_type
password_hash=auth.get_password_hash(user.password)
) )
db.add(db_user) db.add(db_user)
db.commit() db.commit()
@@ -61,6 +61,9 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current
@app.patch("/users/{user_id}", response_model=schemas.User) @app.patch("/users/{user_id}", response_model=schemas.User)
def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
if current_user.account_type != "admin" and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Not enough permissions")
db_user = db.query(models.User).filter(models.User.id == user_id).first() db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user: if not db_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -74,25 +77,26 @@ def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Dep
return db_user return db_user
@app.post("/users/{user_id}/password") @app.post("/users/{user_id}/password")
def change_password(user_id: str, payload: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): def change_password(user_id: str, pwd_data: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
db_user = db.query(models.User).filter(models.User.id == user_id).first() if current_user.id != user_id:
if not db_user: raise HTTPException(status_code=403, detail="Cannot change another user's password")
raise HTTPException(status_code=404, detail="User not found")
if not auth.verify_password(payload.old_password, db_user.password_hash): if not auth.verify_password(pwd_data.old_password, current_user.password_hash):
raise HTTPException(status_code=401, detail="Current password is incorrect") raise HTTPException(status_code=400, detail="Incorrect current password")
db_user.password_hash = auth.get_password_hash(payload.new_password)
current_user.password_hash = auth.get_password_hash(pwd_data.new_password)
db.commit() db.commit()
return {"message": "Password updated"} return {"message": "Password updated successfully"}
@app.delete("/users/{user_id}") @app.delete("/users/{user_id}")
def delete_user(user_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): def delete_user(user_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
if current_user.account_type != "admin":
raise HTTPException(status_code=403, detail="Not enough permissions")
db_user = db.query(models.User).filter(models.User.id == user_id).first() db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user: if not db_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Reassign tasks to rod
db.query(models.Task).filter(models.Task.assignee_id == user_id).update({"assignee_id": "rod"})
db.delete(db_user) db.delete(db_user)
db.commit() db.commit()
return {"message": "User deleted"} return {"message": "User deleted"}
@@ -160,11 +164,26 @@ def delete_task(task_id: str, db: Session = Depends(get_db), current_user: model
if not db_task: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
from sqlalchemy.sql import func
db_task.deleted_at = func.now() db_task.deleted_at = func.now()
db.commit() db.commit()
return {"message": "Task moved to trash"} return {"message": "Task moved to trash"}
@app.get("/tasks/{task_id}/notes", response_model=List[schemas.TaskNote])
def read_task_notes(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
return db.query(models.TaskNote).filter(models.TaskNote.task_id == task_id).order_by(models.TaskNote.created_at.desc()).all()
@app.post("/tasks/{task_id}/notes", response_model=schemas.TaskNote)
def create_task_note(task_id: str, note: schemas.TaskNoteBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
db_note = models.TaskNote(
task_id=task_id,
author_id=current_user.id,
body=note.body
)
db.add(db_note)
db.commit()
db.refresh(db_note)
return db_note
@app.post("/tasks/{task_id}/restore", response_model=schemas.Task) @app.post("/tasks/{task_id}/restore", response_model=schemas.Task)
def restore_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): def restore_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
if current_user.account_type != "admin": if current_user.account_type != "admin":
@@ -178,6 +197,7 @@ def restore_task(task_id: str, db: Session = Depends(get_db), current_user: mode
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
return db_task return db_task
@app.get("/workspace", response_model=schemas.Workspace) @app.get("/workspace", response_model=schemas.Workspace)
def read_workspace(db: Session = Depends(get_db)): def read_workspace(db: Session = Depends(get_db)):
ws = db.query(models.Workspace).first() ws = db.query(models.Workspace).first()
+2
View File
@@ -25,6 +25,8 @@ class UserUpdate(BaseModel):
role: Optional[str] = None role: Optional[str] = None
account_type: Optional[str] = None account_type: Optional[str] = None
photo: Optional[str] = None photo: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
old_password: str old_password: str
BIN
View File
Binary file not shown.
+44 -11
View File
@@ -608,9 +608,10 @@ function Modal({ children, onClose, title, eyebrow, wide = false }) {
); );
} }
function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComplete, onReopen, onEditDesc, onDeleteTask }) { function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComplete, onReopen, onEditDesc, onDeleteTask, onAddNote }) {
const [editingDesc, setEditingDesc] = React.useState(false); const [editingDesc, setEditingDesc] = React.useState(false);
const [descValue, setDescValue] = React.useState(task ? task.description || '' : ''); const [descValue, setDescValue] = React.useState(task ? task.description || '' : '');
const [noteValue, setNoteValue] = React.useState('');
React.useEffect(() => { React.useEffect(() => {
setDescValue(task ? task.description || '' : ''); setDescValue(task ? task.description || '' : '');
@@ -623,6 +624,13 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple
if (audit.length === 0) { if (audit.length === 0) {
audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' }); audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' });
} }
const handleAddNote = async () => {
if (!noteValue.trim()) return;
await onAddNote(noteValue);
setNoteValue('');
};
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">
@@ -671,13 +679,32 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple
</div> </div>
<div className="detail__notes"> <div className="detail__notes">
<h3 className="detail__h">Notes &amp; reminders</h3> <h3 className="detail__h">Notes</h3>
<ul className="notes"> <ul className="notes">
{/* Dynamic notes will go here */} {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> </ul>
<div className="notes__add"> <div className="notes__add">
<input className="field__input" placeholder="Add a note or @mention…" /> <input
<button className="btn btn--soft btn--sm">Add</button> 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> </div>
@@ -921,6 +948,8 @@ function FilterChip({ on, onClick, children }) {
function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, onChangePassword, workspace, onUpdateWorkspace }) { function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, onChangePassword, workspace, onUpdateWorkspace }) {
const [name, setName] = React.useState(user.name); const [name, setName] = React.useState(user.name);
const [role, setRole] = React.useState(user.role); 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 [photo, setPhoto] = React.useState(user.photo || null);
const [tab, setTab] = React.useState('profile'); const [tab, setTab] = React.useState('profile');
const [pwOld, setPwOld] = React.useState(''); const [pwOld, setPwOld] = React.useState('');
@@ -948,7 +977,11 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
}; };
React.useEffect(() => { React.useEffect(() => {
setName(user.name); setRole(user.role); setPhoto(user.photo || null); setName(user.name);
setRole(user.role);
setEmail(user.email || '');
setPhone(user.phone || '');
setPhoto(user.photo || null);
}, [user.id]); }, [user.id]);
const fileInputRef = React.useRef(null); const fileInputRef = React.useRef(null);
@@ -961,12 +994,12 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
}; };
const save = () => { const save = () => {
onSave({ name, role, photo }); onSave({ name, role, photo, email, phone });
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 1600); setTimeout(() => setSaved(false), 1600);
}; };
const dirty = name !== user.name || role !== user.role || photo !== (user.photo || null); const dirty = name !== user.name || role !== user.role || photo !== (user.photo || null) || email !== (user.email || '') || phone !== (user.phone || '');
return ( return (
<Modal onClose={onClose} title="Account & settings" eyebrow={"Signed in as " + user.name} wide> <Modal onClose={onClose} title="Account & settings" eyebrow={"Signed in as " + user.name} wide>
@@ -1025,17 +1058,17 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
</label> </label>
<label className="field"> <label className="field">
<span className="field__label">Email</span> <span className="field__label">Email</span>
<input className="field__input" defaultValue={user.id + "@murchison-auto.co"} /> <input className="field__input" value={email} onChange={e => setEmail(e.target.value)} placeholder="yourname@example.com" />
</label> </label>
<label className="field"> <label className="field">
<span className="field__label">Phone</span> <span className="field__label">Phone</span>
<input className="field__input" defaultValue="+64 27 555 0184" /> <input className="field__input" value={phone} onChange={e => setPhone(e.target.value)} placeholder="+64 ..." />
</label> </label>
</div> </div>
<div className="settings__save-row"> <div className="settings__save-row">
{saved && <span className="settings__saved mono"><Icon.Check /> Saved</span>} {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); }} disabled={!dirty}>Discard</button> <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> <button className="btn btn--primary" onClick={save} disabled={!dirty}>Save changes</button>
</div> </div>
</> </>