diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index 8b91259d..3940b311 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -74,6 +74,7 @@ import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; import type { TaskSortBy } from "@/lib/api/models/app/crm/SearchTasks"; import { EMPTY_TASK_SEARCH } from "@/lib/api/models/app/crm/SearchTasks"; import type OrganizationMember from "@/lib/api/models/app/organizations/OrganizationMember"; +import type Team from "@/lib/api/models/app/teams/Team"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import TaskTypePicker from "@/components/app/crm/TaskTypePicker"; @@ -169,6 +170,13 @@ export default function TasksPage() { return m; }, [members]); + const { data: teams = [] } = useTeams(); + const teamById = React.useMemo(() => { + const m = new Map(); + for (const t of teams) m.set(t.id, t); + return m; + }, [teams]); + const { data: types = [] } = useTaskTypes(); const statusTab: "all" | CRMTaskStatus = @@ -185,6 +193,7 @@ export default function TasksPage() { filters.priorities.length + filters.types.length + filters.assigned_to.length + + filters.team_ids.length + (filters.overdue ? 1 : 0); return ( @@ -241,6 +250,11 @@ export default function TasksPage() { selected={filters.assigned_to} onChange={(ids) => setFilters((f) => ({ ...f, assigned_to: ids }))} /> + setFilters((f) => ({ ...f, team_ids: ids }))} + /> @@ -281,6 +296,7 @@ export default function TasksPage() { @@ -297,12 +313,18 @@ export default function TasksPage() { )} - setNewOpen(false)} members={members} /> + setNewOpen(false)} + members={members} + teams={teams} + /> setEditing(null)} editing={editing ?? undefined} members={members} + teams={teams} /> ); @@ -313,11 +335,13 @@ export default function TasksPage() { function FlatView({ tasks, memberByUser, + teamById, types, onOpen, }: { tasks: CRMTask[]; memberByUser: Map; + teamById: Map; types: { name: string; color: string }[]; onOpen: (t: CRMTask) => void; }) { @@ -340,6 +364,7 @@ function FlatView({ key={t.id} task={t} member={t.assigned_to ? memberByUser.get(t.assigned_to) : undefined} + team={t.assigned_team_id ? teamById.get(t.assigned_team_id) : undefined} types={types} onOpen={() => onOpen(t)} /> @@ -352,11 +377,13 @@ function FlatView({ function FlatRow({ task, member, + team, types, onOpen, }: { task: CRMTask; member?: OrganizationMember; + team?: Team; types: { name: string; color: string }[]; onOpen: () => void; }) { @@ -424,7 +451,12 @@ function FlatRow({ - + ; + teamById: Map; types: { name: string; color: string }[]; onOpen: (t: CRMTask) => void; }) { @@ -521,6 +555,7 @@ function GroupedView({ bucket={b} tasks={items} memberByUser={memberByUser} + teamById={teamById} types={types} onOpen={onOpen} /> @@ -534,12 +569,14 @@ function BucketGroup({ bucket, tasks, memberByUser, + teamById, types, onOpen, }: { bucket: { id: Bucket; label: string; tone: keyof typeof TONE }; tasks: CRMTask[]; memberByUser: Map; + teamById: Map; types: { name: string; color: string }[]; onOpen: (t: CRMTask) => void; }) { @@ -562,6 +599,7 @@ function BucketGroup({ key={t.id} task={t} member={t.assigned_to ? memberByUser.get(t.assigned_to) : undefined} + team={t.assigned_team_id ? teamById.get(t.assigned_team_id) : undefined} types={types} onOpen={onOpen} /> @@ -574,11 +612,13 @@ function BucketGroup({ function GroupedRow({ task, member, + team, types, onOpen, }: { task: CRMTask; member?: OrganizationMember; + team?: Team; types: { name: string; color: string }[]; onOpen: (t: CRMTask) => void; }) { @@ -622,7 +662,13 @@ function GroupedRow({ > {task.title} - + @@ -666,26 +712,59 @@ function memberInitials(member?: OrganizationMember, assignedTo?: string): strin function AssigneeCell({ member, assignedTo, + team, + assignedTeamId, compact = false, }: { member?: OrganizationMember; assignedTo?: string; + team?: Team; + assignedTeamId?: string; compact?: boolean; }) { - if (!assignedTo) { + // A task may carry a person, a team, both, or neither. Render whichever are + // present; only fall back to "Unassigned" when nothing is set. + if (!assignedTo && !assignedTeamId) { return {compact ? "" : "Unassigned"}; } const label = memberLabel(member, assignedTo); const initials = memberInitials(member, assignedTo); + return ( + + {assignedTo && ( + + + {initials} + + {!compact && {label}} + + )} + {assignedTeamId && } + + ); +} + +function teamLabel(team?: Team, teamId?: string): string { + const name = team?.name?.trim(); + if (name) return name; + return teamId ? `Team ${teamId.slice(0, 6)}` : ""; +} + +function TeamChip({ team, teamId, compact = false }: { team?: Team; teamId?: string; compact?: boolean }) { + const label = teamLabel(team, teamId); return ( - - {initials} - - {!compact && {label}} + + {!compact && {label}} ); } @@ -851,6 +930,76 @@ function AssigneeFacet({ ); } +function TeamFacet({ + teams, + selected, + onChange, +}: { + teams: Team[]; + selected: string[]; + onChange: (ids: string[]) => void; +}) { + const [open, setOpen] = React.useState(false); + const label = + selected.length === 0 + ? "Any team" + : selected.length === 1 + ? teamLabel(teams.find((t) => t.id === selected[0]), selected[0]) + : `${selected.length} teams`; + + function toggle(id: string) { + onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]); + } + + return ( + + + + + + {teams.length === 0 ? ( + setOpen(false)} + className="flex items-center gap-2 px-3 h-8 text-[11.5px] text-slate-500 hover:text-sky-700 hover:bg-slate-50 transition-colors" + > + + No teams yet, create one in Settings + + ) : ( + teams.map((t) => ( + toggle(t.id)} + selected={selected.includes(t.id)} + closeOnSelect={false} + icon={ + + } + > + {t.name} + + )) + )} + + + ); +} + function TypeFacet({ types, selected, @@ -1221,11 +1370,13 @@ function TaskDialog({ onClose, editing, members, + teams, }: { open: boolean; onClose: () => void; editing?: CRMTask; members: OrganizationMember[]; + teams: Team[]; }) { const create = useCreateCRMTask(); const update = useUpdateCRMTask(); @@ -1239,6 +1390,7 @@ function TaskDialog({ const [type, setType] = React.useState(""); const [status, setStatus] = React.useState("pending"); const [assignedTo, setAssignedTo] = React.useState(""); + const [assignedTeamId, setAssignedTeamId] = React.useState(""); React.useEffect(() => { if (!open) return; @@ -1250,6 +1402,7 @@ function TaskDialog({ setType(editing.type ?? ""); setStatus(editing.status); setAssignedTo(editing.assigned_to ?? ""); + setAssignedTeamId(editing.assigned_team_id ?? ""); } else { setTitle(""); setDescription(""); @@ -1258,6 +1411,7 @@ function TaskDialog({ setType(""); setStatus("pending"); setAssignedTo(""); + setAssignedTeamId(""); } }, [open, editing]); @@ -1274,6 +1428,7 @@ function TaskDialog({ if (description.trim()) data.description = description.trim(); if (dueDays !== null) data.due_date = dueInDaysToISO(dueDays); if (assignedTo) data.assigned_to = assignedTo; + if (assignedTeamId) data.assigned_team_id = assignedTeamId; if (editing) data.status = status; try { @@ -1394,6 +1549,9 @@ function TaskDialog({ value={assignedTo} members={members} onChange={setAssignedTo} + teams={teams} + teamValue={assignedTeamId} + onTeamChange={setAssignedTeamId} /> @@ -1468,14 +1626,30 @@ function AssigneePicker({ value, members, onChange, + teams, + teamValue, + onTeamChange, }: { value: string; members: OrganizationMember[]; onChange: (id: string) => void; + teams: Team[]; + teamValue: string; + onTeamChange: (id: string) => void; }) { const [open, setOpen] = React.useState(false); const cur = members.find((m) => m.user_id === value); - const { data: teams = [] } = useTeams(); + const curTeam = teams.find((t) => t.id === teamValue); + + // Person and team are independent: a task can set one, both, or neither. + // The trigger summarizes whichever are selected. + const triggerLabel = cur + ? curTeam + ? `${memberLabel(cur)} + ${curTeam.name}` + : memberLabel(cur) + : curTeam + ? curTeam.name + : "Unassigned"; return ( @@ -1488,28 +1662,36 @@ function AssigneePicker({ {memberInitials(cur)} + ) : curTeam ? ( + ) : ( )} - - {cur ? memberLabel(cur) : "Unassigned"} - + {triggerLabel} - + +
+ Person +
onChange("")} selected={value === ""} + closeOnSelect={false} icon={} > - Unassigned + No person {members.map((m) => ( onChange(m.user_id)} selected={m.user_id === value} + closeOnSelect={false} icon={ {memberInitials(m)} @@ -1520,14 +1702,46 @@ function AssigneePicker({ ))}
- setOpen(false)} - className="flex items-center gap-2 px-3 h-8 text-[12px] text-slate-500 hover:text-sky-700 hover:bg-slate-50 transition-colors" - > - - {teams.length === 0 ? "No teams yet — create one in Settings" : "Manage teams"} - +
+ Team +
+ {teams.length === 0 ? ( + setOpen(false)} + className="flex items-center gap-2 px-3 h-8 text-[12px] text-slate-500 hover:text-sky-700 hover:bg-slate-50 transition-colors" + > + + No teams yet, create one in Settings + + ) : ( + <> + onTeamChange("")} + selected={teamValue === ""} + closeOnSelect={false} + icon={} + > + No team + + {teams.map((t) => ( + onTeamChange(t.id)} + selected={t.id === teamValue} + closeOnSelect={false} + icon={ + + } + > + {t.name} + + ))} + + )} ); @@ -1580,6 +1794,7 @@ function hasAnyFilter(f: SearchTasks): boolean { f.priorities.length > 0 || f.types.length > 0 || f.assigned_to.length > 0 || + f.team_ids.length > 0 || !!f.contact_id || !!f.deal_id || !!f.due_after || diff --git a/web/src/lib/api/models/app/crm/CRMTask.ts b/web/src/lib/api/models/app/crm/CRMTask.ts index 8aea1d2c..accd14c8 100644 --- a/web/src/lib/api/models/app/crm/CRMTask.ts +++ b/web/src/lib/api/models/app/crm/CRMTask.ts @@ -7,6 +7,7 @@ export default interface CRMTask { contact_id?: string; deal_id?: string; assigned_to?: string; + assigned_team_id?: string; created_by: string; title: string; description?: string; diff --git a/web/src/lib/api/models/app/crm/SearchTasks.ts b/web/src/lib/api/models/app/crm/SearchTasks.ts index 8548d327..2f136017 100644 --- a/web/src/lib/api/models/app/crm/SearchTasks.ts +++ b/web/src/lib/api/models/app/crm/SearchTasks.ts @@ -20,6 +20,10 @@ export default interface SearchTasks { types: string[]; // User UUIDs (assigned_to). String-matched on the server. assigned_to: string[]; + // Team UUIDs. Matches a task whose assigned_team_id is one of these, OR + // whose assigned_to is a member of one of these teams. The same predicate + // drives both the rows query and the summary aggregate. + team_ids: string[]; contact_id?: string; deal_id?: string; due_after?: string; @@ -37,6 +41,7 @@ export const EMPTY_TASK_SEARCH: SearchTasks = { priorities: [], types: [], assigned_to: [], + team_ids: [], sort_by: "created_at", reverse: false, };