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.
- **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.
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
- **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
+13
View File
@@ -168,6 +168,19 @@ class ApiService {
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) {
const data = await this.request('/audit', {
method: 'POST',
+36 -2
View File
@@ -104,6 +104,25 @@ function App() {
React.useEffect(() => {
window.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 [openTaskId, setOpenTaskId] = React.useState(null);
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 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} />
{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 && (
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
@@ -316,8 +345,13 @@ function App() {
isAdmin={isAdmin}
onClose={() => setShowSettings(false)}
onSave={async (edits) => {
// Not implemented on backend yet for user updating, mock success
try {
await api.updateUser(meId, edits);
setShowSettings(false);
} catch (e) {
console.error(e);
alert("Failed to save changes: " + e.message);
}
}}
onLogout={() => {
api.logout();
+35 -15
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy.sql import func
from typing import List
import uuid
@@ -50,9 +51,8 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current
initials=user.initials,
email=user.email,
phone=user.phone,
photo=user.photo,
account_type=user.account_type,
password_hash=auth.get_password_hash(user.password)
password_hash=auth.get_password_hash(user.password) if user.password else None,
account_type=user.account_type
)
db.add(db_user)
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)
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()
if not db_user:
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
@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)):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
if not auth.verify_password(payload.old_password, db_user.password_hash):
raise HTTPException(status_code=401, detail="Current password is incorrect")
db_user.password_hash = auth.get_password_hash(payload.new_password)
def change_password(user_id: str, pwd_data: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
if current_user.id != user_id:
raise HTTPException(status_code=403, detail="Cannot change another user's password")
if not auth.verify_password(pwd_data.old_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Incorrect current password")
current_user.password_hash = auth.get_password_hash(pwd_data.new_password)
db.commit()
return {"message": "Password updated"}
return {"message": "Password updated successfully"}
@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)):
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()
if not db_user:
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.commit()
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:
raise HTTPException(status_code=404, detail="Task not found")
from sqlalchemy.sql import func
db_task.deleted_at = func.now()
db.commit()
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)
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":
@@ -178,6 +197,7 @@ def restore_task(task_id: str, db: Session = Depends(get_db), current_user: mode
db.commit()
db.refresh(db_task)
return db_task
@app.get("/workspace", response_model=schemas.Workspace)
def read_workspace(db: Session = Depends(get_db)):
ws = db.query(models.Workspace).first()
+2
View File
@@ -25,6 +25,8 @@ class UserUpdate(BaseModel):
role: Optional[str] = None
account_type: Optional[str] = None
photo: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
class PasswordChange(BaseModel):
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 [descValue, setDescValue] = React.useState(task ? task.description || '' : '');
const [noteValue, setNoteValue] = React.useState('');
React.useEffect(() => {
setDescValue(task ? task.description || '' : '');
@@ -623,6 +624,13 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple
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">
@@ -671,13 +679,32 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple
</div>
<div className="detail__notes">
<h3 className="detail__h">Notes &amp; reminders</h3>
<h3 className="detail__h">Notes</h3>
<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>
<div className="notes__add">
<input className="field__input" placeholder="Add a note or @mention…" />
<button className="btn btn--soft btn--sm">Add</button>
<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>
@@ -921,6 +948,8 @@ function FilterChip({ on, onClick, children }) {
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('');
@@ -948,7 +977,11 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
};
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]);
const fileInputRef = React.useRef(null);
@@ -961,12 +994,12 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
};
const save = () => {
onSave({ name, role, photo });
onSave({ name, role, photo, email, phone });
setSaved(true);
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 (
<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 className="field">
<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 className="field">
<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>
</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); }} 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>
</div>
</>