diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57c0da1..af97530 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4f6e943..a31bd34 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }) } +// ── Users (for tag/assign/reallocate pickers) ─────────────────── +export function fetchAssignableUsers(): Promise { + return request('/auth/users?app=tasks') +} + // ── Config ─────────────────────────────────────────────────── export function fetchConfig(): Promise { return request('/config') diff --git a/frontend/src/components/TaskDetail.tsx b/frontend/src/components/TaskDetail.tsx index 3e8cf23..6e03410 100644 --- a/frontend/src/components/TaskDetail.tsx +++ b/frontend/src/components/TaskDetail.tsx @@ -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 }: )} {isOwner && ( -
- setTagEmail(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') addTag() }} /> - -
+ tg.user_email)]} /> )} {isOwner && can(user, 'assign') && (
-
- setAssignEmail(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') assignTo() }} /> - -
+ {task.assigned_to &&
Currently assigned to {task.assigned_to}
}
)} @@ -251,12 +243,7 @@ export default function TaskDetail({ taskId, onChange, onClose }: {can(user, 'reallocate') && (
-
- setReallocateEmail(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') reallocateTo() }} /> - -
+
)} diff --git a/frontend/src/components/shared.tsx b/frontend/src/components/shared.tsx index b771665..3368b04 100644 --- a/frontend/src/components/shared.tsx +++ b/frontend/src/components/shared.tsx @@ -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 ( +
+ + +
+ ) +} + interface FilterableEntity { id: number; name: string; colour: string } // Collapsible multi-select filter chip-bar — generic over anything with an diff --git a/frontend/src/context/UsersContext.tsx b/frontend/src/context/UsersContext.tsx new file mode 100644 index 0000000..9d190af --- /dev/null +++ b/frontend/src/context/UsersContext.tsx @@ -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 +} + +const Ctx = createContext(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([]) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async () => { + const data = await fetchAssignableUsers().catch(() => []) + setUsers(data) + setLoading(false) + }, []) + + useEffect(() => { reload() }, [reload]) + + return {children} +} diff --git a/frontend/src/pages/DispatchView.tsx b/frontend/src/pages/DispatchView.tsx index 641265d..b89c7a2 100644 --- a/frontend/src/pages/DispatchView.tsx +++ b/frontend/src/pages/DispatchView.tsx @@ -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() { {movingId === t.id ? (
- setTarget(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') move(t.id); if (e.key === 'Escape') setMovingId(null) }} /> - + move(t.id)} buttonLabel="Move" + exclude={[t.owner_id]} placeholder="reallocate to…" />
) : ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index daee638..95d6ae4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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) }