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 { StatusesProvider } from './context/StatusesContext'
|
||||||
import { CategoriesProvider } from './context/CategoriesContext'
|
import { CategoriesProvider } from './context/CategoriesContext'
|
||||||
import { ProjectsProvider } from './context/ProjectsContext'
|
import { ProjectsProvider } from './context/ProjectsContext'
|
||||||
|
import { UsersProvider } from './context/UsersContext'
|
||||||
import { SearchProvider } from './context/SearchContext'
|
import { SearchProvider } from './context/SearchContext'
|
||||||
import { TaskFiltersProvider } from './context/TaskFiltersContext'
|
import { TaskFiltersProvider } from './context/TaskFiltersContext'
|
||||||
import { TaskDrawerProvider } from './components/TaskDrawer'
|
import { TaskDrawerProvider } from './components/TaskDrawer'
|
||||||
|
|
@ -30,6 +31,7 @@ export default function App() {
|
||||||
<StatusesProvider>
|
<StatusesProvider>
|
||||||
<CategoriesProvider>
|
<CategoriesProvider>
|
||||||
<ProjectsProvider>
|
<ProjectsProvider>
|
||||||
|
<UsersProvider>
|
||||||
<SearchProvider>
|
<SearchProvider>
|
||||||
<TaskFiltersProvider>
|
<TaskFiltersProvider>
|
||||||
<TaskDrawerProvider>
|
<TaskDrawerProvider>
|
||||||
|
|
@ -51,6 +53,7 @@ export default function App() {
|
||||||
</TaskDrawerProvider>
|
</TaskDrawerProvider>
|
||||||
</TaskFiltersProvider>
|
</TaskFiltersProvider>
|
||||||
</SearchProvider>
|
</SearchProvider>
|
||||||
|
</UsersProvider>
|
||||||
</ProjectsProvider>
|
</ProjectsProvider>
|
||||||
</CategoriesProvider>
|
</CategoriesProvider>
|
||||||
</StatusesProvider>
|
</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'
|
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 }) })
|
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 ───────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────
|
||||||
export function fetchConfig(): Promise<AppConfig> {
|
export function fetchConfig(): Promise<AppConfig> {
|
||||||
return request('/config')
|
return request('/config')
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { useProjects } from '../context/ProjectsContext'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
import { useAuth } from './AuthGate'
|
import { useAuth } from './AuthGate'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import { Check_, formatDateTime, toLocalInput } from './shared'
|
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
||||||
|
|
||||||
export default function TaskDetail({ taskId, onChange, onClose }:
|
export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
||||||
|
|
@ -226,24 +226,16 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
|
||||||
<input type="email" value={tagEmail} placeholder="colleague@hotel.com"
|
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOwner && can(user, 'assign') && (
|
{isOwner && can(user, 'assign') && (
|
||||||
<div className="field" style={{ marginBottom: can(user, 'reallocate') ? 8 : 0 }}>
|
<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>
|
<label><UserPlus size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Assign (hands off ownership)</label>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<UserPicker value={assignEmail} onChange={setAssignEmail} onSubmit={assignTo} buttonLabel="Assign"
|
||||||
<input type="email" value={assignEmail} placeholder={task.assigned_to ?? 'someone@hotel.com'}
|
exclude={[user.email]} />
|
||||||
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>
|
|
||||||
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
|
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -251,12 +243,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
{can(user, 'reallocate') && (
|
{can(user, 'reallocate') && (
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
|
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<UserPicker value={reallocateEmail} onChange={setReallocateEmail} onSubmit={reallocateTo} buttonLabel="Move" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useCategories } from '../context/CategoriesContext'
|
||||||
import { useProjects } from '../context/ProjectsContext'
|
import { useProjects } from '../context/ProjectsContext'
|
||||||
import { useLists } from '../context/ListsContext'
|
import { useLists } from '../context/ListsContext'
|
||||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||||
|
import { useUsers } from '../context/UsersContext'
|
||||||
|
|
||||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||||
if (priority === 'medium') return null
|
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 }
|
interface FilterableEntity { id: number; name: string; colour: string }
|
||||||
|
|
||||||
// Collapsible multi-select filter chip-bar — generic over anything with an
|
// 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 { useSearch } from '../context/SearchContext'
|
||||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||||
import { can } from '../types'
|
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
|
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
|
||||||
// through the shared task drawer, since GET /api/tasks/:id only grants
|
// through the shared task drawer, since GET /api/tasks/:id only grants
|
||||||
|
|
@ -83,10 +83,8 @@ export default function DispatchView() {
|
||||||
</div>
|
</div>
|
||||||
{movingId === t.id ? (
|
{movingId === t.id ? (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<input type="email" autoFocus value={target} placeholder="reallocate to…" style={{ width: 180 }}
|
<UserPicker value={target} onChange={setTarget} onSubmit={() => move(t.id)} buttonLabel="Move"
|
||||||
onChange={e => setTarget(e.target.value)}
|
exclude={[t.owner_id]} placeholder="reallocate to…" />
|
||||||
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>
|
|
||||||
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
|
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,13 @@ export interface User {
|
||||||
caps: string[] // bare slugs — verify?app=tasks strips the prefix
|
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 {
|
export function can(user: User, cap: string): boolean {
|
||||||
return user.is_admin || user.caps.includes(cap)
|
return user.is_admin || user.caps.includes(cap)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue