Replace freetext email inputs with a user lookup for tag/assign/reallocate
Typo'd addresses in the old <input type="email"> fields silently matched nobody (assigned_to/task_tags.user_email have no FK), so a task could vanish into an unreachable owner with no error. New UsersContext + UserPicker pull the real user list from central auth (/api/auth/users?app=tasks), same pattern maintenance already uses for its assignee dropdown.
This commit is contained in:
parent
3145c1fc07
commit
6172617df3
7 changed files with 103 additions and 46 deletions
|
|
@ -8,6 +8,7 @@ import { ListsProvider } from './context/ListsContext'
|
|||
import { StatusesProvider } from './context/StatusesContext'
|
||||
import { CategoriesProvider } from './context/CategoriesContext'
|
||||
import { ProjectsProvider } from './context/ProjectsContext'
|
||||
import { UsersProvider } from './context/UsersContext'
|
||||
import { SearchProvider } from './context/SearchContext'
|
||||
import { TaskFiltersProvider } from './context/TaskFiltersContext'
|
||||
import { TaskDrawerProvider } from './components/TaskDrawer'
|
||||
|
|
@ -30,27 +31,29 @@ export default function App() {
|
|||
<StatusesProvider>
|
||||
<CategoriesProvider>
|
||||
<ProjectsProvider>
|
||||
<SearchProvider>
|
||||
<TaskFiltersProvider>
|
||||
<TaskDrawerProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TodayView />} />
|
||||
<Route path="/board" element={<BoardView />} />
|
||||
<Route path="/list/:id" element={<ListView />} />
|
||||
<Route path="/projects" element={<ProjectsView />} />
|
||||
<Route path="/project/:id" element={<ProjectDetailView />} />
|
||||
<Route path="/shared" element={<FilteredListView view="shared" title="Shared with me" />} />
|
||||
<Route path="/assigned" element={<FilteredListView view="assigned" title="Assigned to me" />} />
|
||||
<Route path="/dispatch" element={<DispatchView />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/today" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</TaskDrawerProvider>
|
||||
</TaskFiltersProvider>
|
||||
</SearchProvider>
|
||||
<UsersProvider>
|
||||
<SearchProvider>
|
||||
<TaskFiltersProvider>
|
||||
<TaskDrawerProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TodayView />} />
|
||||
<Route path="/board" element={<BoardView />} />
|
||||
<Route path="/list/:id" element={<ListView />} />
|
||||
<Route path="/projects" element={<ProjectsView />} />
|
||||
<Route path="/project/:id" element={<ProjectDetailView />} />
|
||||
<Route path="/shared" element={<FilteredListView view="shared" title="Shared with me" />} />
|
||||
<Route path="/assigned" element={<FilteredListView view="assigned" title="Assigned to me" />} />
|
||||
<Route path="/dispatch" element={<DispatchView />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/today" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</TaskDrawerProvider>
|
||||
</TaskFiltersProvider>
|
||||
</SearchProvider>
|
||||
</UsersProvider>
|
||||
</ProjectsProvider>
|
||||
</CategoriesProvider>
|
||||
</StatusesProvider>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types'
|
||||
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, AuthUser } from './types'
|
||||
|
||||
const BASE = '/tasks/api'
|
||||
|
||||
|
|
@ -137,6 +137,11 @@ export function addComment(taskId: number, body: string): Promise<TaskComment> {
|
|||
return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
|
||||
}
|
||||
|
||||
// ── Users (for tag/assign/reallocate pickers) ───────────────────
|
||||
export function fetchAssignableUsers(): Promise<AuthUser[]> {
|
||||
return request('/auth/users?app=tasks')
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useProjects } from '../context/ProjectsContext'
|
|||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
import { Check_, formatDateTime, toLocalInput } from './shared'
|
||||
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
||||
|
||||
export default function TaskDetail({ taskId, onChange, onClose }:
|
||||
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
||||
|
|
@ -226,24 +226,16 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
</div>
|
||||
)}
|
||||
{isOwner && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" value={tagEmail} placeholder="colleague@hotel.com"
|
||||
onChange={e => setTagEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addTag() }} />
|
||||
<button className="btn btn-sm" onClick={addTag} disabled={!tagEmail.trim()}>Tag</button>
|
||||
</div>
|
||||
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
|
||||
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOwner && can(user, 'assign') && (
|
||||
<div className="field" style={{ marginBottom: can(user, 'reallocate') ? 8 : 0 }}>
|
||||
<label><UserPlus size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Assign (hands off ownership)</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" value={assignEmail} placeholder={task.assigned_to ?? 'someone@hotel.com'}
|
||||
onChange={e => setAssignEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') assignTo() }} />
|
||||
<button className="btn btn-sm" onClick={assignTo} disabled={!assignEmail.trim()}>Assign</button>
|
||||
</div>
|
||||
<UserPicker value={assignEmail} onChange={setAssignEmail} onSubmit={assignTo} buttonLabel="Assign"
|
||||
exclude={[user.email]} />
|
||||
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -251,12 +243,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
{can(user, 'reallocate') && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" value={reallocateEmail} placeholder="someone@hotel.com"
|
||||
onChange={e => setReallocateEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') reallocateTo() }} />
|
||||
<button className="btn btn-sm" onClick={reallocateTo} disabled={!reallocateEmail.trim()}>Move</button>
|
||||
</div>
|
||||
<UserPicker value={reallocateEmail} onChange={setReallocateEmail} onSubmit={reallocateTo} buttonLabel="Move" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useCategories } from '../context/CategoriesContext'
|
|||
import { useProjects } from '../context/ProjectsContext'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { useUsers } from '../context/UsersContext'
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||
if (priority === 'medium') return null
|
||||
|
|
@ -22,6 +23,30 @@ export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: Reac
|
|||
)
|
||||
}
|
||||
|
||||
// User lookup + submit button, replacing a freetext email input wherever a
|
||||
// task gets tagged/assigned/reallocated — avoids typo'd addresses silently
|
||||
// matching nobody (assigned_to/task_tags.user_email have no FK to users).
|
||||
export function UserPicker({ value, onChange, onSubmit, exclude = [], placeholder = 'Choose a person…', buttonLabel }: {
|
||||
value: string
|
||||
onChange: (email: string) => void
|
||||
onSubmit: () => void
|
||||
exclude?: string[]
|
||||
placeholder?: string
|
||||
buttonLabel: string
|
||||
}) {
|
||||
const { users } = useUsers()
|
||||
const options = users.filter(u => !exclude.includes(u.email))
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<select value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map(u => <option key={u.email} value={u.email}>{u.name} ({u.email})</option>)}
|
||||
</select>
|
||||
<button className="btn btn-sm" onClick={onSubmit} disabled={!value}>{buttonLabel}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterableEntity { id: number; name: string; colour: string }
|
||||
|
||||
// Collapsible multi-select filter chip-bar — generic over anything with an
|
||||
|
|
|
|||
32
frontend/src/context/UsersContext.tsx
Normal file
32
frontend/src/context/UsersContext.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import type { AuthUser } from '../types'
|
||||
import { fetchAssignableUsers } from '../api'
|
||||
|
||||
interface UsersCtx {
|
||||
users: AuthUser[]
|
||||
loading: boolean
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
const Ctx = createContext<UsersCtx | null>(null)
|
||||
|
||||
export function useUsers() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useUsers must be used inside UsersProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function UsersProvider({ children }: { children: React.ReactNode }) {
|
||||
const [users, setUsers] = useState<AuthUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const data = await fetchAssignableUsers().catch(() => [])
|
||||
setUsers(data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
return <Ctx.Provider value={{ users, loading, reload }}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { useAuth } from '../components/AuthGate'
|
|||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { can } from '../types'
|
||||
import { dueInfo, PriorityBadge, TaskFilterBar } from '../components/shared'
|
||||
import { dueInfo, PriorityBadge, TaskFilterBar, UserPicker } from '../components/shared'
|
||||
|
||||
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
|
||||
// through the shared task drawer, since GET /api/tasks/:id only grants
|
||||
|
|
@ -83,10 +83,8 @@ export default function DispatchView() {
|
|||
</div>
|
||||
{movingId === t.id ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" autoFocus value={target} placeholder="reallocate to…" style={{ width: 180 }}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') move(t.id); if (e.key === 'Escape') setMovingId(null) }} />
|
||||
<button className="btn btn-sm btn-primary" onClick={() => move(t.id)} disabled={!target.trim()}>Move</button>
|
||||
<UserPicker value={target} onChange={setTarget} onSubmit={() => move(t.id)} buttonLabel="Move"
|
||||
exclude={[t.owner_id]} placeholder="reallocate to…" />
|
||||
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -141,6 +141,13 @@ export interface User {
|
|||
caps: string[] // bare slugs — verify?app=tasks strips the prefix
|
||||
}
|
||||
|
||||
// Users with access to this app, served by the central auth service.
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue