From 94989e4bcd51ca8bfd04a37c74dc5f473ef4cad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 12 May 2026 12:38:02 -0300 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20:sparkles:=20melhoria=20nas=20dis?= =?UTF-8?q?posi=C3=A7ao=20das=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 ++- docs/views/planning.md | 3 ++- src/components/AppRouter.tsx | 1 + src/components/AppSidebar.tsx | 18 ++++--------- src/hooks/infra/useSupabase.ts | 2 ++ src/hooks/ui/useFormState.ts | 2 ++ src/types/props.ts | 1 + src/views/planning/PlanningView.tsx | 27 ++++++++++++++++--- .../planning/components/TasksFilters.tsx | 19 ++++++++++--- vite.config.ts | 2 +- 10 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0c3ddd5..c46d5e5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -46,7 +46,9 @@ "Bash(where jq *)", "Bash(powershell -NonInteractive -Command ' *)", "Bash(powershell -NonInteractive -Command \"Get-Content '.claude/settings.json' | ConvertFrom-Json | ConvertTo-Json -Depth 10\")", - "Bash(node -e \"const rw = require\\('react-window'\\); console.log\\(Object.keys\\(rw\\)\\)\")" + "Bash(node -e \"const rw = require\\('react-window'\\); console.log\\(Object.keys\\(rw\\)\\)\")", + "Bash(git log *)", + "Bash(Get-Content .env.example)" ] }, "hooks": { diff --git a/docs/views/planning.md b/docs/views/planning.md index 5668bfa..19f5b9c 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -28,7 +28,7 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | `src/views/planning/components/FilterBar.tsx` | Barra de filtros usada por calendar e timeline | | `src/views/planning/components/MetricsBar.tsx` | Cards de métricas (saúde operacional, em andamento, bloqueadas) | | `src/views/planning/components/StepsLegend.tsx` | Legenda de cores das fases | -| `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, etapa, responsável, período, bloqueadas) | +| `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, etapa, responsável, período, bloqueadas, concluídas) | | `src/views/planning/components/StepGroup.tsx` | Grupo colapsável de tasks por etapa (usado em `demandas`) | | `src/views/planning/components/TaskRow.tsx` | Linha de uma demanda no subview `demandas` | | `src/views/planning/components/ActionMenu.tsx` | Dropdown de ações rápidas (abrir, ClickUp, concluir, bloquear) | @@ -81,6 +81,7 @@ Barra de filtros com `CheckboxDropdown` customizado para etapa e responsável. F | Responsável | `selectedMemberIds: string[]` — qualquer assignee de qualquer step | | Período (prazo) | Tabs "Todos / 7d / 15d / 30d" — compara `currentStep.end` com `today + N dias` | | Bloqueadas | Toggle — filtra `task.status.blocked === true` | +| Concluídas | Toggle — mostra tarefas com `task.concludedAt` preenchido (default: ocultas) | ### Ordenação dentro dos grupos Tasks ordenadas por `end` do step correspondente — da mais atrasada para a mais recente. Sem data ficam no final. diff --git a/src/components/AppRouter.tsx b/src/components/AppRouter.tsx index e255832..89a42ca 100644 --- a/src/components/AppRouter.tsx +++ b/src/components/AppRouter.tsx @@ -76,6 +76,7 @@ export function AppRouter() { {PLANNING_VIEWS.has(view) && ( (() => { const initial: string[] = [] - if (CALENDAR_VIEWS.includes(view)) initial.push("Calendário") if (TOOLS_VIEWS.includes(view)) initial.push("Ferramentas") if (REPORTS_VIEWS.includes(view)) initial.push("Relatórios") return initial @@ -219,7 +208,10 @@ export function AppSidebar() { ) } - const isActive = view === item.view + const PLANNING_VIEWS: ViewType[] = ["demandas", "calendar", "timeline", "list"] + const isActive = item.view === "demandas" + ? PLANNING_VIEWS.includes(view) + : view === item.view const button = ( + +
{hasActiveFilters && ( -``` - -### Comportamento esperado - -| Estado | Botão | -|---|---| -| Modal acabou de abrir (sem edição) | Desabilitado | -| Utilizador alterou algum campo | Habilitado | -| Clicou salvar (request em curso) | Desabilitado + texto "A guardar…" | -| Request concluído | Fecha modal ou re-habilita conforme resultado | - -### Ficheiros a tocar (UX) - -- Componente do modal de task (TaskModal ou equivalente) — adicionar `original`, `isDirty`, `submitting` - ---- - -## Ficheiros a tocar - -- `src/hooks/useSupabase.ts` — substituir `insertSteps` + bloco delete em `updateTask` -- `src/lib/steps.ts` — confirmar que `Step.id` é obrigatório e preservado -- Componente de edição de task (TaskModal ou equivalente) — garantir que `step.id` não é limpo ao editar - -## Notas - -- `step_assignees` não tem ID próprio exposto, por isso o delete+reinsert nos assignees por `step_id` é aceitável (não gera problema de IDs). -- Apenas os `task_steps` é que devem mudar de delete+insert para update/insert. +Ver: [ADR-019](../decisions.md#adr-019-steps--subtasks-modelo-flexível-por-demanda) diff --git a/docs/views/planning.md b/docs/views/planning.md index 19f5b9c..7a16651 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -48,10 +48,12 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad ## Subview: Demandas -Lista todas as demandas agrupadas pela **etapa atual** (step ativo). Focada em acompanhamento operacional. +Lista todas as demandas agrupadas por **subtask status**. Focada em acompanhamento operacional. ### StepGroup -Agrupa tasks por `StepType`. Colapsa/expande via `ChevronDown`/`ChevronRight`. +Agrupa tasks por `SubtaskStatus` (8 grupos fixos). Colapsa/expande via `ChevronDown`/`ChevronRight`. + +Uma demanda aparece em **todos os grupos** onde tiver subtask ativa (`active: true`) — não apenas no grupo da "subtask atual". Com filtro por membro, aparece nos grupos das subtasks onde o membro está atribuído. Recebe `hasActiveFilters?: boolean`. Comportamento por estado: - **Com tasks:** expansível normalmente, cabeçalho com contador colorido @@ -68,8 +70,8 @@ Exibe: - Ícone `Link2` inline para abrir o ClickUp diretamente - Badge "Bloqueada" (vermelho) — fundo da linha fica vermelho sutil - Badge "Concluída" (muted) — linha com opacidade reduzida -- Badge de prazo dinâmico baseado no `end` do step ativo (`formatDueDate`) -- Avatares dos responsáveis do step correspondente ao grupo +- Badge de prazo dinâmico baseado no `end` da subtask ativa do grupo (`formatDueDate`) +- Avatares dos responsáveis da subtask correspondente ao grupo ### TasksFilters Barra de filtros com `CheckboxDropdown` customizado para etapa e responsável. Filtros: @@ -77,17 +79,14 @@ Barra de filtros com `CheckboxDropdown` customizado para etapa e responsável. F | Filtro | Implementação | |---|---| | Busca por texto/ID | `task.title` e `task.id` case-insensitive | -| Etapa | `selectedSteps: StepType[]` — verifica `currentStep.type` | -| Responsável | `selectedMemberIds: string[]` — qualquer assignee de qualquer step | -| Período (prazo) | Tabs "Todos / 7d / 15d / 30d" — compara `currentStep.end` com `today + N dias` | +| Etapa | `selectedSteps: SubtaskStatus[]` — verifica `subtask.status` das ativas | +| Responsável | `selectedMemberIds: string[]` — qualquer assignee de qualquer subtask | +| Período (prazo) | Tabs "Todos / 7d / 15d / 30d" — compara `end` da subtask ativa com `today + N dias` | | Bloqueadas | Toggle — filtra `task.status.blocked === true` | | Concluídas | Toggle — mostra tarefas com `task.concludedAt` preenchido (default: ocultas) | ### Ordenação dentro dos grupos -Tasks ordenadas por `end` do step correspondente — da mais atrasada para a mais recente. Sem data ficam no final. - -### Agrupamento -"Etapa atual" é `task.steps.find(s => s.active) ?? task.steps[0]`. Uma task aparece em **todas** as categorias cujos steps estão `active: true`. Com filtro por membro, aparece nos grupos de **todos os steps onde o membro está atribuído**. +Tasks ordenadas por `end` da subtask ativa no grupo — da mais atrasada para a mais recente. Sem data ficam no final. ## Subview: Calendar diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index 957e30b..0fb6b8a 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -2,19 +2,45 @@ import React, { useState } from 'react'; import { useMembersQuery } from '@/hooks/members/useMembersQuery'; import { useClients } from '@/hooks/clients/useClients'; import { Input, Label, Button, ConfirmModal } from './ui'; -import { Save, ExternalLink, Trash2, Users, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { Save, ExternalLink, Trash2, Users, AlertCircle, CheckCircle2, Plus, GripVertical, X } from 'lucide-react'; import { STEP_META, - createDefaultSteps, + STEP_TYPES_ORDER, migrateLegacyTask, - type Step, - type StepType, + type Subtask, + type SubtaskStatus, type TaskStatus, } from '../lib/steps'; import type { TaskModalProps } from '../types/props'; import { useFormState } from '@/hooks/ui/useFormState'; import { isWeekendOrHoliday, getHolidayName, nextNonHolidayBusinessDay } from '../utils/holidayUtils'; +let _subtaskCounter = 0; +function tempId() { + return `__new__${++_subtaskCounter}`; +} + +/** Subtask com id temporário para subtasks novas (ainda não no DB) */ +type SubtaskDraft = Subtask & { _tempId: string }; + +function draftFromSubtask(s: Subtask): SubtaskDraft { + return { ...s, _tempId: s.id || tempId() }; +} + +function newDraft(order: number): SubtaskDraft { + return { + _tempId: tempId(), + id: '', + title: '', + status: 'design' as SubtaskStatus, + start: '', + end: '', + assignees: [], + active: true, + order, + }; +} + const TaskModal: React.FC = ({ task, members: propMembers, onClose, onSave, onDelete, holidays }) => { const { effectiveClientId } = useClients(); const { data: storeMembers = [] } = useMembersQuery(effectiveClientId); @@ -30,15 +56,15 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo init.status.blockedAt ?? new Date().toISOString().split('T')[0] ); const [concludedAt, setConcludedAt] = useState(task?.concludedAt); - const [steps, setSteps] = useState( - task ? init.steps : createDefaultSteps() + const [subtasks, setSubtasks] = useState( + init.subtasks.map(draftFromSubtask) ); const [errors, setErrors] = useState>({}); const [pendingSubmitData, setPendingSubmitData] = useState[0] | null>(null); const [confirmMessage, setConfirmMessage] = useState(''); const [showDirtyCloseConfirm, setShowDirtyCloseConfirm] = useState(false); - const formSnapshot = { title, clickupLink, blocked, blockedAt, steps, concludedAt }; + const formSnapshot = { title, clickupLink, blocked, blockedAt, subtasks, concludedAt }; const { isDirty, submitting, withSubmit } = useFormState( formSnapshot, !task, @@ -46,38 +72,48 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo ); const handleRequestClose = () => { - if (!isDirty) { - onClose(); - return; - } - + if (!isDirty) { onClose(); return; } setShowDirtyCloseConfirm(true); }; + const activeSubtasks = subtasks.filter(s => s.active); + const validate = () => { const e: Record = {}; if (!title || title.length < 3) e.title = 'Título obrigatório (mín. 3 caracteres).'; if (clickupLink) { try { new URL(clickupLink) } catch { e.clickupLink = 'Insira um URL válido (ex: https://...).'; } } - if (activeCount === 0) e.steps = 'Selecione pelo menos um step.'; - steps.filter(s => s.active).forEach(s => { - if (!s.start || !s.end) e[`${s.type}-dates`] = 'Datas obrigatórias quando step está ativo.'; - else if (s.end < s.start) e[`${s.type}-dates`] = 'Fim anterior ao início.'; + if (activeSubtasks.length === 0) e.subtasks = 'Adicione pelo menos uma subtask ativa.'; + subtasks.forEach(s => { + if (!s.title.trim()) e[`${s._tempId}-title`] = 'Título obrigatório.'; + if (!s.start || !s.end) e[`${s._tempId}-dates`] = 'Datas obrigatórias.'; + else if (s.end < s.start) e[`${s._tempId}-dates`] = 'Fim anterior ao início.'; }); setErrors(e); return Object.keys(e).length === 0; }; + const buildTaskData = (drafts: SubtaskDraft[]) => { + const finalSubtasks: Subtask[] = drafts.map((s, i) => ({ + id: s.id, + title: s.title, + status: s.status, + start: s.start, + end: s.end, + assignees: s.assignees, + active: s.active, + order: i, + })); + const status: TaskStatus = { blocked, blockedAt: blocked ? blockedAt : undefined }; + return { ...task, title, clickupLink, status, subtasks: finalSubtasks, concludedAt }; + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!validate()) return; - const status: TaskStatus = { - blocked, - blockedAt: blocked ? blockedAt : undefined, - }; - const taskData = { ...task, title, clickupLink, status, steps, concludedAt }; + const taskData = buildTaskData(subtasks); const describeDateConflict = (date: string): string => { const holidayName = getHolidayName(date, holidays); @@ -86,18 +122,17 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo return `${date} (${dow === 0 ? 'Domingo' : 'Sábado'})`; }; - const affected = steps - .filter(s => s.active) + const affected = subtasks .filter(s => (s.start && isWeekendOrHoliday(s.start, holidays)) || (s.end && isWeekendOrHoliday(s.end, holidays))) .map(s => { const parts: string[] = []; if (s.start && isWeekendOrHoliday(s.start, holidays)) parts.push(`início em ${describeDateConflict(s.start)}`); if (s.end && isWeekendOrHoliday(s.end, holidays)) parts.push(`fim em ${describeDateConflict(s.end)}`); - return `• ${STEP_META[s.type]?.label ?? s.type}: ${parts.join(' e ')}`; + return `• ${s.title || STEP_META[s.status]?.label}: ${parts.join(' e ')}`; }); if (affected.length > 0) { - setConfirmMessage(`As seguintes fases têm datas em fim de semana ou feriado:\n\n${affected.join('\n')}\n\nDeseja salvar mesmo assim?`); + setConfirmMessage(`As seguintes subtasks têm datas em fim de semana ou feriado:\n\n${affected.join('\n')}\n\nDeseja salvar mesmo assim?`); setPendingSubmitData(taskData as Parameters[0]); return; } @@ -111,55 +146,49 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo withSubmit(() => onSave(pendingSubmitData)); }; - const handleCancelWeekend = () => { - setPendingSubmitData(null); - }; + const handleCancelWeekend = () => setPendingSubmitData(null); const handlePostponeWeekend = () => { if (!pendingSubmitData) return; - const adjustedTaskData = { + const adjusted = { ...pendingSubmitData, - steps: pendingSubmitData.steps.map(step => { - if (!step.active) return step; - - const adjustedStart = step.start && isWeekendOrHoliday(step.start, holidays) - ? nextNonHolidayBusinessDay(step.start, holidays) - : step.start; - let adjustedEnd = step.end && isWeekendOrHoliday(step.end, holidays) - ? nextNonHolidayBusinessDay(step.end, holidays) - : step.end; - - if (adjustedStart && adjustedEnd && adjustedEnd < adjustedStart) { - adjustedEnd = adjustedStart; - } - - return { ...step, start: adjustedStart, end: adjustedEnd }; + subtasks: pendingSubmitData.subtasks.map((s: Subtask) => { + const adjustedStart = s.start && isWeekendOrHoliday(s.start, holidays) + ? nextNonHolidayBusinessDay(s.start, holidays) + : s.start; + let adjustedEnd = s.end && isWeekendOrHoliday(s.end, holidays) + ? nextNonHolidayBusinessDay(s.end, holidays) + : s.end; + if (adjustedStart && adjustedEnd && adjustedEnd < adjustedStart) adjustedEnd = adjustedStart; + return { ...s, start: adjustedStart, end: adjustedEnd }; }), }; setPendingSubmitData(null); - withSubmit(() => onSave(adjustedTaskData)); + withSubmit(() => onSave(adjusted)); + }; + + const addSubtask = () => { + setSubtasks(prev => [...prev, newDraft(prev.length)]); }; - const toggleStep = (type: StepType) => { - setSteps(prev => prev.map(s => s.type === type ? { ...s, active: !s.active } : s)); + const removeSubtask = (tempId: string) => { + setSubtasks(prev => prev.filter(s => s._tempId !== tempId)); }; - const updateStep = (type: StepType, field: keyof Step, value: Step[keyof Step]) => { - setSteps(prev => prev.map(s => s.type === type ? { ...s, [field]: value } : s)); + const updateSubtask = (tempId: string, field: K, value: SubtaskDraft[K]) => { + setSubtasks(prev => prev.map(s => s._tempId === tempId ? { ...s, [field]: value } : s)); }; - const toggleAssignee = (type: StepType, memberId: string) => { - setSteps(prev => prev.map(s => { - if (s.type !== type) return s; + const toggleAssignee = (tempId: string, memberId: string) => { + setSubtasks(prev => prev.map(s => { + if (s._tempId !== tempId) return s; const has = s.assignees.includes(memberId); return { ...s, assignees: has ? s.assignees.filter(id => id !== memberId) : [...s.assignees, memberId] }; })); }; - const activeCount = steps.filter(s => s.active).length; - return (
= ({ task, members: propMembers, onClo Bloqueado
- Steps a partir da data de bloqueio ficam em alerta vermelho + Subtasks a partir da data de bloqueio ficam em alerta vermelho
@@ -305,125 +334,135 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo )} - {/* Steps */} + {/* Subtasks */}
- {errors.steps &&

{errors.steps}

}
- + - {activeCount} ativo{activeCount !== 1 ? 's' : ''} + {activeSubtasks.length} ativa{activeSubtasks.length !== 1 ? 's' : ''}
-
- {steps.map((step) => { - const meta = STEP_META[step.type as StepType]; - if (!meta) return null; + {errors.subtasks &&

{errors.subtasks}

} + +
+ {subtasks.map((subtask) => { + const meta = STEP_META[subtask.status]; return (
- {/* Step header — always visible */} - +
+ + {/* Subtask body — responsáveis + datas */} +
+ {errors[`${subtask._tempId}-title`] && ( +

{errors[`${subtask._tempId}-title`]}

)} - - - {/* Step details — only when active */} - {step.active && ( -
- - {/* Responsáveis */} -
- - Responsáveis - (opcional) - -
- {resolvedMembers.map(m => { - const sel = step.assignees.includes(m.id); - return ( - - ); - })} -
+ + {/* Responsáveis */} +
+ + Responsáveis + (opcional) + +
+ {resolvedMembers.map(m => { + const sel = subtask.assignees.includes(m.id); + return ( + + ); + })}
+
- {/* Datas */} -
-
- Início - updateStep(step.type, 'start', e.target.value)} - className="h-8 text-xs bg-white/70 dark:bg-black/20 border-0 focus:ring-1" - /> -
-
- Fim - updateStep(step.type, 'end', e.target.value)} - className="h-8 text-xs bg-white/70 dark:bg-black/20 border-0 focus:ring-1" - /> -
+ {/* Datas */} +
+
+ Início + updateSubtask(subtask._tempId, 'start', e.target.value)} + className="h-8 text-xs bg-white/70 dark:bg-black/20 border-0 focus:ring-1" + /> +
+
+ Fim + updateSubtask(subtask._tempId, 'end', e.target.value)} + className="h-8 text-xs bg-white/70 dark:bg-black/20 border-0 focus:ring-1" + />
- {errors[`${step.type}-dates`] && ( -

{errors[`${step.type}-dates`]}

- )}
- )} + {errors[`${subtask._tempId}-dates`] && ( +

{errors[`${subtask._tempId}-dates`]}

+ )} +
); })}
+ +
diff --git a/src/hooks/infra/useSupabase.ts b/src/hooks/infra/useSupabase.ts index 767cf09..993e6cd 100644 --- a/src/hooks/infra/useSupabase.ts +++ b/src/hooks/infra/useSupabase.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useThrottledMutation } from '@/hooks/infra/useThrottledMutation' import { supabase } from '@/lib/supabase' -import type { Task, Step } from '@/lib/steps' +import type { Task, Subtask } from '@/lib/steps' import { toast } from 'sonner' import { logAudit } from '@/lib/audit' import { useTaskStore } from '@/store/useTaskStore' @@ -33,37 +33,41 @@ interface UseSupabaseOptions { isAdmin?: boolean } -async function createAllSteps(taskId: string, steps: Step[]): Promise { +async function createAllSubtasks(taskId: string, subtasks: Subtask[]): Promise { + if (subtasks.length === 0) return true + const { data, error } = await supabase - .from('task_steps') + .from('task_subtasks') .insert( - steps.map(step => ({ + subtasks.map(s => ({ task_id: taskId, - type: step.type, - step_order: step.order, - active: step.active, - start_date: step.start || null, - end_date: step.end || null, + title: s.title, + status: s.status, + subtask_order: s.order, + active: s.active, + start_date: s.start || null, + end_date: s.end || null, })) ) - .select('id,type') + .select('id,title,status,subtask_order') if (error || !data) { - devLog('[createAllSteps] Erro ao inserir steps:', error?.message) + devLog('[createAllSubtasks] Erro ao inserir subtasks:', error?.message) return false } - const stepIdByType = new Map(data.map(row => [row.type, row.id])) - const assigneeRows = steps.flatMap(step => { - const stepId = stepIdByType.get(step.type) - if (!stepId || step.assignees.length === 0) return [] - return step.assignees.map(memberId => ({ step_id: stepId, member_id: memberId })) + // Map inserted rows back by order (title+status combo can repeat, order is unique per task) + const idByOrder = new Map(data.map(row => [row.subtask_order, row.id])) + const assigneeRows = subtasks.flatMap(s => { + const subtaskId = idByOrder.get(s.order) + if (!subtaskId || s.assignees.length === 0) return [] + return s.assignees.map(memberId => ({ subtask_id: subtaskId, member_id: memberId })) }) if (assigneeRows.length > 0) { - const { error: assigneeErr } = await supabase.from('step_assignees').insert(assigneeRows) + const { error: assigneeErr } = await supabase.from('subtask_assignees').insert(assigneeRows) if (assigneeErr) { - devLog('[createAllSteps] Erro ao inserir assignees:', assigneeErr.message) + devLog('[createAllSubtasks] Erro ao inserir assignees:', assigneeErr.message) return false } } @@ -116,9 +120,9 @@ export function useSupabase(options: UseSupabaseOptions = {}) { return false } - const ok = await createAllSteps(taskRow.id, taskData.steps) + const ok = await createAllSubtasks(taskRow.id, taskData.subtasks) if (!ok) { - toast.error('Tarefa criada mas erro ao guardar etapas') + toast.error('Tarefa criada mas erro ao guardar subtasks') invalidateTasks() return false } @@ -186,43 +190,16 @@ export function useSupabase(options: UseSupabaseOptions = {}) { } } - const prevByType = new Map((prevTask?.steps ?? []).map(step => [step.type, step])) - const updates = taskData.steps - .map(step => { - const prevStep = prevByType.get(step.type) - const stepId = step.id || prevStep?.id || '' - - if (!stepId || !prevStep) return null - - const stepChanged = ( - prevStep.type !== step.type - || prevStep.order !== step.order - || prevStep.active !== step.active - || (prevStep.start || '') !== (step.start || '') - || (prevStep.end || '') !== (step.end || '') - ) - - if (!stepChanged) return null - - return { - id: stepId, - data: { - type: step.type, - step_order: step.order, - active: step.active, - start_date: step.start || null, - end_date: step.end || null, - }, - } - }) - .filter((item): item is { id: string; data: { type: Step['type']; step_order: number; active: boolean; start_date: string | null; end_date: string | null } } => item !== null) + const prevById = new Map((prevTask?.subtasks ?? []).map(s => [s.id, s])) + const nextIds = new Set(taskData.subtasks.filter(s => s.id).map(s => s.id)) - for (const update of updates) { + // DELETE subtasks removed by the user + const removedIds = [...prevById.keys()].filter(id => !nextIds.has(id)) + if (removedIds.length > 0) { const { error } = await supabase - .from('task_steps') - .update(update.data) - .eq('id', update.id) - + .from('task_subtasks') + .delete() + .in('id', removedIds) if (error) { rollback() toast.error(toSafeUiErrorMessage(error.message)) @@ -230,33 +207,73 @@ export function useSupabase(options: UseSupabaseOptions = {}) { } } - const assigneesToAdd: Array<{ step_id: string; member_id: string }> = [] - const assigneesToRemoveByStep = new Map() - - for (const step of taskData.steps) { - const prevStep = prevByType.get(step.type) - const stepId = step.id || prevStep?.id || '' - if (!stepId || !prevStep) continue + // INSERT new subtasks (id is empty string) + const newSubtasks = taskData.subtasks.filter(s => !s.id) + if (newSubtasks.length > 0) { + const ok = await createAllSubtasks(taskData.id, newSubtasks) + if (!ok) { + rollback() + toast.error('Erro ao guardar novas subtasks') + return false + } + } - const prevAssignees = new Set(prevStep.assignees) - const nextAssignees = new Set(step.assignees) + // UPDATE existing subtasks that changed + const assigneesToAdd: Array<{ subtask_id: string; member_id: string }> = [] + const assigneesToRemoveBySubtask = new Map() + + for (const subtask of taskData.subtasks) { + if (!subtask.id) continue + const prev = prevById.get(subtask.id) + if (!prev) continue + + const changed = ( + prev.title !== subtask.title + || prev.status !== subtask.status + || prev.order !== subtask.order + || prev.active !== subtask.active + || (prev.start || '') !== (subtask.start || '') + || (prev.end || '') !== (subtask.end || '') + ) + + if (changed) { + const { error } = await supabase + .from('task_subtasks') + .update({ + title: subtask.title, + status: subtask.status, + subtask_order: subtask.order, + active: subtask.active, + start_date: subtask.start || null, + end_date: subtask.end || null, + }) + .eq('id', subtask.id) + + if (error) { + rollback() + toast.error(toSafeUiErrorMessage(error.message)) + return false + } + } - const toAdd = step.assignees.filter(id => !prevAssignees.has(id)) - const toRemove = prevStep.assignees.filter(id => !nextAssignees.has(id)) + const prevAssignees = new Set(prev.assignees) + const nextAssignees = new Set(subtask.assignees) + const toAdd = subtask.assignees.filter(id => !prevAssignees.has(id)) + const toRemove = prev.assignees.filter(id => !nextAssignees.has(id)) if (toAdd.length > 0) { - assigneesToAdd.push(...toAdd.map(id => ({ step_id: stepId, member_id: id }))) + assigneesToAdd.push(...toAdd.map(id => ({ subtask_id: subtask.id, member_id: id }))) } if (toRemove.length > 0) { - assigneesToRemoveByStep.set(stepId, toRemove) + assigneesToRemoveBySubtask.set(subtask.id, toRemove) } } - for (const [stepId, memberIds] of assigneesToRemoveByStep.entries()) { + for (const [subtaskId, memberIds] of assigneesToRemoveBySubtask.entries()) { const { error } = await supabase - .from('step_assignees') + .from('subtask_assignees') .delete() - .eq('step_id', stepId) + .eq('subtask_id', subtaskId) .in('member_id', memberIds) if (error) { @@ -268,7 +285,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { if (assigneesToAdd.length > 0) { const { error } = await supabase - .from('step_assignees') + .from('subtask_assignees') .insert(assigneesToAdd) if (error) { diff --git a/src/hooks/ui/usePhaseDrag.ts b/src/hooks/ui/usePhaseDrag.ts index 23dac7d..3073eaa 100644 --- a/src/hooks/ui/usePhaseDrag.ts +++ b/src/hooks/ui/usePhaseDrag.ts @@ -1,11 +1,11 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { toLocalDate, toDateStr, addDays, normaliseTask, - type DragState, type DragPreview, type Task, type Step, type StepType, + type DragState, type DragPreview, type Task, type Subtask, } from '@/utils/dashboardUtils'; import { isWeekendOrHoliday, nextNonHolidayBusinessDay, type Holiday } from '@/utils/holidayUtils'; -type PendingDragUpdate = { task: Task; stepType: StepType }; +type PendingDragUpdate = { task: Task; subtaskId: string }; export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, holidays: Holiday[] = []) { const dragStateRef = useRef(null); @@ -20,7 +20,7 @@ export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, if (!ds) return; const delta = Math.round((e.clientX - ds.startX) / ds.colWidth); if (delta !== 0) didDragRef.current = true; - setDragPreview({ taskId: ds.taskId, stepType: ds.stepType, deltaDays: delta, type: ds.type }); + setDragPreview({ taskId: ds.taskId, subtaskId: ds.subtaskId, deltaDays: delta, type: ds.type }); }; const onUp = (e: MouseEvent) => { @@ -46,8 +46,8 @@ export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, const norm = normaliseTask(task); const updatedTask: Task = { ...norm, - steps: norm.steps.map((s: Step) => - s.type === ds.stepType + subtasks: norm.subtasks.map((s: Subtask) => + s.id === ds.subtaskId ? { ...s, start: toDateStr(newStart), end: toDateStr(newEnd) } : s ), @@ -55,12 +55,12 @@ export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, const startStr = toDateStr(newStart); const endStr = toDateStr(newEnd); - if (isWeekendOrHoliday(startStr, holidays) || isWeekendOrHoliday(endStr, holidays)) { - originalTaskRef.current = { start: ds.originalStart, end: ds.originalEnd }; - setPendingDragUpdate({ task: updatedTask, stepType: ds.stepType }); - } else { - onUpdateTask(updatedTask); - } + if (isWeekendOrHoliday(startStr, holidays) || isWeekendOrHoliday(endStr, holidays)) { + originalTaskRef.current = { start: ds.originalStart, end: ds.originalEnd }; + setPendingDragUpdate({ task: updatedTask, subtaskId: ds.subtaskId }); + } else { + onUpdateTask(updatedTask); + } } } dragStateRef.current = null; @@ -79,17 +79,17 @@ export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, const startDrag = useCallback(( e: React.MouseEvent, taskId: string, - stepType: StepType, + subtaskId: string, type: DragState['type'], - step: Step, + subtask: Subtask, colWidth: number, ) => { e.preventDefault(); e.stopPropagation(); dragStateRef.current = { - type, taskId, stepType, - originalStart: toLocalDate(step.start), - originalEnd: toLocalDate(step.end), + type, taskId, subtaskId, + originalStart: toLocalDate(subtask.start), + originalEnd: toLocalDate(subtask.end), startX: e.clientX, colWidth, }; @@ -113,21 +113,19 @@ export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, const adjustedTask: Task = { ...pendingDragUpdate.task, - steps: pendingDragUpdate.task.steps.map((step: Step) => { - if (step.type !== pendingDragUpdate.stepType) return step; - - const adjustedStart = isWeekendOrHoliday(step.start, holidays) - ? nextNonHolidayBusinessDay(step.start, holidays) - : step.start; - let adjustedEnd = isWeekendOrHoliday(step.end, holidays) - ? nextNonHolidayBusinessDay(step.end, holidays) - : step.end; - - if (adjustedEnd < adjustedStart) { - adjustedEnd = adjustedStart; - } + subtasks: pendingDragUpdate.task.subtasks.map((s: Subtask) => { + if (s.id !== pendingDragUpdate.subtaskId) return s; + + const adjustedStart = isWeekendOrHoliday(s.start, holidays) + ? nextNonHolidayBusinessDay(s.start, holidays) + : s.start; + let adjustedEnd = isWeekendOrHoliday(s.end, holidays) + ? nextNonHolidayBusinessDay(s.end, holidays) + : s.end; + + if (adjustedEnd < adjustedStart) adjustedEnd = adjustedStart; - return { ...step, start: adjustedStart, end: adjustedEnd }; + return { ...s, start: adjustedStart, end: adjustedEnd }; }), }; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e92d252..45abc84 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,5 +1,5 @@ import { supabase } from '@/lib/supabase' -import type { Task, Step, StepType } from '@/lib/steps' +import type { Task, Subtask, SubtaskStatus } from '@/lib/steps' import type { Member } from '@/hooks/infra/useSupabase' import type { DbTaskRow } from '@/types/db' import { DbTaskRowSchema } from '@/lib/validators' @@ -17,16 +17,17 @@ export const queryKeys = { function dbRowToTask(row: DbTaskRow): Task { const parsed = DbTaskRowSchema.parse(row) - const steps: Step[] = parsed.task_steps - .sort((a, b) => a.step_order - b.step_order) + const subtasks: Subtask[] = parsed.task_subtasks + .sort((a, b) => a.subtask_order - b.subtask_order) .map(s => ({ id: s.id, - type: s.type as StepType, - order: s.step_order, + title: s.title, + status: s.status as SubtaskStatus, + order: s.subtask_order, active: s.active, start: s.start_date ?? '', end: s.end_date ?? '', - assignees: s.step_assignees.map(a => a.member_id), + assignees: s.subtask_assignees.map(a => a.member_id), })) return { @@ -41,15 +42,15 @@ function dbRowToTask(row: DbTaskRow): Task { createdAt: parsed.created_at, concludedAt: parsed.concluded_at ?? undefined, concludedBy: parsed.concluded_by ?? undefined, - steps, + subtasks, } } const TASK_SELECT = ` id, title, clickup_link, blocked, blocked_at, created_at, client_id, concluded_at, concluded_by, - task_steps ( - id, type, step_order, active, start_date, end_date, - step_assignees ( member_id ) + task_subtasks ( + id, title, status, subtask_order, active, start_date, end_date, + subtask_assignees ( member_id ) ) ` as const diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 9312601..0fde917 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -9,18 +9,26 @@ export const STEP_TYPES_ORDER = [ 'publicacao', ] as const; -export type StepType = typeof STEP_TYPES_ORDER[number]; +/** Status possíveis de uma subtask (ex-StepType) */ +export type SubtaskStatus = typeof STEP_TYPES_ORDER[number]; -export interface Step { +/** @deprecated use SubtaskStatus */ +export type StepType = SubtaskStatus; + +export interface Subtask { id: string; - type: StepType; - start: string; // YYYY-MM-DD, empty if not set - end: string; // YYYY-MM-DD, empty if not set + title: string; // nome livre obrigatório + status: SubtaskStatus; // ex-type + start: string; // YYYY-MM-DD, empty if not set + end: string; // YYYY-MM-DD, empty if not set assignees: string[]; active: boolean; order: number; } +/** @deprecated use Subtask */ +export type Step = Subtask & { type: SubtaskStatus }; + export interface TaskStatus { blocked: boolean; blockedAt?: string; // YYYY-MM-DD — the date when blocked was set @@ -32,13 +40,13 @@ export interface Task { clickupLink?: string; clientId?: string; status: TaskStatus; - steps: Step[]; + subtasks: Subtask[]; createdAt: string; concludedAt?: string; concludedBy?: string; } -export const STEP_META: Record s.active && s.start && s.end); +/** Returns the subtask that should be highlighted as "current" based on today's date */ +export function getCurrentSubtask(subtasks: Subtask[], today: string): Subtask | null { + const active = subtasks.filter(s => s.active && s.start && s.end); if (active.length === 0) return null; - // Step whose range includes today const current = active.find(s => s.start <= today && s.end >= today); if (current) return current; - // Next upcoming step const upcoming = active.filter(s => s.start > today).sort((a, b) => a.start.localeCompare(b.start)); if (upcoming.length > 0) return upcoming[0]; - // All steps in the past — return the last one return active.sort((a, b) => b.end.localeCompare(a.end))[0]; } +/** @deprecated use getCurrentSubtask */ +export function getCurrentStep(subtasks: Subtask[], today: string): Subtask | null { + return getCurrentSubtask(subtasks, today); +} + /** Returns label like "Em Design · Bloqueado" or "Análise UX · Em andamento" */ -export function getStatusLabel(task: { status: TaskStatus; steps: Step[] }, today: string): string { - const step = getCurrentStep(task.steps, today); - const stepLabel = step ? (STEP_META[step.type]?.label ?? step.type) : 'Sem steps'; +export function getStatusLabel(task: { status: TaskStatus; subtasks: Subtask[] }, today: string): string { + const subtask = getCurrentSubtask(task.subtasks, today); + const stepLabel = subtask ? (STEP_META[subtask.status]?.label ?? subtask.status) : 'Sem subtasks'; const stateLabel = task.status.blocked ? 'Bloqueado' : 'Em andamento'; return `${stepLabel} · ${stateLabel}`; } -/** Returns true if a given step bar should be shown as blocked (red) */ +/** Returns true if a given subtask bar should be shown as blocked (red) */ export function isStepBlocked(task: { status: TaskStatus }, stepStart: string): boolean { if (!task.status.blocked || !task.status.blockedAt) return false; return stepStart >= task.status.blockedAt; } -/** Creates default steps array for a new task */ -export function createDefaultSteps(): Step[] { - return STEP_TYPES_ORDER.map((type, order) => ({ - id: '', - type, - start: '', - end: '', - assignees: [], - active: false, - order, - })); -} - /** * Formato legado de tarefa (localStorage / Google Drive antigo). - * Pode conter `steps` já migrados ou apenas `phases` + `assignee`. + * Pode conter `steps`/`subtasks` já migrados ou apenas `phases` + `assignee`. */ export interface LegacyTask { id?: string; title?: string; clickupLink?: string; - /** Assignee global, usado quando não há `phaseAssignees`. */ assignee?: string; - /** Status como string ('bloqueado', 'em andamento', …) ou objeto moderno. */ status?: string | TaskStatus; createdAt?: string; phases?: { @@ -194,64 +189,66 @@ export interface LegacyTask { qa?: { start: string; end: string }; }; phaseAssignees?: Record; - /** Presença de `steps` indica tarefa já no formato moderno. */ - steps?: Step[]; + /** Suporte a ambos os formatos durante a transição */ + steps?: Array<{ type?: string; status?: string; title?: string; id?: string; start?: string; end?: string; assignees?: string[]; active?: boolean; order?: number }>; + subtasks?: Subtask[]; } -/** Migrates a legacy task (with phases/assignee) to the new steps format */ -export function migrateLegacyTask(task: LegacyTask): { status: TaskStatus; steps: Step[] } { - if (task.steps) { +/** Migrates a legacy task to the subtasks format */ +export function migrateLegacyTask(task: LegacyTask): { status: TaskStatus; subtasks: Subtask[] } { + if (task.subtasks) { const existingStatus = task.status; const status: TaskStatus = existingStatus && typeof existingStatus === 'object' ? existingStatus : { blocked: false }; + return { status, subtasks: task.subtasks }; + } - const stepByType = new Map(task.steps.map(step => [step.type, step])); - const steps = STEP_TYPES_ORDER.map((type, order) => { - const existing = stepByType.get(type); - if (existing) { - return { - ...existing, - order, - }; - } + if (task.steps) { + const existingStatus = task.status; + const status: TaskStatus = + existingStatus && typeof existingStatus === 'object' + ? existingStatus + : { blocked: false }; - return { - id: '', - type, - start: '', - end: '', - assignees: [], - active: false, - order, - }; - }); + const subtasks: Subtask[] = task.steps.map((s, i) => ({ + id: s.id ?? '', + title: s.title ?? s.type ?? s.status ?? '', + status: (s.status ?? s.type ?? 'design') as SubtaskStatus, + start: s.start ?? '', + end: s.end ?? '', + assignees: s.assignees ?? [], + active: s.active ?? false, + order: s.order ?? i, + })); - return { status, steps }; + return { status, subtasks }; } - const legacyMap: Partial> = { + const legacyMap: Partial> = { design: 'design', approval: 'aprovacao-design', dev: 'desenvolvimento', qa: 'qa', }; - const steps: Step[] = STEP_TYPES_ORDER.map((type, order) => { - const legacyKey = Object.entries(legacyMap).find(([, v]) => v === type)?.[0]; - const legacyPhase = legacyKey ? task.phases?.[legacyKey as keyof NonNullable] : null; - const legacyAssignee = legacyKey ? (task.phaseAssignees?.[legacyKey] || task.assignee) : null; - return { + const subtasks: Subtask[] = Object.entries(legacyMap).reduce((acc, [legacyKey, statusVal], i) => { + const legacyPhase = task.phases?.[legacyKey as keyof NonNullable]; + if (!legacyPhase) return acc; + const legacyAssignee = task.phaseAssignees?.[legacyKey] || task.assignee; + acc.push({ id: '', - type, - start: legacyPhase?.start || '', - end: legacyPhase?.end || '', + title: statusVal, + status: statusVal, + start: legacyPhase.start, + end: legacyPhase.end, assignees: legacyAssignee ? [legacyAssignee] : [], - active: !!legacyPhase, - order, - }; - }); + active: true, + order: i, + }); + return acc; + }, []); const oldStatus = typeof task.status === 'string' ? task.status : undefined; const blocked = oldStatus === 'bloqueado'; @@ -260,5 +257,5 @@ export function migrateLegacyTask(task: LegacyTask): { status: TaskStatus; steps blockedAt: blocked ? (task.createdAt?.split('T')[0] ?? undefined) : undefined, }; - return { status, steps }; + return { status, subtasks }; } diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 8af8ad7..958542e 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -2,18 +2,19 @@ import { z } from 'zod' // ─── Tasks ──────────────────────────────────────────────────────────────────── -export const DbStepAssigneeSchema = z.object({ +export const DbSubtaskAssigneeSchema = z.object({ member_id: z.string(), }) -export const DbStepRowSchema = z.object({ +export const DbSubtaskRowSchema = z.object({ id: z.string().min(1), - type: z.string().min(1), - step_order: z.number().int().nonnegative(), + title: z.string(), + status: z.string().min(1), + subtask_order: z.number().int().nonnegative(), active: z.boolean(), start_date: z.string().nullable(), end_date: z.string().nullable(), - step_assignees: z.array(DbStepAssigneeSchema).default([]), + subtask_assignees: z.array(DbSubtaskAssigneeSchema).default([]), }) export const DbTaskRowSchema = z.object({ @@ -26,7 +27,7 @@ export const DbTaskRowSchema = z.object({ concluded_at: z.string().nullable(), concluded_by: z.string().nullable(), client_id: z.string().nullable(), - task_steps: z.array(DbStepRowSchema).default([]), + task_subtasks: z.array(DbSubtaskRowSchema).default([]), }) export type ValidatedDbTaskRow = z.infer diff --git a/src/types/db.ts b/src/types/db.ts index 8e539fe..1cd7b42 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -8,10 +8,12 @@ // ─── Supabase rows ──────────────────────────────────────────────────────────── +/** @deprecated use DbSubtaskAssigneeRow */ export interface DbStepAssigneeRow { member_id: string; } +/** @deprecated use DbSubtaskRow */ export interface DbStepRow { id: string; type: string; @@ -22,7 +24,8 @@ export interface DbStepRow { step_assignees: DbStepAssigneeRow[]; } -export interface DbTaskRow { +/** @deprecated use DbTaskRow (com task_subtasks) */ +export interface DbTaskRowLegacy { id: string; title: string; clickup_link: string | null; @@ -35,6 +38,34 @@ export interface DbTaskRow { task_steps: DbStepRow[]; } +export interface DbSubtaskAssigneeRow { + member_id: string; +} + +export interface DbSubtaskRow { + id: string; + title: string; + status: string; + subtask_order: number; + active: boolean; + start_date: string | null; + end_date: string | null; + subtask_assignees: DbSubtaskAssigneeRow[]; +} + +export interface DbTaskRow { + id: string; + title: string; + clickup_link: string | null; + blocked: boolean; + blocked_at: string | null; + created_at: string; + concluded_at: string | null; + concluded_by: string | null; + client_id: string | null; + task_subtasks: DbSubtaskRow[]; +} + // ─── RBAC / Multi-tenant rows ───────────────────────────────────────────────── export interface DbClientRow { diff --git a/src/utils/dashboardUtils.ts b/src/utils/dashboardUtils.ts index 0efeb67..b3aebc8 100644 --- a/src/utils/dashboardUtils.ts +++ b/src/utils/dashboardUtils.ts @@ -2,11 +2,11 @@ import { formatDate, formatDateDisplay } from '../utils/dateUtils'; import { STEP_META, migrateLegacyTask, - getCurrentStep, + getCurrentSubtask, isStepBlocked, type Task, - type Step, - type StepType, + type Subtask, + type SubtaskStatus, type LegacyTask, } from '../lib/steps'; @@ -65,39 +65,44 @@ export function getMonthWeeks(year: number, month: number): Date[][] { return weeks; } -/** Garante que a tarefa tem o formato moderno com `steps`. */ +/** Garante que a tarefa tem o formato moderno com `subtasks`. */ export function normaliseTask(task: Task | LegacyTask): Task { - if ((task as Task).steps && typeof (task as Task).status === 'object') { + if ((task as Task).subtasks && typeof (task as Task).status === 'object') { return task as Task; } const migrated = migrateLegacyTask(task as LegacyTask); return { ...(task as object), ...migrated } as Task; } -/** Devolve os steps ativos (com datas preenchidas) de uma tarefa. */ -export function getVisibleSteps(task: Task | LegacyTask): Step[] { +/** Devolve as subtasks ativas (com datas preenchidas) de uma tarefa. */ +export function getVisibleSubtasks(task: Task | LegacyTask): Subtask[] { const norm = normaliseTask(task); - return norm.steps.filter(s => s.active && s.start && s.end); + return norm.subtasks.filter(s => s.active && s.start && s.end); +} + +/** @deprecated use getVisibleSubtasks */ +export function getVisibleSteps(task: Task | LegacyTask): Subtask[] { + return getVisibleSubtasks(task); } export function getTaskStatusDisplay(task: Task | LegacyTask): { label: string; cls: string } { const norm = normaliseTask(task); const today = todayStr(); - const step = getCurrentStep(norm.steps, today); - const stepLabel = step ? (STEP_META[step.type]?.label ?? step.type) : 'Sem steps'; + const subtask = getCurrentSubtask(norm.subtasks, today); + const stepLabel = subtask ? (STEP_META[subtask.status]?.label ?? subtask.status) : 'Sem subtasks'; if (norm.status?.blocked) { return { label: `${stepLabel} · Bloqueado`, cls: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100', }; } - if (!step) { - return { label: 'Sem steps', cls: 'bg-muted text-muted-foreground' }; + if (!subtask) { + return { label: 'Sem subtasks', cls: 'bg-muted text-muted-foreground' }; } - if (step.end < today) { + if (subtask.end < today) { return { label: `${stepLabel} · Concluído`, cls: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100' }; } - if (step.start > today) { + if (subtask.start > today) { return { label: `${stepLabel} · Backlog`, cls: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200' }; } return { label: `${stepLabel} · Em andamento`, cls: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100' }; @@ -110,7 +115,9 @@ export type CalendarViewMode = 'step' | 'demand'; export interface BarItem { taskId: string; taskTitle: string; - stepType: StepType; + subtaskId: string; + subtaskTitle: string; + subtaskStatus: SubtaskStatus; stepStart: string; stepEnd: string; startCol: number; @@ -126,65 +133,62 @@ export function layoutWeekBars(weekDays: Date[], tasks: (Task | LegacyTask)[], v const raw: Raw[] = []; for (const task of tasks) { - const steps = getVisibleSteps(task); - + const subtasks = getVisibleSubtasks(task); + if (viewMode === 'demand') { - const sortedSteps = steps.filter(s => s.start && s.end).sort((a, b) => toLocalDate(a.start!).getTime() - toLocalDate(b.start!).getTime()); - - let currentGroup: typeof steps[0] | null = null; + const sorted = subtasks.filter(s => s.start && s.end).sort((a, b) => toLocalDate(a.start).getTime() - toLocalDate(b.start).getTime()); + + let groupSubtask: Subtask | null = null; let groupStart = ''; let groupEnd = ''; - - for (const step of sortedSteps) { - const stepStart = step.start!; - const stepEnd = step.end!; - - if (!currentGroup) { - currentGroup = step; - groupStart = stepStart; - groupEnd = stepEnd; + + for (const s of sorted) { + if (!groupSubtask) { + groupSubtask = s; + groupStart = s.start; + groupEnd = s.end; continue; } - + const prevEnd = toLocalDate(groupEnd); - const currStart = toLocalDate(stepStart); + const currStart = toLocalDate(s.start); const gapDays = Math.round((currStart.getTime() - prevEnd.getTime()) / 86400000); - + if (gapDays > 2) { - if (groupStart && groupEnd) { + if (groupSubtask && groupStart && groupEnd) { const pStart = toLocalDate(groupStart); const pEnd = toLocalDate(groupEnd); if (pEnd >= weekStart && pStart <= weekEnd) { const startCol = Math.max(0, Math.round((pStart.getTime() - weekStart.getTime()) / 86400000)); const endCol = Math.min(6, Math.round((pEnd.getTime() - weekStart.getTime()) / 86400000)); - raw.push({ taskId: task.id ?? '', taskTitle: task.title ?? '', stepType: 'desenvolvimento', stepStart: groupStart, stepEnd: groupEnd, startCol, endCol }); + raw.push({ taskId: task.id ?? '', taskTitle: task.title ?? '', subtaskId: groupSubtask.id, subtaskTitle: groupSubtask.title, subtaskStatus: groupSubtask.status, stepStart: groupStart, stepEnd: groupEnd, startCol, endCol }); } } - currentGroup = step; - groupStart = stepStart; - groupEnd = stepEnd; + groupSubtask = s; + groupStart = s.start; + groupEnd = s.end; } else { - if (stepEnd > groupEnd) groupEnd = stepEnd; + if (s.end > groupEnd) groupEnd = s.end; } } - - if (currentGroup && groupStart && groupEnd) { + + if (groupSubtask && groupStart && groupEnd) { const pStart = toLocalDate(groupStart); const pEnd = toLocalDate(groupEnd); if (pEnd >= weekStart && pStart <= weekEnd) { const startCol = Math.max(0, Math.round((pStart.getTime() - weekStart.getTime()) / 86400000)); const endCol = Math.min(6, Math.round((pEnd.getTime() - weekStart.getTime()) / 86400000)); - raw.push({ taskId: task.id ?? '', taskTitle: task.title ?? '', stepType: 'desenvolvimento', stepStart: groupStart, stepEnd: groupEnd, startCol, endCol }); + raw.push({ taskId: task.id ?? '', taskTitle: task.title ?? '', subtaskId: groupSubtask.id, subtaskTitle: groupSubtask.title, subtaskStatus: groupSubtask.status, stepStart: groupStart, stepEnd: groupEnd, startCol, endCol }); } } } else { - for (const step of steps) { - const pStart = toLocalDate(step.start); - const pEnd = toLocalDate(step.end); + for (const s of subtasks) { + const pStart = toLocalDate(s.start); + const pEnd = toLocalDate(s.end); if (pEnd < weekStart || pStart > weekEnd) continue; const startCol = Math.max(0, Math.round((pStart.getTime() - weekStart.getTime()) / 86400000)); const endCol = Math.min(6, Math.round((pEnd.getTime() - weekStart.getTime()) / 86400000)); - raw.push({ taskId: task.id ?? '', taskTitle: task.title ?? '', stepType: step.type, stepStart: step.start, stepEnd: step.end, startCol, endCol }); + raw.push({ taskId: task.id ?? '', taskTitle: task.title ?? '', subtaskId: s.id, subtaskTitle: s.title, subtaskStatus: s.status, stepStart: s.start, stepEnd: s.end, startCol, endCol }); } } } @@ -207,7 +211,7 @@ export function layoutWeekBars(weekDays: Date[], tasks: (Task | LegacyTask)[], v export interface DragState { type: 'move' | 'resize-start' | 'resize-end'; taskId: string; - stepType: StepType; + subtaskId: string; originalStart: Date; originalEnd: Date; startX: number; @@ -216,10 +220,10 @@ export interface DragState { export interface DragPreview { taskId: string; - stepType: StepType; + subtaskId: string; deltaDays: number; type: DragState['type']; } // Re-export used downstream -export { formatDate, formatDateDisplay, STEP_META, isStepBlocked, type Task, type Step, type StepType, type LegacyTask }; +export { formatDate, formatDateDisplay, STEP_META, isStepBlocked, type Task, type Subtask, type SubtaskStatus, type LegacyTask }; diff --git a/src/views/calendar/components/StepBar.tsx b/src/views/calendar/components/StepBar.tsx index 6393bbc..bea829d 100644 --- a/src/views/calendar/components/StepBar.tsx +++ b/src/views/calendar/components/StepBar.tsx @@ -30,7 +30,7 @@ const STEP_BORDER_COLORS: Record = { }; const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfStep, isLastBarOfStep, dragPreview, onStartDrag, onClick, viewMode = 'step', demandColor }) => { - const meta = STEP_META[bar.stepType]; + const meta = STEP_META[bar.subtaskStatus]; const colW = 100 / 7; const isDemandMode = viewMode === 'demand'; @@ -44,7 +44,7 @@ const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfSte let adjStart = bar.startCol; let adjEnd = bar.endCol; - if (dragPreview && dragPreview.taskId === bar.taskId && dragPreview.stepType === bar.stepType) { + if (dragPreview && dragPreview.taskId === bar.taskId && dragPreview.subtaskId === bar.subtaskId) { const d = dragPreview.deltaDays; if (dragPreview.type === 'move') { adjStart = bar.startCol + d; adjEnd = bar.endCol + d; } else if (dragPreview.type === 'resize-start') { adjStart = Math.min(bar.startCol + d, bar.endCol); } @@ -56,14 +56,14 @@ const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfSte const left = clampedStart * colW; const width = (clampedEnd - clampedStart + 1) * colW; const top = `calc(var(--cal-day-header-h) + ${bar.slot} * var(--cal-slot-height) + 3px)`; - const isDragging = dragPreview?.taskId === bar.taskId && dragPreview?.stepType === bar.stepType; + const isDragging = dragPreview?.taskId === bar.taskId && dragPreview?.subtaskId === bar.subtaskId; const norm = normaliseTask(task); const blocked = isStepBlocked(norm, bar.stepStart); const isConcluded = !!task.concludedAt; - + let bgClass: string; let borderColor: string; - + if (isConcluded) { bgClass = 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-300'; borderColor = '#9ca3af'; @@ -72,10 +72,10 @@ const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfSte borderColor = demandColor; } else { bgClass = blocked ? 'bg-red-500 dark:bg-red-600 text-white' : meta.bar.replace(/border\s+\S+/g, ''); - borderColor = blocked ? '#dc2626' : (STEP_BORDER_COLORS[bar.stepType] ?? '#d1d5db'); + borderColor = blocked ? '#dc2626' : (STEP_BORDER_COLORS[bar.subtaskStatus] ?? '#d1d5db'); } - const stepKey = `${bar.taskId}-${bar.stepType}`; + const stepKey = `${bar.taskId}-${bar.subtaskId}`; let borderRadius = '0px'; if (isFirstBarOfStep && isLastBarOfStep) { @@ -110,7 +110,7 @@ const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfSte cursor: cursorStyle, transition: isDragging ? 'none' : 'filter 0.1s', }} - title={`${task.title}${isDemandMode ? '' : ` · ${meta.label}`}${blocked ? ' · BLOQUEADO' : ''}${isConcluded ? ' · CONCLUÍDA' : ''}`} + title={`${task.title}${isDemandMode ? '' : ` · ${bar.subtaskTitle || meta.label}`}${blocked ? ' · BLOQUEADO' : ''}${isConcluded ? ' · CONCLUÍDA' : ''}`} onMouseDown={e => !isDemandMode && onStartDrag(e, bar, 'move', task)} onMouseEnter={(e) => { document.querySelectorAll(`[data-step-key="${stepKey}"]`).forEach(el => { @@ -137,11 +137,11 @@ const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfSte ? : {meta.tag} } - {task.title} + {bar.subtaskTitle || task.title} )} {isDemandMode && demandColor && ( - @@ -159,7 +159,8 @@ const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfSte ); }, (prev, next) => prev.bar.taskId === next.bar.taskId && - prev.bar.stepType === next.bar.stepType && + prev.bar.subtaskId === next.bar.subtaskId && + prev.bar.subtaskStatus === next.bar.subtaskStatus && prev.bar.startCol === next.bar.startCol && prev.bar.endCol === next.bar.endCol && prev.bar.slot === next.bar.slot && diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index b68a5b4..9c0a199 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -19,7 +19,7 @@ import { useUIStore } from '@/store/useUIStore'; import { ViewState } from '@/components/ViewState'; import { CalendarX2, DatabaseZap, FilterX, Search, Plus } from 'lucide-react'; import { Skeleton } from 'boneyard-js/react'; -import { STEP_TYPES_ORDER, type StepType, type Task } from '@/lib/steps'; +import { STEP_TYPES_ORDER, type SubtaskStatus, type Task } from '@/lib/steps'; import { Button } from '@/components/ui/Button'; const PLANNING_BONES = { @@ -108,10 +108,10 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd task.title.toLowerCase().includes(searchTerm.toLowerCase()) || task.id.toLowerCase().includes(searchTerm.toLowerCase()); - const currentStep = task.steps.find(s => s.active) ?? task.steps[0]; - const matchStep = selectedSteps.length > 0 ? selectedSteps.includes(currentStep?.type) : true; + const currentStep = task.subtasks.find(s => s.active) ?? task.subtasks[0]; + const matchStep = selectedSteps.length > 0 ? selectedSteps.includes(currentStep?.status) : true; const matchMember = selectedMemberIds.length > 0 - ? task.steps.some(s => s.assignees.some(a => selectedMemberIds.includes(a))) + ? task.subtasks.some(s => s.assignees.some(a => selectedMemberIds.includes(a))) : true; const matchBlocked = showOnlyBlocked ? task.status.blocked : true; @@ -131,29 +131,32 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd const groupedDemandasTasks = useMemo(() => { const { selectedMemberIds } = demandasFilters; - const groups = new Map(); - STEP_TYPES_ORDER.forEach(step => groups.set(step, [])); + const groups = new Map(); + STEP_TYPES_ORDER.forEach(s => groups.set(s, [])); filteredDemandasTasks.forEach(task => { - let stepsToUse = task.steps.filter(s => s.active); - if (stepsToUse.length === 0) stepsToUse = task.steps.slice(0, 1); + let subtasksToUse = task.subtasks.filter(s => s.active); + if (subtasksToUse.length === 0) subtasksToUse = task.subtasks.slice(0, 1); if (selectedMemberIds.length > 0) { - const memberSteps = task.steps.filter(s => + const memberSubtasks = task.subtasks.filter(s => s.assignees.some(a => selectedMemberIds.includes(a)) ); - if (memberSteps.length > 0) stepsToUse = memberSteps; + if (memberSubtasks.length > 0) subtasksToUse = memberSubtasks; } - stepsToUse.forEach(step => { - const bucket = groups.get(step.type); + const seenStatuses = new Set(); + subtasksToUse.forEach(subtask => { + if (seenStatuses.has(subtask.status)) return; + seenStatuses.add(subtask.status); + const bucket = groups.get(subtask.status); if (bucket) bucket.push(task); }); }); - groups.forEach((bucket, stepType) => { + groups.forEach((bucket, status) => { bucket.sort((a, b) => { - const endA = a.steps.find(s => s.type === stepType)?.end; - const endB = b.steps.find(s => s.type === stepType)?.end; + const endA = a.subtasks.find(s => s.status === status && s.active)?.end; + const endB = b.subtasks.find(s => s.status === status && s.active)?.end; if (!endA && !endB) return 0; if (!endA) return 1; if (!endB) return -1; @@ -279,7 +282,7 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd
) : (
- {([...groupedDemandasTasks.entries()] as [StepType, Task[]][]) + {([...groupedDemandasTasks.entries()] as [SubtaskStatus, Task[]][]) .filter(([stepType]) => demandasFilters.selectedSteps.length === 0 || demandasFilters.selectedSteps.includes(stepType) ) diff --git a/src/views/planning/components/StepGroup.tsx b/src/views/planning/components/StepGroup.tsx index 33262c0..2bbba61 100644 --- a/src/views/planning/components/StepGroup.tsx +++ b/src/views/planning/components/StepGroup.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { FixedSizeList as List, type ListChildComponentProps } from 'react-window'; import { ChevronDown, ChevronRight } from 'lucide-react'; -import { STEP_META, type StepType, type Task } from '@/lib/steps'; +import { STEP_META, type SubtaskStatus, type Task } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; import { TaskRow } from './TaskRow'; @@ -9,7 +9,7 @@ const TASK_ROW_HEIGHT = 52; const VIRTUALIZE_THRESHOLD = 50; interface StepGroupProps { - stepType: StepType; + stepType: SubtaskStatus; tasks: Task[]; members: Member[]; onToggleBlock: (task: Task) => void; @@ -69,7 +69,7 @@ export function StepGroup({ stepType, tasks, members, onToggleBlock, onConclude, > {({ index, style, data }: ListChildComponentProps<{ tasks: Task[]; - stepType: StepType; + stepType: SubtaskStatus; members: Member[]; onToggleBlock: (task: Task) => void; onConclude: (task: Task) => void; diff --git a/src/views/planning/components/TaskRow.tsx b/src/views/planning/components/TaskRow.tsx index bf0565a..d5a3a74 100644 --- a/src/views/planning/components/TaskRow.tsx +++ b/src/views/planning/components/TaskRow.tsx @@ -1,13 +1,13 @@ import { memo } from 'react'; import { Link2, AlertCircle, Clock } from 'lucide-react'; -import { STEP_META, type Task, type StepType } from '@/lib/steps'; +import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; import { formatDueDate } from '../utils'; import { ActionMenu } from './ActionMenu'; interface TaskRowProps { task: Task; - stepType: StepType; + stepType: SubtaskStatus; members: Member[]; onToggleBlock: (task: Task) => void; onConclude: (task: Task) => void; @@ -19,12 +19,12 @@ export const TaskRow = memo(function TaskRow({ task, stepType, members, onToggle const isConcluded = !!task.concludedAt; const meta = STEP_META[stepType]; - const currentStep = task.steps.find(s => s.type === stepType) ?? task.steps.find(s => s.active) ?? task.steps[0]; - const assigneeMembers = (currentStep?.assignees ?? []) + const currentSubtask = task.subtasks.find(s => s.status === stepType && s.active) ?? task.subtasks.find(s => s.active) ?? task.subtasks[0]; + const assigneeMembers = (currentSubtask?.assignees ?? []) .map(id => members.find(m => m.id === id)) .filter((m): m is Member => m !== undefined); - const timeStatus = formatDueDate(currentStep?.end); + const timeStatus = formatDueDate(currentSubtask?.end); return (
; - startDrag: (e: React.MouseEvent, taskId: string, stepType: StepType, type: DragState['type'], step: Step, colWidth: number) => void; + startDrag: (e: React.MouseEvent, taskId: string, subtaskId: string, type: DragState['type'], subtask: Subtask, colWidth: number) => void; onEdit: (t: Task) => void; } -const PhaseBar: React.FC = React.memo(({ step, task, days, dragPreview, didDragRef, startDrag, onEdit }) => { - if (!step?.start || !step?.end) return null; +const PhaseBar: React.FC = React.memo(({ subtask, task, days, dragPreview, didDragRef, startDrag, onEdit }) => { + if (!subtask?.start || !subtask?.end) return null; - const pStart = toLocalDate(step.start); - const pEnd = toLocalDate(step.end); + const pStart = toLocalDate(subtask.start); + const pEnd = toLocalDate(subtask.end); const tStart = days[0]; const tEnd = days[days.length - 1]; if (pEnd < tStart || pStart > tEnd) return null; @@ -28,7 +28,7 @@ const PhaseBar: React.FC = React.memo(({ step, task, days, dragPr let e = Math.round((pEnd.getTime() - tStart.getTime()) / 86400000); const dp = dragPreview; - if (dp && dp.taskId === task.id && dp.stepType === step.type) { + if (dp && dp.taskId === task.id && dp.subtaskId === subtask.id) { if (dp.type === 'move') { s += dp.deltaDays; e += dp.deltaDays; } else if (dp.type === 'resize-start') { s = Math.min(s + dp.deltaDays, e); } else { e = Math.max(e + dp.deltaDays, s); } @@ -40,17 +40,17 @@ const PhaseBar: React.FC = React.memo(({ step, task, days, dragPr const left = (sC / days.length) * 100; const width = ((eC - sC + 1) / days.length) * 100; - const meta = STEP_META[step.type]; + const meta = STEP_META[subtask.status]; const norm = normaliseTask(task); - const blocked = isStepBlocked(norm, step.start); - const isDragging = dp?.taskId === task.id && dp?.stepType === step.type; + const blocked = isStepBlocked(norm, subtask.start); + const isDragging = dp?.taskId === task.id && dp?.subtaskId === subtask.id; const startsHere = s >= 0; const endsHere = e < days.length; const isConcluded = !!task.concludedAt; - const barCls = isConcluded - ? 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-300 border border-gray-400 dark:border-gray-500' - : blocked - ? meta.barBlocked + const barCls = isConcluded + ? 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-300 border border-gray-400 dark:border-gray-500' + : blocked + ? meta.barBlocked : meta.bar; return ( @@ -62,37 +62,39 @@ const PhaseBar: React.FC = React.memo(({ step, task, days, dragPr cursor: isDragging ? 'grabbing' : 'grab', transition: isDragging ? 'none' : 'filter 0.1s', }} - title={`${meta.label}: ${formatDateDisplay(pStart)} → ${formatDateDisplay(pEnd)}`} - onMouseDown={e => startDrag(e, task.id, step.type, 'move', step, DAY_COL_W)} + title={`${subtask.title || meta.label}: ${formatDateDisplay(pStart)} → ${formatDateDisplay(pEnd)}`} + onMouseDown={e => startDrag(e, task.id, subtask.id, 'move', subtask, DAY_COL_W)} onClick={() => { if (!didDragRef.current) onEdit(task); }} > {startsHere && (
{ e.stopPropagation(); startDrag(e, task.id, step.type, 'resize-start', step, DAY_COL_W); }} + onMouseDown={e => { e.stopPropagation(); startDrag(e, task.id, subtask.id, 'resize-start', subtask, DAY_COL_W); }} /> )} {startsHere && ( {isConcluded && } {blocked && !isConcluded && } - {meta.label} + {subtask.title || meta.label} )} {endsHere && (
{ e.stopPropagation(); startDrag(e, task.id, step.type, 'resize-end', step, DAY_COL_W); }} + onMouseDown={e => { e.stopPropagation(); startDrag(e, task.id, subtask.id, 'resize-end', subtask, DAY_COL_W); }} /> )}
); }, (prev, next) => - prev.step.type === next.step.type && - prev.step.start === next.step.start && - prev.step.end === next.step.end && + prev.subtask.id === next.subtask.id && + prev.subtask.status === next.subtask.status && + prev.subtask.start === next.subtask.start && + prev.subtask.end === next.subtask.end && + prev.subtask.title === next.subtask.title && prev.task.id === next.task.id && prev.task.concludedAt === next.task.concludedAt && prev.task.status?.blocked === next.task.status?.blocked && diff --git a/src/views/timeline/components/StepRow.tsx b/src/views/timeline/components/StepRow.tsx index ecc764f..5e50979 100644 --- a/src/views/timeline/components/StepRow.tsx +++ b/src/views/timeline/components/StepRow.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { DAY_COL_W, type DragPreview, type DragState, type Task, type Step, type StepType } from '@/utils/dashboardUtils'; +import { DAY_COL_W, type DragPreview, type DragState, type Task, type Subtask } from '@/utils/dashboardUtils'; import type { Holiday } from '@/utils/holidayUtils'; import { getHolidayName } from '@/utils/holidayUtils'; import PhaseBar from './PhaseBar'; @@ -9,19 +9,19 @@ const PHASE_ROW_H = 28; const toDateStr = (d: Date) => d.toISOString().slice(0, 10); interface StepRowProps { - step: Step | null; + subtask: Subtask | null; days: Date[]; daysRange: number; task: Task; dragPreview: DragPreview | null; didDragRef: React.MutableRefObject; - startDrag: (e: React.MouseEvent, taskId: string, stepType: StepType, type: DragState['type'], step: Step, colWidth: number) => void; + startDrag: (e: React.MouseEvent, taskId: string, subtaskId: string, type: DragState['type'], subtask: Subtask, colWidth: number) => void; onEdit: (t: Task) => void; holidays?: Holiday[]; flex1?: boolean; } -const StepRow: React.FC = React.memo(({ step, days, daysRange, task, dragPreview, didDragRef, startDrag, onEdit, holidays = [], flex1 = false }) => ( +const StepRow: React.FC = React.memo(({ subtask, days, daysRange, task, dragPreview, didDragRef, startDrag, onEdit, holidays = [], flex1 = false }) => (
{days.map((d, i) => { @@ -31,9 +31,9 @@ const StepRow: React.FC = React.memo(({ step, days, daysRange, tas return
; })}
- {step && ( + {subtask && ( = React.memo(({ step, days, daysRange, tas prev.task.id === next.task.id && prev.task.concludedAt === next.task.concludedAt && prev.task.status?.blocked === next.task.status?.blocked && - prev.step?.type === next.step?.type && - prev.step?.start === next.step?.start && - prev.step?.end === next.step?.end && + prev.subtask?.id === next.subtask?.id && + prev.subtask?.status === next.subtask?.status && + prev.subtask?.start === next.subtask?.start && + prev.subtask?.end === next.subtask?.end && + prev.subtask?.title === next.subtask?.title && prev.days.length === next.days.length && prev.daysRange === next.daysRange && prev.dragPreview === next.dragPreview && diff --git a/supabase/migrations/20260512000000_steps_to_subtasks.sql b/supabase/migrations/20260512000000_steps_to_subtasks.sql new file mode 100644 index 0000000..6405929 --- /dev/null +++ b/supabase/migrations/20260512000000_steps_to_subtasks.sql @@ -0,0 +1,78 @@ +-- ============================================================ +-- Migration: Steps → Subtasks +-- Cria task_subtasks e subtask_assignees como substitutas de +-- task_steps e step_assignees. Dados existentes são migrados. +-- As tabelas antigas são mantidas para rollback seguro. +-- ============================================================ + +-- ── Criar task_subtasks ────────────────────────────────────── +CREATE TABLE task_subtasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + title text NOT NULL DEFAULT '', + status text NOT NULL, + subtask_order integer NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT false, + start_date date, + end_date date, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- ── Criar subtask_assignees ────────────────────────────────── +CREATE TABLE subtask_assignees ( + subtask_id uuid NOT NULL REFERENCES task_subtasks(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE, + PRIMARY KEY (subtask_id, member_id) +); + +-- ── Migrar dados de task_steps ─────────────────────────────── +-- O campo "type" (ex: "design") vira tanto title quanto status. +-- O usuário poderá renomear o title livremente depois. +INSERT INTO task_subtasks (id, task_id, title, status, subtask_order, active, start_date, end_date) +SELECT id, task_id, type, type, step_order, active, start_date, end_date +FROM task_steps; + +-- ── Migrar assignees ───────────────────────────────────────── +INSERT INTO subtask_assignees (subtask_id, member_id) +SELECT step_id, member_id +FROM step_assignees; + +-- ── RLS ────────────────────────────────────────────────────── +ALTER TABLE task_subtasks ENABLE ROW LEVEL SECURITY; +ALTER TABLE subtask_assignees ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Authenticated users can read task_subtasks" + ON task_subtasks FOR SELECT + TO authenticated + USING (true); + +CREATE POLICY "Authenticated users can insert task_subtasks" + ON task_subtasks FOR INSERT + TO authenticated + WITH CHECK (true); + +CREATE POLICY "Authenticated users can update task_subtasks" + ON task_subtasks FOR UPDATE + TO authenticated + USING (true) + WITH CHECK (true); + +CREATE POLICY "Authenticated users can delete task_subtasks" + ON task_subtasks FOR DELETE + TO authenticated + USING (true); + +CREATE POLICY "Authenticated users can read subtask_assignees" + ON subtask_assignees FOR SELECT + TO authenticated + USING (true); + +CREATE POLICY "Authenticated users can insert subtask_assignees" + ON subtask_assignees FOR INSERT + TO authenticated + WITH CHECK (true); + +CREATE POLICY "Authenticated users can delete subtask_assignees" + ON subtask_assignees FOR DELETE + TO authenticated + USING (true); From e6f5675d3f659c6536c92d4fb5cadb7f3d5e6edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 12 May 2026 13:30:12 -0300 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20:sparkles:=20melhorado=20disposi?= =?UTF-8?q?=C3=A7ao=20de=20tasks=20e=20subtaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/views/planning.md | 56 +-- docs/views/reports.md | 2 +- .../MembersView/components/MemberCard.tsx | 6 +- .../MembersView/components/MemberTaskItem.tsx | 8 +- src/views/calendar/hooks/useCalendarDrag.ts | 2 +- src/views/list/components/DemandRow.tsx | 2 +- src/views/list/hooks/useListFilters.ts | 4 +- src/views/planning/PlanningView.tsx | 93 ++--- src/views/planning/components/TaskList.tsx | 179 ++++++++++ src/views/planning/components/TaskTable.tsx | 326 ++++++++++++++++++ src/views/planning/hooks/useTaskFilters.ts | 16 +- src/views/reports/components/DemandsTable.tsx | 2 +- .../reports/components/PredictiveAlerts.tsx | 2 +- src/views/reports/useReportsData.ts | 26 +- src/views/reports/utils.ts | 6 +- src/views/timeline/TimelineView.tsx | 4 +- .../timeline/components/TaskCalendarRows.tsx | 2 +- src/views/timeline/components/TaskRow.tsx | 2 +- 18 files changed, 600 insertions(+), 138 deletions(-) create mode 100644 src/views/planning/components/TaskList.tsx create mode 100644 src/views/planning/components/TaskTable.tsx diff --git a/docs/views/planning.md b/docs/views/planning.md index 7a16651..62ecc49 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -15,7 +15,7 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | `calendar` | Calendário mensal com drag-drop | | `timeline` | Gantt/linha do tempo | | `list` | Tabela de demandas (ListView) | -| `demandas` | Lista de demandas agrupadas por etapa atual | +| `demandas` | Tabela hierárquica de demandas com subtasks como linhas-filho | `PlanningView` recebe `subview: 'calendar' | 'timeline' | 'list' | 'demandas'` e renderiza o modo correspondente. @@ -24,13 +24,15 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | Ficheiro | Responsabilidade | |---|---| | `src/views/planning/PlanningView.tsx` | Orquestra dados, filtros e renderiza a subview correta | -| `src/views/planning/hooks/useTaskFilters.ts` | Filtros de assignee, status, steps, período para calendar/timeline | +| `src/views/planning/hooks/useTaskFilters.ts` | Filtros de assignee, status, subtasks, período para calendar/timeline | | `src/views/planning/components/FilterBar.tsx` | Barra de filtros usada por calendar e timeline | | `src/views/planning/components/MetricsBar.tsx` | Cards de métricas (saúde operacional, em andamento, bloqueadas) | | `src/views/planning/components/StepsLegend.tsx` | Legenda de cores das fases | | `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, etapa, responsável, período, bloqueadas, concluídas) | -| `src/views/planning/components/StepGroup.tsx` | Grupo colapsável de tasks por etapa (usado em `demandas`) | -| `src/views/planning/components/TaskRow.tsx` | Linha de uma demanda no subview `demandas` | +| `src/views/planning/components/TaskTable.tsx` | Tabela hierárquica: task como linha-pai colapsável, subtasks como linhas-filho com colunas (etapa, título, período, prazo, responsáveis) | +| `src/views/planning/components/StepGroup.tsx` | **Legado** — grupo colapsável por etapa (substituído por `TaskTable`) | +| `src/views/planning/components/TaskRow.tsx` | **Legado** — linha de demanda no modo "Por etapa" (substituído por `TaskTable`) | +| `src/views/planning/components/TaskList.tsx` | **Legado** — lista de demandas com subtasks expandidas (substituído por `TaskTable`) | | `src/views/planning/components/ActionMenu.tsx` | Dropdown de ações rápidas (abrir, ClickUp, concluir, bloquear) | | `src/views/planning/utils.ts` | `formatDueDate` — badge de prazo relativo | | `src/views/CalendarView.tsx` | Calendário mensal com drag-drop e slots | @@ -48,30 +50,36 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad ## Subview: Demandas -Lista todas as demandas agrupadas por **subtask status**. Focada em acompanhamento operacional. +Lista todas as demandas em **tabela hierárquica** estilo ClickUp. Não há mais toggle de modo — um único layout unifica a visão por task e por subtask. -### StepGroup -Agrupa tasks por `SubtaskStatus` (8 grupos fixos). Colapsa/expande via `ChevronDown`/`ChevronRight`. +### TaskTable +Componente principal do subview `demandas`. Cada **task** é uma linha-pai colapsável; cada **subtask** é uma linha-filho exibida quando a task está expandida. -Uma demanda aparece em **todos os grupos** onde tiver subtask ativa (`active: true`) — não apenas no grupo da "subtask atual". Com filtro por membro, aparece nos grupos das subtasks onde o membro está atribuído. +**Linha da task (pai):** +- Borda lateral colorida pela fase ativa (ou vermelha se bloqueada) +- Botão `▾`/`▸` para expandir/colapsar subtasks +- Título clicável (`onEdit`) com risco se bloqueada ou concluída +- Badges "Bloqueada" e "Concluída" +- Ícone `Link2` inline para ClickUp +- Contador de etapas, badge de prazo (da subtask ativa), avatares de todos os responsáveis +- `ActionMenu` com `stopPropagation` -Recebe `hasActiveFilters?: boolean`. Comportamento por estado: -- **Com tasks:** expansível normalmente, cabeçalho com contador colorido -- **Vazio + filtros ativos:** cabeçalho apagado (contador `0`), mensagem de filtro em itálico -- **Vazio sem filtros:** renderizado normalmente com contador `0` +**Linhas das subtasks (filhas):** colunas alinhadas com cabeçalho visual: -**Virtualização (`react-window`):** quando um grupo tem mais de 50 tasks, usa `FixedSizeList` (altura de item `52px`, altura máxima `600px`). Abaixo do threshold usa `.map()` normal. - -### TaskRow -Linha de uma demanda. A div inteira é clicável (`onEdit`) — `ActionMenu` tem `stopPropagation`. - -Exibe: -- Título com risco (`line-through`) quando bloqueada -- Ícone `Link2` inline para abrir o ClickUp diretamente -- Badge "Bloqueada" (vermelho) — fundo da linha fica vermelho sutil -- Badge "Concluída" (muted) — linha com opacidade reduzida -- Badge de prazo dinâmico baseado no `end` da subtask ativa do grupo (`formatDueDate`) -- Avatares dos responsáveis da subtask correspondente ao grupo +| Coluna | Conteúdo | +|---|---| +| Etapa | Pill colorida com `STEP_META.label` + indicador "ativa" em verde | +| Título | Texto livre da subtask (`subtask.title`) | +| Período | `DD/MM → DD/MM` (start e end) | +| Prazo | Badge `formatDueDate` — exibido apenas na subtask ativa | +| Resp. | Avatares dos responsáveis da subtask | + +**Comportamentos:** +- Tasks expandidas por padrão quando há filtros ativos +- Subtask ativa destacada com indicador `● ativa` em emerald +- Tasks bloqueadas: fundo vermelho sutil em task e subtasks +- Tasks concluídas: opacidade reduzida, título riscado +- Subtasks sem datas mostram `—` no campo Período ### TasksFilters Barra de filtros com `CheckboxDropdown` customizado para etapa e responsável. Filtros: diff --git a/docs/views/reports.md b/docs/views/reports.md index b43f0f2..16d0c6e 100644 --- a/docs/views/reports.md +++ b/docs/views/reports.md @@ -48,7 +48,7 @@ Substituímos o foco exclusivo em "médias" por métricas estatísticas mais rob * **Interpretação:** Se a vazão média é de 20 tasks/semana, mas a *Timeline* prevê 40 entregas para a próxima semana, o atraso já é garantido matematicamente. ### D. Heatmap de Risco vs. Capacidade -* **Fonte de Dados:** `members` x `tasks.steps` x `Lead Time` histórico. +* **Fonte de Dados:** `members` x `tasks.subtasks` x `Lead Time` histórico. * **Visualização:** Matriz (Membros no Eixo Y, Tipos de Step no Eixo X). Cores de verde a vermelho baseadas no tempo médio que cada membro leva para cada tipo de task. * **Interpretação:** Identifica se um membro está aparentemente livre, mas alocado em uma atividade onde historicamente tem baixa performance (gargalo de senioridade). diff --git a/src/views/MembersView/components/MemberCard.tsx b/src/views/MembersView/components/MemberCard.tsx index e8ad492..c8440f5 100644 --- a/src/views/MembersView/components/MemberCard.tsx +++ b/src/views/MembersView/components/MemberCard.tsx @@ -9,7 +9,7 @@ import { ArrowUpRight, CalendarDays } from 'lucide-react'; import MemberTaskItem from './MemberTaskItem'; function normalizeTask(task: Task | LegacyTask): Task { - if ((task as Task).steps && typeof (task as Task).status === 'object') return task as Task; + if ((task as Task).subtasks && typeof (task as Task).status === 'object') return task as Task; const migrated = migrateLegacyTask(task as LegacyTask); return { ...(task as object), ...migrated } as Task; } @@ -32,7 +32,7 @@ const MemberCard: React.FC = React.memo(({ member, tasks, today const memberSteps: { task: Task; step: Step }[] = []; for (const task of tasks) { const norm = normalizeTask(task); - const visibleSteps = (norm.steps as Step[]).filter( + const visibleSteps = (norm.subtasks as Step[]).filter( s => s.active && s.start && s.end && s.assignees.includes(member.id) ); for (const step of visibleSteps) { @@ -107,7 +107,7 @@ const MemberCard: React.FC = React.memo(({ member, tasks, today
{nextDeadline ? (

- Próxima entrega {formatDateLabel(nextDeadline.step.end)} · {STEP_META[nextDeadline.step.type].label} + Próxima entrega {formatDateLabel(nextDeadline.step.end)} · {STEP_META[nextDeadline.step.status]?.label ?? nextDeadline.step.title}

) : (

Sem etapas futuras atribuídas.

diff --git a/src/views/MembersView/components/MemberTaskItem.tsx b/src/views/MembersView/components/MemberTaskItem.tsx index 080844d..26e51da 100644 --- a/src/views/MembersView/components/MemberTaskItem.tsx +++ b/src/views/MembersView/components/MemberTaskItem.tsx @@ -9,7 +9,7 @@ interface MemberTaskItemProps { } const MemberTaskItem: React.FC = ({ task, steps, today }) => { - const currentStep = getCurrentStep(task.steps, today); + const currentStep = getCurrentStep(task.subtasks, today); const isBlocked = task.status?.blocked; return ( @@ -18,7 +18,7 @@ const MemberTaskItem: React.FC = ({ task, steps, today }) = {isBlocked ? ( ) : ( - + )} {task.title} {isBlocked && ( @@ -29,11 +29,11 @@ const MemberTaskItem: React.FC = ({ task, steps, today }) =
{steps.map(step => { - const meta = STEP_META[step.type]; + const meta = STEP_META[step.status]; const isActive = step.start <= today && step.end >= today; return ( = (task.status?.blockedAt ?? '') ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200' diff --git a/src/views/calendar/hooks/useCalendarDrag.ts b/src/views/calendar/hooks/useCalendarDrag.ts index a8ae481..60f5aaf 100644 --- a/src/views/calendar/hooks/useCalendarDrag.ts +++ b/src/views/calendar/hooks/useCalendarDrag.ts @@ -23,7 +23,7 @@ export function useCalendarDrag(tasks: Task[], onUpdateTask: (task: Task) => voi const container = (e.currentTarget as HTMLElement).closest('[data-week-row]') as HTMLElement; const colWidth = container ? container.getBoundingClientRect().width / 7 : 80; const norm = normaliseTask(task); - const step = (norm.steps as Step[]).find(s => s.type === bar.stepType); + const step = (norm.subtasks as Step[]).find(s => s.status === bar.stepType); if (!step) return; phaseDragStart(e, bar.taskId, bar.stepType, type, step, colWidth); }, [phaseDragStart]); diff --git a/src/views/list/components/DemandRow.tsx b/src/views/list/components/DemandRow.tsx index f6441dc..3f267e3 100644 --- a/src/views/list/components/DemandRow.tsx +++ b/src/views/list/components/DemandRow.tsx @@ -19,7 +19,7 @@ function formatDate(isoDate: string): string { } export const DemandRow = memo(function DemandRow({ task, referenceDate, members, onEdit, onDelete, onConclude, onToggleBlock }: DemandRowProps) { - const allAssigneeIds = [...new Set(task.steps.flatMap(s => s.assignees))]; + const allAssigneeIds = [...new Set(task.subtasks.flatMap(s => s.assignees))]; const assigneeMembers = allAssigneeIds .map(id => members.find(m => m.id === id)) .filter((m): m is Member => m !== undefined); diff --git a/src/views/list/hooks/useListFilters.ts b/src/views/list/hooks/useListFilters.ts index f0df6ad..46e90f1 100644 --- a/src/views/list/hooks/useListFilters.ts +++ b/src/views/list/hooks/useListFilters.ts @@ -8,7 +8,7 @@ export interface NormalisedTask { } function getReferenceDate(task: Task): string { - const activeEnds = task.steps + const activeEnds = task.subtasks .filter(s => s.active && s.end) .map(s => s.end) .sort(); @@ -41,7 +41,7 @@ export function useListFilters(tasks: Task[], _members: Member[]) { .filter(({ referenceDate }) => referenceDate >= rangeStart && referenceDate <= rangeEnd) .filter(({ task }) => { if (filterAssignee) { - const allAssignees = task.steps.flatMap(s => s.assignees); + const allAssignees = task.subtasks.flatMap(s => s.assignees); if (!allAssignees.includes(filterAssignee)) return false; } if (filterStatus) { diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index 9c0a199..c64bd2c 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -13,13 +13,12 @@ import { useTaskFilters } from './hooks/useTaskFilters'; import { FilterBar } from './components/FilterBar'; import { StepsLegend } from './components/StepsLegend'; import { TasksFilters, type FiltersState } from './components/TasksFilters'; -import { StepGroup } from './components/StepGroup'; +import { TaskTable } from './components/TaskTable'; import type { PlanningViewProps } from '@/types/props'; import { useUIStore } from '@/store/useUIStore'; import { ViewState } from '@/components/ViewState'; import { CalendarX2, DatabaseZap, FilterX, Search, Plus } from 'lucide-react'; import { Skeleton } from 'boneyard-js/react'; -import { STEP_TYPES_ORDER, type SubtaskStatus, type Task } from '@/lib/steps'; import { Button } from '@/components/ui/Button'; const PLANNING_BONES = { @@ -129,45 +128,7 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd }); }, [tasks, demandasFilters, subview]); - const groupedDemandasTasks = useMemo(() => { - const { selectedMemberIds } = demandasFilters; - const groups = new Map(); - STEP_TYPES_ORDER.forEach(s => groups.set(s, [])); - filteredDemandasTasks.forEach(task => { - let subtasksToUse = task.subtasks.filter(s => s.active); - if (subtasksToUse.length === 0) subtasksToUse = task.subtasks.slice(0, 1); - - if (selectedMemberIds.length > 0) { - const memberSubtasks = task.subtasks.filter(s => - s.assignees.some(a => selectedMemberIds.includes(a)) - ); - if (memberSubtasks.length > 0) subtasksToUse = memberSubtasks; - } - - const seenStatuses = new Set(); - subtasksToUse.forEach(subtask => { - if (seenStatuses.has(subtask.status)) return; - seenStatuses.add(subtask.status); - const bucket = groups.get(subtask.status); - if (bucket) bucket.push(task); - }); - }); - - groups.forEach((bucket, status) => { - bucket.sort((a, b) => { - const endA = a.subtasks.find(s => s.status === status && s.active)?.end; - const endB = b.subtasks.find(s => s.status === status && s.active)?.end; - if (!endA && !endB) return 0; - if (!endA) return 1; - if (!endB) return -1; - return endA < endB ? -1 : endA > endB ? 1 : 0; - }); - }); - - return groups; - }, [filteredDemandasTasks, demandasFilters]); - - const hasDemandasActiveFilters = +const hasDemandasActiveFilters = demandasFilters.searchTerm !== '' || demandasFilters.selectedSteps.length > 0 || demandasFilters.selectedMemberIds.length > 0 || @@ -259,14 +220,14 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd onDelete(task.id)} onOpenNew={onOpenNew} onExport={onExport} /> ) : subview === 'demandas' ? (
-
+
setDemandasFilters(prev => ({ ...prev, ...next }))} onClear={() => setDemandasFilters(EMPTY_FILTERS)} /> - @@ -280,36 +241,24 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd Criar primeira demanda
- ) : ( -
- {([...groupedDemandasTasks.entries()] as [SubtaskStatus, Task[]][]) - .filter(([stepType]) => - demandasFilters.selectedSteps.length === 0 || demandasFilters.selectedSteps.includes(stepType) - ) - .map(([stepType, stepTasks]) => ( - - ))} - {filteredDemandasTasks.length === 0 && hasDemandasActiveFilters && ( -
-

Nenhuma demanda encontrada com os filtros atuais.

- -
- )} + ) : filteredDemandasTasks.length === 0 && hasDemandasActiveFilters ? ( +
+

Nenhuma demanda encontrada com os filtros atuais.

+
+ ) : ( + )}
) : null} diff --git a/src/views/planning/components/TaskList.tsx b/src/views/planning/components/TaskList.tsx new file mode 100644 index 0000000..f1cabd7 --- /dev/null +++ b/src/views/planning/components/TaskList.tsx @@ -0,0 +1,179 @@ +import { memo } from 'react'; +import { Link2, AlertCircle, Clock } from 'lucide-react'; +import { STEP_META, type Task } from '@/lib/steps'; +import type { Member } from '@/hooks/infra/useSupabase'; +import { formatDueDate } from '../utils'; +import { ActionMenu } from './ActionMenu'; + +interface TaskListProps { + tasks: Task[]; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; +} + +const SubtaskChip = memo(function SubtaskChip({ subtask, task, members, onToggleBlock, onConclude, onEdit }: { + subtask: Task['subtasks'][number]; + task: Task; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; +}) { + const meta = STEP_META[subtask.status]; + const timeStatus = formatDueDate(subtask.end); + const assigneeMembers = subtask.assignees + .map(id => members.find(m => m.id === id)) + .filter((m): m is Member => m !== undefined); + const isBlocked = task.status.blocked; + + return ( +
onEdit(task)} + > +
+ +
+ {/* subtask label + task name */} +
+
+ + {meta.label} + + {subtask.active && ( + ativa + )} +
+ {subtask.title} +
+ +
+ {timeStatus && ( +
+ + {timeStatus.label} +
+ )} + +
+ {assigneeMembers.length === 0 ? ( +
?
+ ) : ( + assigneeMembers.slice(0, 3).map(m => ( +
+ {m.avatar_url + ? {m.name} + : m.name.slice(0, 2).toUpperCase() + } +
+ )) + )} +
+
+
+
+ ); +}); + +export const TaskList = memo(function TaskList({ tasks, members, onToggleBlock, onConclude, onEdit }: TaskListProps) { + if (tasks.length === 0) return null; + + return ( +
+ {tasks.map(task => { + const isBlocked = task.status.blocked; + const isConcluded = !!task.concludedAt; + + return ( +
+ {/* Header da task */} +
+
+ onEdit(task)} + > + {task.title} + + + {task.clickupLink && ( + e.stopPropagation()} + title="Abrir no ClickUp" + className="text-muted-foreground hover:text-primary transition-colors shrink-0" + > + + + )} + + {isBlocked && ( + + + Bloqueada + + )} + {isConcluded && ( + + Concluída + + )} +
+ +
e.stopPropagation()}> + +
+
+ + {/* Subtasks */} + {task.subtasks.length > 0 && ( + <> +
+
+ {task.subtasks.map(subtask => ( + + ))} +
+ + )} +
+ ); + })} +
+ ); +}); diff --git a/src/views/planning/components/TaskTable.tsx b/src/views/planning/components/TaskTable.tsx new file mode 100644 index 0000000..12f4c12 --- /dev/null +++ b/src/views/planning/components/TaskTable.tsx @@ -0,0 +1,326 @@ +import { memo, useState } from 'react'; +import { ChevronDown, ChevronRight, Link2, AlertCircle, Circle, CheckCircle2 } from 'lucide-react'; +import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; +import type { Member } from '@/hooks/infra/useSupabase'; +import { formatDueDate } from '../utils'; +import { ActionMenu } from './ActionMenu'; + +interface TaskTableProps { + tasks: Task[]; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; +} + +function MemberAvatars({ assigneeIds, members, size = 'sm' }: { + assigneeIds: string[]; + members: Member[]; + size?: 'sm' | 'xs'; +}) { + const dim = size === 'xs' ? 'w-5 h-5 text-[9px]' : 'w-6 h-6 text-[10px]'; + const resolved = assigneeIds + .map(id => members.find(m => m.id === id)) + .filter((m): m is Member => m !== undefined); + + if (resolved.length === 0) { + return ( +
+ ? +
+ ); + } + + return ( +
+ {resolved.slice(0, 3).map(m => ( +
+ {m.avatar_url + ? {m.name} + : m.name.slice(0, 2).toUpperCase() + } +
+ ))} + {resolved.length > 3 && ( +
+ +{resolved.length - 3} +
+ )} +
+ ); +} + +function formatDateRange(start: string, end: string): string { + const fmt = (d: string) => { + if (!d) return '—'; + const [, m, day] = d.split('-'); + return `${day}/${m}`; + }; + if (!start && !end) return '—'; + if (!start) return `até ${fmt(end)}`; + if (!end) return `de ${fmt(start)}`; + return `${fmt(start)} → ${fmt(end)}`; +} + +const SubtaskRow = memo(function SubtaskRow({ + subtask, + task, + members, + isLast, + onEdit, +}: { + subtask: Task['subtasks'][number]; + task: Task; + members: Member[]; + isLast: boolean; + onEdit: (task: Task) => void; +}) { + const meta = STEP_META[subtask.status as SubtaskStatus]; + const timeStatus = formatDueDate(subtask.end); + const isBlocked = task.status.blocked; + const isConcluded = !!task.concludedAt; + + return ( +
onEdit(task)} + > + {/* Indicador de fase */} +
+
+ + {meta.label} + + {subtask.active && !isConcluded && ( + + + ativa + + )} +
+ + {/* Título da subtask */} + + {subtask.title} + + + {/* Datas */} + + {formatDateRange(subtask.start, subtask.end)} + + + {/* Badge de prazo */} +
+ {timeStatus && subtask.active && ( + + {timeStatus.label} + + )} +
+ + {/* Responsáveis */} +
+ +
+
+ ); +}); + +const TaskTableRow = memo(function TaskTableRow({ + task, + members, + onToggleBlock, + onConclude, + onEdit, + defaultExpanded, +}: { + task: Task; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; + defaultExpanded: boolean; +}) { + const [expanded, setExpanded] = useState(defaultExpanded); + const isBlocked = task.status.blocked; + const isConcluded = !!task.concludedAt; + + const activeSubtask = task.subtasks.find(s => s.active); + const allAssigneeIds = [...new Set(task.subtasks.flatMap(s => s.assignees))]; + + const taskTimeStatus = formatDueDate(activeSubtask?.end); + + return ( +
+ {/* Linha da task (pai) */} +
task.subtasks.length > 0 && setExpanded(e => !e)} + > + {/* Borda lateral colorida */} +
+ + {/* Expand/collapse */} + + + {/* Título */} +
{ e.stopPropagation(); onEdit(task); }} + > + + {task.title} + + + {task.clickupLink && ( + e.stopPropagation()} + title="Abrir no ClickUp" + className="text-muted-foreground hover:text-primary transition-colors shrink-0" + > + + + )} + + {isBlocked && ( + + + Bloqueada + + )} + {isConcluded && ( + + + Concluída + + )} +
+ + {/* Meta-info da task: subtask count + prazo + responsáveis */} +
+ {task.subtasks.length > 0 && ( + + {task.subtasks.length} etapa{task.subtasks.length !== 1 ? 's' : ''} + + )} + + {taskTimeStatus && !isConcluded && ( + + {taskTimeStatus.label} + + )} + + + +
e.stopPropagation()}> + +
+
+
+ + {/* Linhas das subtasks */} + {expanded && task.subtasks.length > 0 && ( +
+ {task.subtasks.map((subtask, idx) => ( + + ))} +
+ )} +
+ ); +}, (prev, next) => + prev.task.id === next.task.id && + prev.task.status?.blocked === next.task.status?.blocked && + prev.task.concludedAt === next.task.concludedAt && + prev.task.subtasks?.length === next.task.subtasks?.length && + prev.members.length === next.members.length && + prev.defaultExpanded === next.defaultExpanded +); + +export const TaskTable = memo(function TaskTable({ tasks, members, onToggleBlock, onConclude, onEdit }: TaskTableProps) { + if (tasks.length === 0) return null; + + const hasActiveFilters = tasks.length > 0; + + return ( +
+ {/* Cabeçalho de colunas (apenas visual, alinhado com as subtask rows) */} +
+ Etapa + Título + Período + Prazo + Resp. +
+ + {tasks.map(task => ( + + ))} +
+ ); +}); diff --git a/src/views/planning/hooks/useTaskFilters.ts b/src/views/planning/hooks/useTaskFilters.ts index 0fc7a5c..162e05b 100644 --- a/src/views/planning/hooks/useTaskFilters.ts +++ b/src/views/planning/hooks/useTaskFilters.ts @@ -54,18 +54,18 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia } if (filterAssignee) { const norm = normaliseTask(task); - const anyStepHas = norm.steps.some(s => s.assignees.includes(filterAssignee)); + const anyStepHas = norm.subtasks.some(s => s.assignees.includes(filterAssignee)); if (!anyStepHas) return false; } if (filterSteps.length > 0) { const norm = normaliseTask(task); - const activeTypes = norm.steps.filter(s => s.active).map(s => s.type); + const activeTypes = norm.subtasks.filter(s => s.active).map(s => s.status); if (!filterSteps.some(ft => activeTypes.includes(ft))) return false; } if (enablePeriodFilter) { const norm = normaliseTask(task); - const intersectsPeriod = norm.steps.some(step => { + const intersectsPeriod = norm.subtasks.some(step => { if (!step.active || !step.start || !step.end) return false; const stepStart = new Date(step.start + 'T00:00:00'); const stepEnd = new Date(step.end + 'T00:00:00'); @@ -80,14 +80,14 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia }) .map(task => { const norm = normaliseTask(task); - let steps = norm.steps; + let subtasks = norm.subtasks; if (filterSteps.length > 0) { - steps = steps.filter(s => filterSteps.includes(s.type)); + subtasks = subtasks.filter(s => filterSteps.includes(s.status)); } if (filterAssignee) { - steps = steps.filter(s => s.assignees.includes(filterAssignee)); + subtasks = subtasks.filter(s => s.assignees.includes(filterAssignee)); } - return { ...norm, steps }; + return { ...norm, subtasks }; }); }, [tasks, filterAssignee, filterStatus, filterSteps, enablePeriodFilter, periodStart, periodEnd]); @@ -104,7 +104,7 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia return tasks.filter(t => { const norm = normaliseTask(t); if (norm.concludedAt) return false; - const step = getCurrentStep(norm.steps ?? [], today); + const step = getCurrentStep(norm.subtasks ?? [], today); return step && step.start <= today && step.end >= today; }).length; }, [tasks]); diff --git a/src/views/reports/components/DemandsTable.tsx b/src/views/reports/components/DemandsTable.tsx index aacc2b7..d08df11 100644 --- a/src/views/reports/components/DemandsTable.tsx +++ b/src/views/reports/components/DemandsTable.tsx @@ -35,7 +35,7 @@ const DemandsTable: React.FC = ({ enriched }) => ( {enriched.map((t, i) => { - const stepMeta = t.currentStep ? STEP_META[t.currentStep.type as StepType] : null; + const stepMeta = t.currentStep ? STEP_META[t.currentStep.status as StepType] : null; return ( diff --git a/src/views/reports/components/PredictiveAlerts.tsx b/src/views/reports/components/PredictiveAlerts.tsx index 0e73df2..d05f5d1 100644 --- a/src/views/reports/components/PredictiveAlerts.tsx +++ b/src/views/reports/components/PredictiveAlerts.tsx @@ -24,7 +24,7 @@ const PredictiveAlerts: React.FC = ({ enriched, p85ByStep for (const t of enriched) { const step = t.currentStep; - const p85 = step ? p85ByStep[step.type] : 0; + const p85 = step ? p85ByStep[step.status] : 0; const plannedDuration = step?.start && step?.end ? Math.ceil((new Date(step.end + 'T00:00:00').getTime() - new Date(step.start + 'T00:00:00').getTime()) / 86400000) + 1 : 0; diff --git a/src/views/reports/useReportsData.ts b/src/views/reports/useReportsData.ts index 7e7cbc2..79456cf 100644 --- a/src/views/reports/useReportsData.ts +++ b/src/views/reports/useReportsData.ts @@ -138,7 +138,7 @@ export function useReportsData(): ReportsData { if (t.risk === 'concluido') continue; for (const step of t.visibleSteps) { if (step.start <= today && step.end >= today) { - counts[step.type as StepType] = (counts[step.type as StepType] ?? 0) + 1; + counts[step.status as StepType] = (counts[step.status as StepType] ?? 0) + 1; } } } @@ -188,12 +188,12 @@ export function useReportsData(): ReportsData { totalDays += days; for (const step of t.visibleSteps) { - if (!byStepCounts[step.type]) { - byStepCounts[step.type] = { total: 0, count: 0 }; + if (!byStepCounts[step.status]) { + byStepCounts[step.status] = { total: 0, count: 0 }; } const stepDays = calDaysBetween(step.start, step.end) + 1; - byStepCounts[step.type].total += stepDays; - byStepCounts[step.type].count += 1; + byStepCounts[step.status].total += stepDays; + byStepCounts[step.status].count += 1; } } } @@ -234,12 +234,12 @@ export function useReportsData(): ReportsData { const stepStats: Record = {}; for (const t of filteredEnriched) { for (const step of t.visibleSteps) { - if (!stepStats[step.type]) { - stepStats[step.type] = { avgDuration: 0, totalTasks: 0, delays: 0 }; + if (!stepStats[step.status]) { + stepStats[step.status] = { avgDuration: 0, totalTasks: 0, delays: 0 }; } - stepStats[step.type].totalTasks++; + stepStats[step.status].totalTasks++; const duration = calDaysBetween(step.start, step.end) + 1; - stepStats[step.type].avgDuration = Math.round(((stepStats[step.type].avgDuration * (stepStats[step.type].totalTasks - 1)) + duration) / stepStats[step.type].totalTasks); + stepStats[step.status].avgDuration = Math.round(((stepStats[step.status].avgDuration * (stepStats[step.status].totalTasks - 1)) + duration) / stepStats[step.status].totalTasks); } } return Object.entries(stepStats) @@ -272,7 +272,7 @@ export function useReportsData(): ReportsData { scatterData.push({ date: t.lastDeadline, duration: t.leadTime, - stepType: t.currentStep?.type ?? 'desenvolvimento', + stepType: t.currentStep?.status ?? 'desenvolvimento', title: t.title, }); } @@ -282,8 +282,8 @@ export function useReportsData(): ReportsData { const stepDurations: Record = {}; for (const t of completedTasks) { for (const step of t.visibleSteps) { - if (!stepDurations[step.type]) stepDurations[step.type] = []; - stepDurations[step.type].push(calDaysBetween(step.start, step.end) + 1); + if (!stepDurations[step.status]) stepDurations[step.status] = []; + stepDurations[step.status].push(calDaysBetween(step.start, step.end) + 1); } } for (const [type, durations] of Object.entries(stepDurations)) { @@ -301,7 +301,7 @@ export function useReportsData(): ReportsData { let count = 0; for (const t of filteredEnriched) { for (const step of t.visibleSteps) { - if (step.assignees.includes(m.id) && step.type === stepType) { + if (step.assignees.includes(m.id) && step.status === stepType) { totalDays += calDaysBetween(step.start, step.end) + 1; count++; } diff --git a/src/views/reports/utils.ts b/src/views/reports/utils.ts index a22b4ae..5a5a567 100644 --- a/src/views/reports/utils.ts +++ b/src/views/reports/utils.ts @@ -47,13 +47,13 @@ export function isTaskStagnant(lastUpdatedAt: string | null, daysThreshold: numb } export function normalizeTask(task: Task | LegacyTask): Task { - if ((task as Task).steps && typeof (task as Task).status === 'object') return task as Task; + if ((task as Task).subtasks && typeof (task as Task).status === 'object') return task as Task; const migrated = migrateLegacyTask(task as LegacyTask); return { ...(task as object), ...migrated } as Task; } export function getVisibleSteps(task: Task | LegacyTask): Step[] { - return normalizeTask(task).steps.filter(s => s.active && s.start && s.end); + return normalizeTask(task).subtasks.filter(s => s.active && s.start && s.end); } export function getLastDeadline(task: Task | LegacyTask): string | null { @@ -135,7 +135,7 @@ export function enrichTask(task: Task | LegacyTask, today: string, members: Memb const bizLeft = lastDeadline ? businessDaysLeft(lastDeadline, today) : 0; const memberIds = getTaskMembers(task); const taskMembers = members.filter(m => memberIds.includes(m.id)); - const currentStep = getCurrentStep(norm.steps ?? [], today); + const currentStep = getCurrentStep(norm.subtasks ?? [], today); let progress = 0; if (visibleSteps.length > 0 && lastDeadline && firstStart) { diff --git a/src/views/timeline/TimelineView.tsx b/src/views/timeline/TimelineView.tsx index fdc95f0..8ebe61c 100644 --- a/src/views/timeline/TimelineView.tsx +++ b/src/views/timeline/TimelineView.tsx @@ -66,9 +66,9 @@ const TimelineView: React.FC = ({ tasks, members, onEdit, onD .join(', '); return ( -
+
- {STEP_META[step.type].tag} + {STEP_META[step.status]?.tag ?? step.title} {formatStepRange(step.start, step.end)}
{assignees &&
{assignees}
} diff --git a/src/views/timeline/components/TaskCalendarRows.tsx b/src/views/timeline/components/TaskCalendarRows.tsx index d85ab46..9933964 100644 --- a/src/views/timeline/components/TaskCalendarRows.tsx +++ b/src/views/timeline/components/TaskCalendarRows.tsx @@ -47,7 +47,7 @@ const TaskCalendarRows: React.FC = ({ task, days, daysRan /> ) : visibleSteps.map((step) => ( = ({ task, members, days, daysRange, dragP /> ) : visibleSteps.map(step => ( Date: Tue, 12 May 2026 14:05:32 -0300 Subject: [PATCH 04/12] feat: :sparkles: adicionado popover das demandas --- docs/hooks/supabase.md | 6 + docs/views/planning.md | 38 ++- src/hooks/tasks/useSubtaskQuickEdit.ts | 77 +++++ src/views/planning/PlanningView.tsx | 7 + .../planning/components/AssigneesPopover.tsx | 99 ++++++ .../planning/components/DatesPopover.tsx | 115 +++++++ .../planning/components/MemberAvatars.tsx | 45 +++ src/views/planning/components/SubtaskRow.tsx | 97 ++++++ src/views/planning/components/TaskTable.tsx | 308 +----------------- .../planning/components/TaskTableRow.tsx | 188 +++++++++++ src/views/planning/components/usePopover.ts | 58 ++++ 11 files changed, 736 insertions(+), 302 deletions(-) create mode 100644 src/hooks/tasks/useSubtaskQuickEdit.ts create mode 100644 src/views/planning/components/AssigneesPopover.tsx create mode 100644 src/views/planning/components/DatesPopover.tsx create mode 100644 src/views/planning/components/MemberAvatars.tsx create mode 100644 src/views/planning/components/SubtaskRow.tsx create mode 100644 src/views/planning/components/TaskTableRow.tsx create mode 100644 src/views/planning/components/usePopover.ts diff --git a/docs/hooks/supabase.md b/docs/hooks/supabase.md index 8ed36f5..68f00c2 100644 --- a/docs/hooks/supabase.md +++ b/docs/hooks/supabase.md @@ -28,6 +28,12 @@ Aplicado em: `useSupabase` (500ms), `useTaskQuickActions` (500ms), `useUserClien `useTaskQuickActions` (`src/hooks/useTaskQuickActions.ts`) centraliza os toggles rápidos de bloqueio e conclusão, usados por `ListView` e `TasksView` — elimina código duplicado e garante throttle consistente. +`useSubtaskQuickEdit` (`src/hooks/tasks/useSubtaskQuickEdit.ts`) — mutations granulares de subtask sem passar pela modal. Expõe: +- `updateSubtaskAssignees(task, subtaskId, assignees[])` — diff de adds/removes em `subtask_assignees` com update otimista no cache +- `updateSubtaskDates(task, subtaskId, start, end)` — UPDATE direto em `task_subtasks.start_date / end_date` com update otimista no cache + +Usado por `PlanningView` (subview `demandas`) para alimentar os popovers inline de `TaskTable`. + ## Update otimista (`updateTask`) ``` diff --git a/docs/views/planning.md b/docs/views/planning.md index 62ecc49..f17279e 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -29,7 +29,13 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | `src/views/planning/components/MetricsBar.tsx` | Cards de métricas (saúde operacional, em andamento, bloqueadas) | | `src/views/planning/components/StepsLegend.tsx` | Legenda de cores das fases | | `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, etapa, responsável, período, bloqueadas, concluídas) | -| `src/views/planning/components/TaskTable.tsx` | Tabela hierárquica: task como linha-pai colapsável, subtasks como linhas-filho com colunas (etapa, título, período, prazo, responsáveis) | +| `src/views/planning/components/TaskTable.tsx` | Componente raiz da tabela — itera tasks e delega para `TaskTableRow` | +| `src/views/planning/components/TaskTableRow.tsx` | Linha-pai colapsável de uma task com progresso, prazo, avatares e painel de subtasks | +| `src/views/planning/components/SubtaskRow.tsx` | Linha-filho de uma subtask com ícone de status, badge de etapa, `DatesPopover` e `AssigneesPopover` | +| `src/views/planning/components/MemberAvatars.tsx` | Avatares empilhados de membros (tamanhos `sm`/`xs`); placeholder `?` quando sem responsável | +| `src/views/planning/components/AssigneesPopover.tsx` | Popover de atribuição de responsáveis — renderizado via `createPortal` no `document.body` | +| `src/views/planning/components/DatesPopover.tsx` | Popover de edição de período (start/end) — renderizado via `createPortal` no `document.body` | +| `src/views/planning/components/usePopover.ts` | Hook de controle de popover: open/close, posicionamento via `getBoundingClientRect`, fechar ao clicar fora ou `Escape` | | `src/views/planning/components/StepGroup.tsx` | **Legado** — grupo colapsável por etapa (substituído por `TaskTable`) | | `src/views/planning/components/TaskRow.tsx` | **Legado** — linha de demanda no modo "Por etapa" (substituído por `TaskTable`) | | `src/views/planning/components/TaskList.tsx` | **Legado** — lista de demandas com subtasks expandidas (substituído por `TaskTable`) | @@ -61,22 +67,40 @@ Componente principal do subview `demandas`. Cada **task** é uma linha-pai colap - Título clicável (`onEdit`) com risco se bloqueada ou concluída - Badges "Bloqueada" e "Concluída" - Ícone `Link2` inline para ClickUp +- **Barra de progresso** (visível em `md+`): percentual de subtasks concluídas; verde em 100%, cor primária caso contrário - Contador de etapas, badge de prazo (da subtask ativa), avatares de todos os responsáveis - `ActionMenu` com `stopPropagation` -**Linhas das subtasks (filhas):** colunas alinhadas com cabeçalho visual: +**Painel expandido das subtasks:** header interno alinhado ao grid + linha vertical da árvore (`absolute left-[18px]`) com traço horizontal em cada subtask. Grid fixo `grid-cols-[180px_1fr_110px_90px_auto]`. | Coluna | Conteúdo | |---|---| -| Etapa | Pill colorida com `STEP_META.label` + indicador "ativa" em verde | +| Etapa | Ícone de status contextual + pill colorida com `STEP_META.label` | | Título | Texto livre da subtask (`subtask.title`) | -| Período | `DD/MM → DD/MM` (start e end) | -| Prazo | Badge `formatDueDate` — exibido apenas na subtask ativa | -| Resp. | Avatares dos responsáveis da subtask | +| Período | `DD/MM → DD/MM` em `font-mono` — clicável; abre `DatesPopover` para editar start/end inline | +| Prazo | Badge `formatDueDate` — exibido para **todas** as subtasks; `—` quando sem data | +| Resp. | Avatares dos responsáveis; botão `+` dashed quando sem responsável — clicável; abre `AssigneesPopover` para atribuir/remover membros inline | + +**Ícones de status da subtask (`SubtaskStatusIcon`):** + +| Estado | Ícone | +|---|---| +| Concluída (task) | `CheckCircle2` verde | +| Ativa + bloqueada | `AlertTriangle` laranja | +| Ativa + atrasada | `AlertCircle` vermelho | +| Ativa normal | `PlayCircle` azul | +| Pendente | `Circle` pequeno em muted | + +**Edição inline de subtasks:** + +`AssigneesPopover` — abre ao clicar no avatar ou no botão `+` da coluna Resp. Lista todos os membros com toggle (checkbox visual). Persiste via `useSubtaskQuickEdit.updateSubtaskAssignees` — diff de adds/removes direto em `subtask_assignees`. Update otimista no cache TanStack Query. + +`DatesPopover` — abre ao clicar no período da coluna Período. Dois `` (início/fim) com botões Cancelar/Salvar. Persiste via `useSubtaskQuickEdit.updateSubtaskDates` — UPDATE direto em `task_subtasks`. Fecha ao salvar com sucesso. + +Ambos fecham ao clicar fora ou pressionar `Escape` (hook `usePopover`). Renderizam via `createPortal` no `document.body` com `z-index: 9999` e posicionamento calculado por `getBoundingClientRect + scrollY/scrollX` — isso evita corte por `overflow:hidden` das rows da tabela. `PlanningView` instancia `useSubtaskQuickEdit` e passa `onUpdateSubtaskAssignees` e `onUpdateSubtaskDates` para `TaskTable`. **Comportamentos:** - Tasks expandidas por padrão quando há filtros ativos -- Subtask ativa destacada com indicador `● ativa` em emerald - Tasks bloqueadas: fundo vermelho sutil em task e subtasks - Tasks concluídas: opacidade reduzida, título riscado - Subtasks sem datas mostram `—` no campo Período diff --git a/src/hooks/tasks/useSubtaskQuickEdit.ts b/src/hooks/tasks/useSubtaskQuickEdit.ts new file mode 100644 index 0000000..756f9d1 --- /dev/null +++ b/src/hooks/tasks/useSubtaskQuickEdit.ts @@ -0,0 +1,77 @@ +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { supabase } from '@/lib/supabase' +import { toast } from 'sonner' +import { queryKeys } from '@/lib/queries' +import type { Task } from '@/lib/steps' + +interface UseSubtaskQuickEditOptions { + clientId?: string | null + isAdmin?: boolean +} + +export function useSubtaskQuickEdit({ clientId, isAdmin }: UseSubtaskQuickEditOptions = {}) { + const queryClient = useQueryClient() + + const patchCache = useCallback((taskId: string, subtaskId: string, patch: Partial) => { + const key = queryKeys.tasks(clientId ?? null, isAdmin ?? false) + queryClient.setQueryData(key, prev => + prev?.map(t => + t.id !== taskId ? t : { + ...t, + subtasks: t.subtasks.map(s => s.id !== subtaskId ? s : { ...s, ...patch }), + } + ) ?? [] + ) + return () => queryClient.invalidateQueries({ queryKey: ['tasks'] }) + }, [queryClient, clientId, isAdmin]) + + const updateSubtaskAssignees = useCallback(async ( + task: Task, + subtaskId: string, + assignees: string[], + ): Promise => { + const rollback = patchCache(task.id, subtaskId, { assignees }) + + const prevAssignees = task.subtasks.find(s => s.id === subtaskId)?.assignees ?? [] + const toAdd = assignees.filter(a => !prevAssignees.includes(a)) + const toRemove = prevAssignees.filter(a => !assignees.includes(a)) + + if (toRemove.length > 0) { + const { error } = await supabase + .from('subtask_assignees') + .delete() + .eq('subtask_id', subtaskId) + .in('member_id', toRemove) + if (error) { rollback(); toast.error('Erro ao remover responsável'); return false } + } + + if (toAdd.length > 0) { + const { error } = await supabase + .from('subtask_assignees') + .insert(toAdd.map(member_id => ({ subtask_id: subtaskId, member_id }))) + if (error) { rollback(); toast.error('Erro ao atribuir responsável'); return false } + } + + return true + }, [patchCache]) + + const updateSubtaskDates = useCallback(async ( + task: Task, + subtaskId: string, + start: string, + end: string, + ): Promise => { + const rollback = patchCache(task.id, subtaskId, { start, end }) + + const { error } = await supabase + .from('task_subtasks') + .update({ start_date: start || null, end_date: end || null }) + .eq('id', subtaskId) + + if (error) { rollback(); toast.error('Erro ao atualizar datas'); return false } + return true + }, [patchCache]) + + return { updateSubtaskAssignees, updateSubtaskDates } +} diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index c64bd2c..ed2e91c 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '@/contexts/AuthContext'; import { useClients } from '@/hooks/clients/useClients'; import { useTaskQuickActions } from '@/hooks/tasks/useTaskQuickActions'; +import { useSubtaskQuickEdit } from '@/hooks/tasks/useSubtaskQuickEdit'; import { CalendarView } from '@/views/calendar'; import TimelineView from '@/views/timeline'; import { ListView } from '@/views/list'; @@ -68,6 +69,10 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd const membersError = membersErr?.message ?? null; const { concludeTask, toggleBlock } = useTaskQuickActions(member?.auth_user_id); + const { updateSubtaskAssignees, updateSubtaskDates } = useSubtaskQuickEdit({ + clientId: effectiveClientId, + isAdmin, + }); const { filterAssignee, setFilterAssignee, @@ -258,6 +263,8 @@ const hasDemandasActiveFilters = onToggleBlock={toggleBlock} onConclude={concludeTask} onEdit={onEdit} + onUpdateSubtaskAssignees={updateSubtaskAssignees} + onUpdateSubtaskDates={updateSubtaskDates} /> )}
diff --git a/src/views/planning/components/AssigneesPopover.tsx b/src/views/planning/components/AssigneesPopover.tsx new file mode 100644 index 0000000..1efce72 --- /dev/null +++ b/src/views/planning/components/AssigneesPopover.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Plus, Check } from 'lucide-react'; +import type { Task } from '@/lib/steps'; +import type { Member } from '@/hooks/infra/useSupabase'; +import { MemberAvatars } from './MemberAvatars'; +import { usePopover } from './usePopover'; + +interface AssigneesPopoverProps { + subtask: Task['subtasks'][number]; + task: Task; + members: Member[]; + onUpdate?: (task: Task, subtaskId: string, assignees: string[]) => Promise; +} + +export function AssigneesPopover({ subtask, task, members, onUpdate }: AssigneesPopoverProps) { + const { open, setOpen, pos, anchorRef } = usePopover({ align: 'end', contentWidth: 208 }); + const [pending, setPending] = useState(false); + const [localAssignees, setLocalAssignees] = useState(subtask.assignees); + + function openPopover(e: React.MouseEvent) { + e.stopPropagation(); + setLocalAssignees(subtask.assignees); + setOpen(v => !v); + } + + async function toggle(memberId: string) { + if (!onUpdate || pending) return; + const next = localAssignees.includes(memberId) + ? localAssignees.filter(a => a !== memberId) + : [...localAssignees, memberId]; + setLocalAssignees(next); + setPending(true); + await onUpdate(task, subtask.id, next); + setPending(false); + } + + const hasMember = localAssignees.length > 0; + + return ( +
+ + + {open && createPortal( +
e.stopPropagation()} + > +

+ Responsáveis +

+ {members.map(m => { + const selected = localAssignees.includes(m.id); + return ( + + ); + })} + {members.length === 0 && ( +

Nenhum membro disponível

+ )} +
, + document.body + )} +
+ ); +} diff --git a/src/views/planning/components/DatesPopover.tsx b/src/views/planning/components/DatesPopover.tsx new file mode 100644 index 0000000..c94e92d --- /dev/null +++ b/src/views/planning/components/DatesPopover.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { Task } from '@/lib/steps'; +import { usePopover } from './usePopover'; + +function formatDateRange(start: string, end: string): string { + const fmt = (d: string) => { + if (!d) return '—'; + const [, m, day] = d.split('-'); + return `${day}/${m}`; + }; + if (!start && !end) return '—'; + if (!start) return `até ${fmt(end)}`; + if (!end) return `de ${fmt(start)}`; + return `${fmt(start)} → ${fmt(end)}`; +} + +interface DatesPopoverProps { + subtask: Task['subtasks'][number]; + task: Task; + onUpdate?: (task: Task, subtaskId: string, start: string, end: string) => Promise; +} + +export function DatesPopover({ subtask, task, onUpdate }: DatesPopoverProps) { + const { open, setOpen, pos, anchorRef } = usePopover(); + const [start, setStart] = useState(subtask.start ?? ''); + const [end, setEnd] = useState(subtask.end ?? ''); + const [pending, setPending] = useState(false); + + function openPopover(e: React.MouseEvent) { + e.stopPropagation(); + if (!onUpdate) return; + setStart(subtask.start ?? ''); + setEnd(subtask.end ?? ''); + setOpen(v => !v); + } + + async function save() { + if (!onUpdate || pending) return; + setPending(true); + const ok = await onUpdate(task, subtask.id, start, end); + setPending(false); + if (ok) setOpen(false); + } + + const label = formatDateRange(subtask.start ?? '', subtask.end ?? ''); + + return ( +
+ + + {open && createPortal( +
e.stopPropagation()} + > +

Período

+
+
+ + setStart(e.target.value)} + className="w-full text-xs border border-border rounded-md px-2 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ + setEnd(e.target.value)} + className="w-full text-xs border border-border rounded-md px-2 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> +
+
+
+ + +
+
, + document.body + )} +
+ ); +} diff --git a/src/views/planning/components/MemberAvatars.tsx b/src/views/planning/components/MemberAvatars.tsx new file mode 100644 index 0000000..52101e2 --- /dev/null +++ b/src/views/planning/components/MemberAvatars.tsx @@ -0,0 +1,45 @@ +import type { Member } from '@/hooks/infra/useSupabase'; + +export function MemberAvatars({ assigneeIds, members, size = 'sm' }: { + assigneeIds: string[]; + members: Member[]; + size?: 'sm' | 'xs'; +}) { + const dim = size === 'xs' ? 'w-5 h-5 text-[9px]' : 'w-6 h-6 text-[10px]'; + const resolved = assigneeIds + .map(id => members.find(m => m.id === id)) + .filter((m): m is Member => m !== undefined); + + if (resolved.length === 0) { + return ( +
+ ? +
+ ); + } + + return ( +
+ {resolved.slice(0, 3).map(m => ( +
+ {m.avatar_url + ? {m.name} + : m.name.slice(0, 2).toUpperCase() + } +
+ ))} + {resolved.length > 3 && ( +
+ +{resolved.length - 3} +
+ )} +
+ ); +} diff --git a/src/views/planning/components/SubtaskRow.tsx b/src/views/planning/components/SubtaskRow.tsx new file mode 100644 index 0000000..a888545 --- /dev/null +++ b/src/views/planning/components/SubtaskRow.tsx @@ -0,0 +1,97 @@ +import { memo } from 'react'; +import { AlertCircle, Circle, CheckCircle2, PlayCircle, AlertTriangle } from 'lucide-react'; +import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; +import type { Member } from '@/hooks/infra/useSupabase'; +import { formatDueDate } from '../utils'; +import { DatesPopover } from './DatesPopover'; +import { AssigneesPopover } from './AssigneesPopover'; + +function SubtaskStatusIcon({ subtask, isConcluded, isBlocked }: { + subtask: Task['subtasks'][number]; + isConcluded: boolean; + isBlocked: boolean; +}) { + if (isConcluded) return ; + if (isBlocked && subtask.active) return ; + if (subtask.active) { + const timeStatus = formatDueDate(subtask.end); + if (timeStatus?.label.startsWith('Atrasada')) return ; + return ; + } + return ; +} + +interface SubtaskRowProps { + subtask: Task['subtasks'][number]; + task: Task; + members: Member[]; + isLast: boolean; + onEdit: (task: Task) => void; + onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; + onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; +} + +export const SubtaskRow = memo(function SubtaskRow({ + subtask, + task, + members, + isLast, + onEdit, + onUpdateSubtaskAssignees, + onUpdateSubtaskDates, +}: SubtaskRowProps) { + const meta = STEP_META[subtask.status as SubtaskStatus]; + const timeStatus = formatDueDate(subtask.end); + const isBlocked = task.status.blocked; + const isConcluded = !!task.concludedAt; + + return ( +
onEdit(task)} + > +
+ +
+ + + {meta.label} + +
+ + + {subtask.title} + + +
e.stopPropagation()}> + +
+ +
+ {timeStatus ? ( + + {timeStatus.label} + + ) : ( + — + )} +
+ +
e.stopPropagation()}> + +
+
+ ); +}); diff --git a/src/views/planning/components/TaskTable.tsx b/src/views/planning/components/TaskTable.tsx index 12f4c12..1860270 100644 --- a/src/views/planning/components/TaskTable.tsx +++ b/src/views/planning/components/TaskTable.tsx @@ -1,9 +1,7 @@ -import { memo, useState } from 'react'; -import { ChevronDown, ChevronRight, Link2, AlertCircle, Circle, CheckCircle2 } from 'lucide-react'; -import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; +import { memo } from 'react'; +import type { Task } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; -import { formatDueDate } from '../utils'; -import { ActionMenu } from './ActionMenu'; +import { TaskTableRow } from './TaskTableRow'; interface TaskTableProps { tasks: Task[]; @@ -11,305 +9,23 @@ interface TaskTableProps { onToggleBlock: (task: Task) => void; onConclude: (task: Task) => void; onEdit: (task: Task) => void; + onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; + onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; } -function MemberAvatars({ assigneeIds, members, size = 'sm' }: { - assigneeIds: string[]; - members: Member[]; - size?: 'sm' | 'xs'; -}) { - const dim = size === 'xs' ? 'w-5 h-5 text-[9px]' : 'w-6 h-6 text-[10px]'; - const resolved = assigneeIds - .map(id => members.find(m => m.id === id)) - .filter((m): m is Member => m !== undefined); - - if (resolved.length === 0) { - return ( -
- ? -
- ); - } - - return ( -
- {resolved.slice(0, 3).map(m => ( -
- {m.avatar_url - ? {m.name} - : m.name.slice(0, 2).toUpperCase() - } -
- ))} - {resolved.length > 3 && ( -
- +{resolved.length - 3} -
- )} -
- ); -} - -function formatDateRange(start: string, end: string): string { - const fmt = (d: string) => { - if (!d) return '—'; - const [, m, day] = d.split('-'); - return `${day}/${m}`; - }; - if (!start && !end) return '—'; - if (!start) return `até ${fmt(end)}`; - if (!end) return `de ${fmt(start)}`; - return `${fmt(start)} → ${fmt(end)}`; -} - -const SubtaskRow = memo(function SubtaskRow({ - subtask, - task, - members, - isLast, - onEdit, -}: { - subtask: Task['subtasks'][number]; - task: Task; - members: Member[]; - isLast: boolean; - onEdit: (task: Task) => void; -}) { - const meta = STEP_META[subtask.status as SubtaskStatus]; - const timeStatus = formatDueDate(subtask.end); - const isBlocked = task.status.blocked; - const isConcluded = !!task.concludedAt; - - return ( -
onEdit(task)} - > - {/* Indicador de fase */} -
-
- - {meta.label} - - {subtask.active && !isConcluded && ( - - - ativa - - )} -
- - {/* Título da subtask */} - - {subtask.title} - - - {/* Datas */} - - {formatDateRange(subtask.start, subtask.end)} - - - {/* Badge de prazo */} -
- {timeStatus && subtask.active && ( - - {timeStatus.label} - - )} -
- - {/* Responsáveis */} -
- -
-
- ); -}); - -const TaskTableRow = memo(function TaskTableRow({ - task, +export const TaskTable = memo(function TaskTable({ + tasks, members, onToggleBlock, onConclude, onEdit, - defaultExpanded, -}: { - task: Task; - members: Member[]; - onToggleBlock: (task: Task) => void; - onConclude: (task: Task) => void; - onEdit: (task: Task) => void; - defaultExpanded: boolean; -}) { - const [expanded, setExpanded] = useState(defaultExpanded); - const isBlocked = task.status.blocked; - const isConcluded = !!task.concludedAt; - - const activeSubtask = task.subtasks.find(s => s.active); - const allAssigneeIds = [...new Set(task.subtasks.flatMap(s => s.assignees))]; - - const taskTimeStatus = formatDueDate(activeSubtask?.end); - - return ( -
- {/* Linha da task (pai) */} -
task.subtasks.length > 0 && setExpanded(e => !e)} - > - {/* Borda lateral colorida */} -
- - {/* Expand/collapse */} - - - {/* Título */} -
{ e.stopPropagation(); onEdit(task); }} - > - - {task.title} - - - {task.clickupLink && ( - e.stopPropagation()} - title="Abrir no ClickUp" - className="text-muted-foreground hover:text-primary transition-colors shrink-0" - > - - - )} - - {isBlocked && ( - - - Bloqueada - - )} - {isConcluded && ( - - - Concluída - - )} -
- - {/* Meta-info da task: subtask count + prazo + responsáveis */} -
- {task.subtasks.length > 0 && ( - - {task.subtasks.length} etapa{task.subtasks.length !== 1 ? 's' : ''} - - )} - - {taskTimeStatus && !isConcluded && ( - - {taskTimeStatus.label} - - )} - - - -
e.stopPropagation()}> - -
-
-
- - {/* Linhas das subtasks */} - {expanded && task.subtasks.length > 0 && ( -
- {task.subtasks.map((subtask, idx) => ( - - ))} -
- )} -
- ); -}, (prev, next) => - prev.task.id === next.task.id && - prev.task.status?.blocked === next.task.status?.blocked && - prev.task.concludedAt === next.task.concludedAt && - prev.task.subtasks?.length === next.task.subtasks?.length && - prev.members.length === next.members.length && - prev.defaultExpanded === next.defaultExpanded -); - -export const TaskTable = memo(function TaskTable({ tasks, members, onToggleBlock, onConclude, onEdit }: TaskTableProps) { + onUpdateSubtaskAssignees, + onUpdateSubtaskDates, +}: TaskTableProps) { if (tasks.length === 0) return null; - const hasActiveFilters = tasks.length > 0; - return (
- {/* Cabeçalho de colunas (apenas visual, alinhado com as subtask rows) */} -
- Etapa - Título - Período - Prazo - Resp. -
- {tasks.map(task => ( 0} + onUpdateSubtaskAssignees={onUpdateSubtaskAssignees} + onUpdateSubtaskDates={onUpdateSubtaskDates} /> ))}
diff --git a/src/views/planning/components/TaskTableRow.tsx b/src/views/planning/components/TaskTableRow.tsx new file mode 100644 index 0000000..a6ab04e --- /dev/null +++ b/src/views/planning/components/TaskTableRow.tsx @@ -0,0 +1,188 @@ +import { memo, useState } from 'react'; +import { ChevronDown, ChevronRight, Link2, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; +import type { Member } from '@/hooks/infra/useSupabase'; +import { formatDueDate } from '../utils'; +import { ActionMenu } from './ActionMenu'; +import { MemberAvatars } from './MemberAvatars'; +import { SubtaskRow } from './SubtaskRow'; + +interface TaskTableRowProps { + task: Task; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; + defaultExpanded: boolean; + onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; + onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; +} + +export const TaskTableRow = memo(function TaskTableRow({ + task, + members, + onToggleBlock, + onConclude, + onEdit, + defaultExpanded, + onUpdateSubtaskAssignees, + onUpdateSubtaskDates, +}: TaskTableRowProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const isBlocked = task.status.blocked; + const isConcluded = !!task.concludedAt; + + const activeSubtask = task.subtasks.find(s => s.active); + const allAssigneeIds = [...new Set(task.subtasks.flatMap(s => s.assignees))]; + const taskTimeStatus = formatDueDate(activeSubtask?.end); + const completedCount = task.subtasks.filter(s => !s.active && s.end).length; + const progress = task.subtasks.length > 0 + ? isConcluded ? 100 : Math.round((completedCount / task.subtasks.length) * 100) + : 0; + + return ( +
+
task.subtasks.length > 0 && setExpanded(e => !e)} + > +
+ + + +
{ e.stopPropagation(); onEdit(task); }} + > + + {task.title} + + + {task.clickupLink && ( + e.stopPropagation()} + title="Abrir no ClickUp" + className="text-muted-foreground hover:text-primary transition-colors shrink-0" + > + + + )} + + {isBlocked && ( + + + Bloqueada + + )} + {isConcluded && ( + + + Concluída + + )} +
+ +
+ {task.subtasks.length > 0 && ( +
+
+
+
+ {progress}% +
+ )} + + {task.subtasks.length > 0 && ( + + {task.subtasks.length} etapa{task.subtasks.length !== 1 ? 's' : ''} + + )} + + {taskTimeStatus && !isConcluded && ( + + {taskTimeStatus.label} + + )} + + + +
e.stopPropagation()}> + +
+
+
+ + {expanded && task.subtasks.length > 0 && ( +
+
+
+ Etapa + Título + Período + Prazo + Resp. +
+ +
+
+ {task.subtasks.map((subtask, idx) => ( + + ))} +
+
+ )} +
+ ); +}, (prev, next) => + prev.task === next.task && + prev.members === next.members && + prev.defaultExpanded === next.defaultExpanded +); diff --git a/src/views/planning/components/usePopover.ts b/src/views/planning/components/usePopover.ts new file mode 100644 index 0000000..cff9d02 --- /dev/null +++ b/src/views/planning/components/usePopover.ts @@ -0,0 +1,58 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +export interface PopoverPosition { + top: number; + left: number; + width: number; +} + +interface UsePopoverOptions { + align?: 'start' | 'end'; + contentWidth?: number; +} + +const VIEWPORT_PADDING = 8; + +export function usePopover({ align = 'start', contentWidth = 0 }: UsePopoverOptions = {}) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }); + const anchorRef = useRef(null); + + const recalc = useCallback(() => { + if (!anchorRef.current) return; + const r = anchorRef.current.getBoundingClientRect(); + const rawLeft = align === 'end' && contentWidth > 0 + ? r.right + window.scrollX - contentWidth + : r.left + window.scrollX; + const maxLeft = window.scrollX + window.innerWidth - contentWidth - VIEWPORT_PADDING; + const left = contentWidth > 0 + ? Math.min(Math.max(rawLeft, window.scrollX + VIEWPORT_PADDING), maxLeft) + : rawLeft; + + setPos({ top: r.bottom + window.scrollY + 4, left, width: r.width }); + }, [align, contentWidth]); + + useEffect(() => { + if (!open) return; + recalc(); + function onMouseDown(e: MouseEvent) { + const target = e.target as Node; + if (anchorRef.current && anchorRef.current.contains(target)) return; + // portal content has data-popover attribute + const portal = document.querySelector('[data-popover]'); + if (portal && portal.contains(target)) return; + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('keydown', onKey); + }; + }, [open, recalc]); + + return { open, setOpen, pos, anchorRef }; +} From faad5c826b436b4bdaae77cbb0288ca612e8b6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 12 May 2026 14:32:51 -0300 Subject: [PATCH 05/12] feat: :sparkles: adicionado prioridade nas tasks --- docs/decisions.md | 10 +- docs/views/planning.md | 1 + src/components/AppModals.tsx | 3 +- src/hooks/infra/useSupabase.ts | 16 +- src/hooks/tasks/useTaskActions.ts | 5 +- src/hooks/tasks/useTaskPriorityOrder.ts | 58 ++++++ src/hooks/tasks/useTaskQuickActions.ts | 40 ++++ src/lib/queries.ts | 5 +- src/lib/steps.ts | 1 + src/lib/validators.ts | 1 + src/types/db.ts | 1 + src/types/props.ts | 4 +- src/types/supabase.ts | 3 + src/views/planning/PlanningView.tsx | 36 +++- src/views/planning/components/TaskList.tsx | 6 +- src/views/planning/components/TaskTable.tsx | 173 +++++++++++++++++- .../planning/components/TaskTableRow.tsx | 69 ++++++- .../components/__tests__/TaskTable.test.tsx | 56 ++++++ .../20260512000001_task_priority_order.sql | 22 +++ 19 files changed, 491 insertions(+), 19 deletions(-) create mode 100644 src/hooks/tasks/useTaskPriorityOrder.ts create mode 100644 src/views/planning/components/__tests__/TaskTable.test.tsx create mode 100644 supabase/migrations/20260512000001_task_priority_order.sql diff --git a/docs/decisions.md b/docs/decisions.md index 4f97554..2494e18 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -171,5 +171,13 @@ Registro de decisões arquiteturais significativas do projeto Run/Way. **Status:** Aceito (Mai 2026) **Decisão:** o modelo de `Step` (8 tipos fixos por task, identidade = tipo) foi substituído por `Subtask` (N subtasks livres por task, identidade = `id`, tipo expresso como campo `status`). Tabelas: `task_subtasks` + `subtask_assignees` (substituem `task_steps` + `step_assignees`). Tipo domínio: `Subtask` com campos `id, title, status: SubtaskStatus, start, end, assignees, active, order`. `StepType` passou a ser alias de `SubtaskStatus` para compatibilidade temporária. **Racional:** o modelo fixo de 8 steps impedia nomear etapas de forma contextual (ex: "Homepage — Design" vs "Design"); uma demanda pode ter múltiplas subtasks do mesmo tipo em paralelo; a flexibilidade de N subtasks livres é mais adequada a diferentes tipos de projeto -**Consequências:** nova task nasce sem subtasks — usuário adiciona livremente via TaskModal; dados existentes migrados automaticamente (`title = type`, `status = type`); `Planning View` exibe a demanda em todos os grupos onde tiver subtask ativa (não apenas o grupo "atual"); drag/drop no Calendar e Timeline indexado por `subtaskId` em vez de `stepType`; `task_steps` e `step_assignees` mantidas no banco para rollback até a migration de drop (`20260512000001`) +**Consequências:** nova task nasce sem subtasks — usuário adiciona livremente via TaskModal; dados existentes migrados automaticamente (`title = type`, `status = type`); `Planning View` exibe a demanda em todos os grupos onde tiver subtask ativa (não apenas o grupo "atual"); drag/drop no Calendar e Timeline indexado por `subtaskId` em vez de `stepType`; `task_steps` e `step_assignees` mantidas no banco para rollback até uma migration de drop futura +--- + +## ADR-020: Prioridade manual das demandas-pai + +**Status:** Aceito (Mai 2026) +**Decisão:** demandas têm `priority_order` persistido na tabela `tasks`; a subview `Demandas` ordena por esse campo e permite reordenar linhas-pai via drag-and-drop quando não há filtros ativos. +**Racional:** prioridade manual é uma decisão de planejamento da lista, não derivada apenas do prazo da subtask ativa; persistir a ordem no banco garante consistência entre sessões e usuários. +**Consequências:** novas demandas entram no fim da fila do cliente; reordenação aplica update otimista no cache TanStack Query e grava os novos índices no Supabase; filtros desativam o drag para evitar gravar uma ordem parcial acidental. diff --git a/docs/views/planning.md b/docs/views/planning.md index f17279e..d33794a 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -101,6 +101,7 @@ Ambos fecham ao clicar fora ou pressionar `Escape` (hook `usePopover`). Renderiz **Comportamentos:** - Tasks expandidas por padrão quando há filtros ativos +- Reordenação manual das linhas-pai por drag-and-drop quando não há filtros ativos; a ordem persiste em `tasks.priority_order` - Tasks bloqueadas: fundo vermelho sutil em task e subtasks - Tasks concluídas: opacidade reduzida, título riscado - Subtasks sem datas mostram `—` no campo Período diff --git a/src/components/AppModals.tsx b/src/components/AppModals.tsx index 2b435d0..5525605 100644 --- a/src/components/AppModals.tsx +++ b/src/components/AppModals.tsx @@ -4,12 +4,13 @@ import { ClientTransitionOverlay } from "@/components/ClientTransitionOverlay"; import type { Task } from "@/types/task"; import type { Member } from "@/types/member"; import type { Holiday } from "@/utils/holidayUtils"; +import type { TaskModalPayload } from "@/types/props"; interface TaskModalState { isOpen: boolean; editingTask: Task | null; onClose: () => void; - onSave: (taskData: Omit) => Promise; + onSave: (taskData: TaskModalPayload) => Promise; onDelete: (id: string) => void; } diff --git a/src/hooks/infra/useSupabase.ts b/src/hooks/infra/useSupabase.ts index 993e6cd..1d93951 100644 --- a/src/hooks/infra/useSupabase.ts +++ b/src/hooks/infra/useSupabase.ts @@ -81,6 +81,7 @@ function didTaskFieldsChange(prevTask: Task | undefined, nextTask: Task, resolve return ( prevTask.title !== nextTask.title || (prevTask.clickupLink ?? null) !== (nextTask.clickupLink ?? null) + || prevTask.priorityOrder !== nextTask.priorityOrder || prevTask.status.blocked !== nextTask.status.blocked || (prevTask.status.blockedAt ?? null) !== (nextTask.status.blockedAt ?? null) || (prevTask.concludedAt ?? null) !== (nextTask.concludedAt ?? null) @@ -98,15 +99,27 @@ export function useSupabase(options: UseSupabaseOptions = {}) { queryClient.invalidateQueries({ queryKey: queryKeys.tasks(clientId ?? null, isAdmin ?? false) }) }, [queryClient, clientId, isAdmin]) - const createTask = useCallback(async (taskData: Omit): Promise => { + const createTask = useCallback(async (taskData: Omit): Promise => { const resolvedClientId = taskData.clientId ?? clientId ?? null devLog('[createTask] iniciando criação, clientId:', resolvedClientId, 'title:', taskData.title) + const priorityQuery = supabase + .from('tasks') + .select('priority_order') + .order('priority_order', { ascending: false }) + .limit(1) + + const { data: lastTask } = resolvedClientId === null + ? await priorityQuery.is('client_id', null) + : await priorityQuery.eq('client_id', resolvedClientId) + const priorityOrder = (lastTask?.[0]?.priority_order ?? -1) + 1 + const { data: taskRow, error: taskErr } = await supabase .from('tasks') .insert({ title: taskData.title, clickup_link: taskData.clickupLink ?? null, + priority_order: priorityOrder, blocked: taskData.status.blocked, blocked_at: taskData.status.blockedAt ?? null, client_id: resolvedClientId, @@ -175,6 +188,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { .update({ title: taskData.title, clickup_link: taskData.clickupLink ?? null, + priority_order: taskData.priorityOrder, blocked: taskData.status.blocked, blocked_at: taskData.status.blockedAt ?? null, concluded_at: taskData.concludedAt ?? null, diff --git a/src/hooks/tasks/useTaskActions.ts b/src/hooks/tasks/useTaskActions.ts index 452586e..eec6435 100644 --- a/src/hooks/tasks/useTaskActions.ts +++ b/src/hooks/tasks/useTaskActions.ts @@ -2,9 +2,10 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; import { useUIStore } from "@/store/useUIStore"; import type { Task } from "@/lib/steps"; +import type { TaskModalPayload } from "@/types/props"; interface UseTaskActionsParams { - createTask: (data: Omit) => Promise; + createTask: (data: Omit) => Promise; updateTask: (task: Task) => Promise; deleteTask: (id: string) => Promise; effectiveClientId: string | null | undefined; @@ -65,7 +66,7 @@ export function useTaskActions({ if (shouldCloseModal) closeTaskModal(); }, [pendingDeleteTaskId, closeModalOnDelete, deleteTask, closeTaskModal]); - const saveTask = useCallback(async (taskData: Omit) => { + const saveTask = useCallback(async (taskData: TaskModalPayload) => { let ok: boolean; if (editingTask) { diff --git a/src/hooks/tasks/useTaskPriorityOrder.ts b/src/hooks/tasks/useTaskPriorityOrder.ts new file mode 100644 index 0000000..221e3c0 --- /dev/null +++ b/src/hooks/tasks/useTaskPriorityOrder.ts @@ -0,0 +1,58 @@ +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { supabase } from '@/lib/supabase' +import { queryKeys } from '@/lib/queries' +import { useTaskStore } from '@/store/useTaskStore' +import type { Task } from '@/lib/steps' + +interface UseTaskPriorityOrderOptions { + clientId?: string | null + isAdmin?: boolean +} + +export function useTaskPriorityOrder({ clientId, isAdmin }: UseTaskPriorityOrderOptions = {}) { + const queryClient = useQueryClient() + + return useCallback(async (orderedTasks: Task[]): Promise => { + const key = queryKeys.tasks(clientId ?? null, isAdmin ?? false) + const cachedTasks = queryClient.getQueryData(key) ?? [] + const orderedIds = new Set(orderedTasks.map(task => task.id)) + const hiddenTasks = cachedTasks + .filter(task => !orderedIds.has(task.id)) + .sort((a, b) => a.priorityOrder - b.priorityOrder || b.createdAt.localeCompare(a.createdAt)) + const allTasksInNextOrder = [...orderedTasks, ...hiddenTasks] + const orderById = new Map(allTasksInNextOrder.map((task, index) => [task.id, index])) + + const nextTasks = cachedTasks + .map(task => { + const nextOrder = orderById.get(task.id) + return nextOrder === undefined ? task : { ...task, priorityOrder: nextOrder } + }) + .sort((a, b) => a.priorityOrder - b.priorityOrder || b.createdAt.localeCompare(a.createdAt)) + + queryClient.setQueryData(key, nextTasks) + useTaskStore.getState().applyOptimisticUpdate(nextTasks) + + const updates = await Promise.all( + allTasksInNextOrder.map((task, index) => + supabase + .from('tasks') + .update({ priority_order: index }) + .eq('id', task.id), + ), + ) + const error = updates.find(result => result.error)?.error + + if (error) { + queryClient.setQueryData(key, cachedTasks) + useTaskStore.getState().clearOptimistic() + toast.error('Erro ao reordenar demandas') + return false + } + + useTaskStore.getState().clearOptimistic() + toast.success('Prioridade das demandas atualizada') + return true + }, [clientId, isAdmin, queryClient]) +} diff --git a/src/hooks/tasks/useTaskQuickActions.ts b/src/hooks/tasks/useTaskQuickActions.ts index 4563ab0..979e2ed 100644 --- a/src/hooks/tasks/useTaskQuickActions.ts +++ b/src/hooks/tasks/useTaskQuickActions.ts @@ -45,8 +45,48 @@ export function useTaskQuickActions(memberId?: string | null) { return true }, [invalidate]) + const concludeTasks = useCallback(async (tasks: Task[]) => { + const pendingTasks = tasks.filter(task => !task.concludedAt) + if (pendingTasks.length === 0) { + toast.info('As demandas selecionadas já estão concluídas') + return false + } + + const now = new Date().toISOString() + const { error } = await supabase + .from('tasks') + .update({ concluded_at: now, concluded_by: memberId ?? null }) + .in('id', pendingTasks.map(task => task.id)) + + if (error) { toast.error('Erro ao concluir demandas'); return false } + toast.success(`${pendingTasks.length} demanda${pendingTasks.length !== 1 ? 's' : ''} concluída${pendingTasks.length !== 1 ? 's' : ''}`) + invalidate() + return true + }, [memberId, invalidate]) + + const blockTasks = useCallback(async (tasks: Task[]) => { + const unblockedTasks = tasks.filter(task => !task.status.blocked) + if (unblockedTasks.length === 0) { + toast.info('As demandas selecionadas já estão bloqueadas') + return false + } + + const now = new Date().toISOString().split('T')[0] + const { error } = await supabase + .from('tasks') + .update({ blocked: true, blocked_at: now }) + .in('id', unblockedTasks.map(task => task.id)) + + if (error) { toast.error('Erro ao bloquear demandas'); return false } + toast.success(`${unblockedTasks.length} demanda${unblockedTasks.length !== 1 ? 's' : ''} bloqueada${unblockedTasks.length !== 1 ? 's' : ''}`) + invalidate() + return true + }, [invalidate]) + return { concludeTask: useThrottledMutation(concludeTask, THROTTLE_MS), toggleBlock: useThrottledMutation(toggleBlock, THROTTLE_MS), + concludeTasks, + blockTasks, } } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 45abc84..74f2492 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -35,6 +35,7 @@ function dbRowToTask(row: DbTaskRow): Task { title: parsed.title, clickupLink: parsed.clickup_link ?? undefined, clientId: parsed.client_id ?? undefined, + priorityOrder: parsed.priority_order, status: { blocked: parsed.blocked, blockedAt: parsed.blocked_at ?? undefined, @@ -47,7 +48,7 @@ function dbRowToTask(row: DbTaskRow): Task { } const TASK_SELECT = ` - id, title, clickup_link, blocked, blocked_at, created_at, client_id, concluded_at, concluded_by, + id, title, clickup_link, priority_order, blocked, blocked_at, created_at, client_id, concluded_at, concluded_by, task_subtasks ( id, title, status, subtask_order, active, start_date, end_date, subtask_assignees ( member_id ) @@ -63,6 +64,7 @@ export async function fetchTasksFromDb( const { data, error } = await supabase .from('tasks') .select(TASK_SELECT) + .order('priority_order', { ascending: true }) .order('created_at', { ascending: false }) if (error) throw new Error(error.message) return (data ?? []).map(dbRowToTask) @@ -72,6 +74,7 @@ export async function fetchTasksFromDb( .from('tasks') .select(TASK_SELECT) .eq('client_id', clientId) + .order('priority_order', { ascending: true }) .order('created_at', { ascending: false }) if (error) throw new Error(error.message) diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 0fde917..148772f 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -39,6 +39,7 @@ export interface Task { title: string; clickupLink?: string; clientId?: string; + priorityOrder: number; status: TaskStatus; subtasks: Subtask[]; createdAt: string; diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 958542e..1322dab 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -21,6 +21,7 @@ export const DbTaskRowSchema = z.object({ id: z.string().min(1), title: z.string().min(1), clickup_link: z.string().nullable().transform(v => v === '' ? null : v), + priority_order: z.number().int().nonnegative().default(0), blocked: z.boolean(), blocked_at: z.string().nullable(), created_at: z.string(), diff --git a/src/types/db.ts b/src/types/db.ts index 1cd7b42..7737148 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -57,6 +57,7 @@ export interface DbTaskRow { id: string; title: string; clickup_link: string | null; + priority_order: number; blocked: boolean; blocked_at: string | null; created_at: string; diff --git a/src/types/props.ts b/src/types/props.ts index 6f3d3c0..f324e3a 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -10,6 +10,8 @@ import type { Member } from '@/hooks/infra/useSupabase'; import type { Holiday } from '../utils/holidayUtils'; import type { CalendarViewMode } from '../utils/dashboardUtils'; +export type TaskModalPayload = Omit & Partial>; + // ─── TaskModal ──────────────────────────────────────────────────────────────── export interface TaskModalProps { @@ -18,7 +20,7 @@ export interface TaskModalProps { members: Member[]; onClose: () => void; /** Chamado com os dados prontos a guardar (sem `id` / `createdAt` se nova). */ - onSave: (taskData: Omit) => Promise; + onSave: (taskData: TaskModalPayload) => Promise; /** Chamado com o id da tarefa a eliminar; omitido no modal de criação. */ onDelete?: (id: string) => void; holidays: Holiday[]; diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 171ae50..30009ac 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -269,6 +269,7 @@ export type Database = { concluded_by: string | null created_at: string id: string + priority_order: number title: string } Insert: { @@ -280,6 +281,7 @@ export type Database = { concluded_by?: string | null created_at?: string id?: string + priority_order?: number title: string } Update: { @@ -291,6 +293,7 @@ export type Database = { concluded_by?: string | null created_at?: string id?: string + priority_order?: number title?: string } Relationships: [ diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index ed2e91c..491043b 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs'; import { useTasksQuery } from '@/hooks/tasks/useTasksQuery'; import { useMembersQuery } from '@/hooks/members/useMembersQuery'; @@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '@/contexts/AuthContext'; import { useClients } from '@/hooks/clients/useClients'; import { useTaskQuickActions } from '@/hooks/tasks/useTaskQuickActions'; +import { useTaskPriorityOrder } from '@/hooks/tasks/useTaskPriorityOrder'; import { useSubtaskQuickEdit } from '@/hooks/tasks/useSubtaskQuickEdit'; import { CalendarView } from '@/views/calendar'; import TimelineView from '@/views/timeline'; @@ -21,6 +22,7 @@ import { ViewState } from '@/components/ViewState'; import { CalendarX2, DatabaseZap, FilterX, Search, Plus } from 'lucide-react'; import { Skeleton } from 'boneyard-js/react'; import { Button } from '@/components/ui/Button'; +import { toast } from 'sonner'; const PLANNING_BONES = { name: 'planning-view', @@ -68,7 +70,11 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd const tasksError = tasksErr?.message ?? null; const membersError = membersErr?.message ?? null; - const { concludeTask, toggleBlock } = useTaskQuickActions(member?.auth_user_id); + const { concludeTask, toggleBlock, concludeTasks, blockTasks } = useTaskQuickActions(member?.auth_user_id); + const updateTaskPriorityOrder = useTaskPriorityOrder({ + clientId: effectiveClientId, + isAdmin, + }); const { updateSubtaskAssignees, updateSubtaskDates } = useSubtaskQuickEdit({ clientId: effectiveClientId, isAdmin, @@ -87,6 +93,28 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd const [demandasFilters, setDemandasFilters] = useState(EMPTY_FILTERS); + const handleBulkAssign = useCallback(async (selectedTasks: typeof tasks, memberId: string) => { + const tasksWithActiveSubtask = selectedTasks + .map(task => ({ task, subtask: task.subtasks.find(subtask => subtask.active) ?? task.subtasks[0] })) + .filter(({ subtask }) => !!subtask); + + if (tasksWithActiveSubtask.length === 0) { + toast.info('As demandas selecionadas não têm etapas para atribuir'); + return false; + } + + for (const { task, subtask } of tasksWithActiveSubtask) { + const nextAssignees = subtask.assignees.includes(memberId) + ? subtask.assignees + : [...subtask.assignees, memberId]; + const success = await updateSubtaskAssignees(task, subtask.id, nextAssignees); + if (!success) return false; + } + + toast.success(`${tasksWithActiveSubtask.length} demanda${tasksWithActiveSubtask.length !== 1 ? 's' : ''} atribuída${tasksWithActiveSubtask.length !== 1 ? 's' : ''}`); + return true; + }, [updateSubtaskAssignees]); + useEffect(() => { if (!dashboardRedirect) return; clearDashboardRedirect(); @@ -263,6 +291,10 @@ const hasDemandasActiveFilters = onToggleBlock={toggleBlock} onConclude={concludeTask} onEdit={onEdit} + onReorder={!hasDemandasActiveFilters ? updateTaskPriorityOrder : undefined} + onBulkAssign={handleBulkAssign} + onBulkBlock={blockTasks} + onBulkConclude={concludeTasks} onUpdateSubtaskAssignees={updateSubtaskAssignees} onUpdateSubtaskDates={updateSubtaskDates} /> diff --git a/src/views/planning/components/TaskList.tsx b/src/views/planning/components/TaskList.tsx index f1cabd7..34486a0 100644 --- a/src/views/planning/components/TaskList.tsx +++ b/src/views/planning/components/TaskList.tsx @@ -13,12 +13,10 @@ interface TaskListProps { onEdit: (task: Task) => void; } -const SubtaskChip = memo(function SubtaskChip({ subtask, task, members, onToggleBlock, onConclude, onEdit }: { +const SubtaskChip = memo(function SubtaskChip({ subtask, task, members, onEdit }: { subtask: Task['subtasks'][number]; task: Task; members: Member[]; - onToggleBlock: (task: Task) => void; - onConclude: (task: Task) => void; onEdit: (task: Task) => void; }) { const meta = STEP_META[subtask.status]; @@ -163,8 +161,6 @@ export const TaskList = memo(function TaskList({ tasks, members, onToggleBlock, subtask={subtask} task={task} members={members} - onToggleBlock={onToggleBlock} - onConclude={onConclude} onEdit={onEdit} /> ))} diff --git a/src/views/planning/components/TaskTable.tsx b/src/views/planning/components/TaskTable.tsx index 1860270..48ee72d 100644 --- a/src/views/planning/components/TaskTable.tsx +++ b/src/views/planning/components/TaskTable.tsx @@ -1,6 +1,8 @@ -import { memo } from 'react'; +import { memo, useMemo, useState } from 'react'; +import { Ban, CheckCircle2, UserPlus, X } from 'lucide-react'; import type { Task } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; +import { Button } from '@/components/ui/Button'; import { TaskTableRow } from './TaskTableRow'; interface TaskTableProps { @@ -9,6 +11,10 @@ interface TaskTableProps { onToggleBlock: (task: Task) => void; onConclude: (task: Task) => void; onEdit: (task: Task) => void; + onBulkAssign?: (tasks: Task[], memberId: string) => Promise; + onBulkBlock?: (tasks: Task[]) => Promise; + onBulkConclude?: (tasks: Task[]) => Promise; + onReorder?: (tasks: Task[]) => Promise; onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; } @@ -19,13 +25,79 @@ export const TaskTable = memo(function TaskTable({ onToggleBlock, onConclude, onEdit, + onBulkAssign, + onBulkBlock, + onBulkConclude, + onReorder, onUpdateSubtaskAssignees, onUpdateSubtaskDates, }: TaskTableProps) { + const [selectedIds, setSelectedIds] = useState([]); + const [assignOpen, setAssignOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<'assign' | 'block' | 'conclude' | null>(null); + const [dragTaskId, setDragTaskId] = useState(null); + const [dragOverTaskId, setDragOverTaskId] = useState(null); + + const selectedTasks = useMemo( + () => tasks.filter(task => selectedIds.includes(task.id)), + [tasks, selectedIds], + ); + const selectedCount = selectedTasks.length; + if (tasks.length === 0) return null; + function toggleSelection(taskId: string, selected: boolean) { + setSelectedIds(prev => { + if (selected) return prev.includes(taskId) ? prev : [...prev, taskId]; + return prev.filter(id => id !== taskId); + }); + } + + async function runBulkAction(action: 'block' | 'conclude', handler?: (tasks: Task[]) => Promise) { + if (!handler || selectedTasks.length === 0 || pendingAction) return; + setAssignOpen(false); + setPendingAction(action); + const success = await handler(selectedTasks); + setPendingAction(null); + if (success) setSelectedIds([]); + } + + async function assignSelected(memberId: string) { + if (!onBulkAssign || selectedTasks.length === 0 || pendingAction) return; + setPendingAction('assign'); + const success = await onBulkAssign(selectedTasks, memberId); + setPendingAction(null); + if (success) { + setAssignOpen(false); + setSelectedIds([]); + } + } + + async function handleDrop(targetTaskId: string) { + if (!dragTaskId || dragTaskId === targetTaskId || !onReorder) { + setDragTaskId(null); + setDragOverTaskId(null); + return; + } + + const fromIndex = tasks.findIndex(task => task.id === dragTaskId); + const toIndex = tasks.findIndex(task => task.id === targetTaskId); + if (fromIndex < 0 || toIndex < 0) { + setDragTaskId(null); + setDragOverTaskId(null); + return; + } + + const reordered = [...tasks]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + setDragTaskId(null); + setDragOverTaskId(null); + await onReorder(reordered); + } + return ( -
+
{tasks.map(task => ( 0} + draggable={!!onReorder} + dragging={dragTaskId === task.id} + dragOver={dragOverTaskId === task.id && dragTaskId !== task.id} + onDragStart={() => setDragTaskId(task.id)} + onDragOver={() => setDragOverTaskId(task.id)} + onDrop={() => handleDrop(task.id)} + onDragEnd={() => { setDragTaskId(null); setDragOverTaskId(null); }} onUpdateSubtaskAssignees={onUpdateSubtaskAssignees} onUpdateSubtaskDates={onUpdateSubtaskDates} /> ))} + + {selectedCount > 0 && ( +
+
+
+

+ {selectedCount} demanda{selectedCount !== 1 ? 's' : ''} selecionada{selectedCount !== 1 ? 's' : ''} +

+ +
+ +
+
+ + + {assignOpen && ( +
+

+ Responsável +

+ {members.map(member => ( + + ))} + {members.length === 0 && ( +

Nenhum membro disponível

+ )} +
+ )} +
+ + + + +
+
+
+ )}
); }); diff --git a/src/views/planning/components/TaskTableRow.tsx b/src/views/planning/components/TaskTableRow.tsx index a6ab04e..04ad766 100644 --- a/src/views/planning/components/TaskTableRow.tsx +++ b/src/views/planning/components/TaskTableRow.tsx @@ -1,7 +1,8 @@ import { memo, useState } from 'react'; -import { ChevronDown, ChevronRight, Link2, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Link2, AlertCircle, CheckCircle2, GripVertical } from 'lucide-react'; import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; +import { Checkbox } from '@/components/ui/Checkbox'; import { formatDueDate } from '../utils'; import { ActionMenu } from './ActionMenu'; import { MemberAvatars } from './MemberAvatars'; @@ -13,7 +14,16 @@ interface TaskTableRowProps { onToggleBlock: (task: Task) => void; onConclude: (task: Task) => void; onEdit: (task: Task) => void; + selected: boolean; + onSelect: (taskId: string, selected: boolean) => void; defaultExpanded: boolean; + draggable?: boolean; + dragging?: boolean; + dragOver?: boolean; + onDragStart?: () => void; + onDragOver?: () => void; + onDrop?: () => void; + onDragEnd?: () => void; onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; } @@ -24,7 +34,16 @@ export const TaskTableRow = memo(function TaskTableRow({ onToggleBlock, onConclude, onEdit, + selected, + onSelect, defaultExpanded, + draggable = false, + dragging = false, + dragOver = false, + onDragStart, + onDragOver, + onDrop, + onDragEnd, onUpdateSubtaskAssignees, onUpdateSubtaskDates, }: TaskTableRowProps) { @@ -43,12 +62,24 @@ export const TaskTableRow = memo(function TaskTableRow({ return (
{ + if (!draggable) return; + e.preventDefault(); + onDragOver?.(); + }} + onDrop={e => { + if (!draggable) return; + e.preventDefault(); + onDrop?.(); + }} >
+ + +
e.stopPropagation()}> + onSelect(task.id, checked === true)} + /> +
+
diff --git a/src/views/planning/components/SubtaskProgressStatusPopover.tsx b/src/views/planning/components/SubtaskProgressStatusPopover.tsx new file mode 100644 index 0000000..0438487 --- /dev/null +++ b/src/views/planning/components/SubtaskProgressStatusPopover.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check } from 'lucide-react'; +import { + SUBTASK_PROGRESS_META, + SUBTASK_PROGRESS_STATUS_ORDER, + type SubtaskProgressStatus, + type Task, +} from '@/lib/steps'; +import { usePopover } from './usePopover'; + +interface SubtaskProgressStatusPopoverProps { + subtask: Task['subtasks'][number]; + task: Task; + onUpdate?: (task: Task, subtaskId: string, status: SubtaskProgressStatus) => Promise; +} + +export function SubtaskProgressStatusPopover({ subtask, task, onUpdate }: SubtaskProgressStatusPopoverProps) { + const { open, setOpen, pos, anchorRef } = usePopover({ contentWidth: 240 }); + const [pending, setPending] = useState(false); + const currentStatus = subtask.progressStatus ?? 'todo'; + const meta = SUBTASK_PROGRESS_META[currentStatus]; + + function openPopover(e: React.MouseEvent) { + e.stopPropagation(); + if (!onUpdate) return; + setOpen(v => !v); + } + + async function selectStatus(status: SubtaskProgressStatus) { + if (!onUpdate || pending || status === currentStatus) { + setOpen(false); + return; + } + setPending(true); + const ok = await onUpdate(task, subtask.id, status); + setPending(false); + if (ok) setOpen(false); + } + + return ( +
+ + + {open && createPortal( +
e.stopPropagation()} + > +

+ Status da subtask +

+ {SUBTASK_PROGRESS_STATUS_ORDER.map(status => { + const item = SUBTASK_PROGRESS_META[status]; + const selected = status === currentStatus; + return ( + + ); + })} +
, + document.body + )} +
+ ); +} diff --git a/src/views/planning/components/SubtaskRow.tsx b/src/views/planning/components/SubtaskRow.tsx index a888545..78fde20 100644 --- a/src/views/planning/components/SubtaskRow.tsx +++ b/src/views/planning/components/SubtaskRow.tsx @@ -1,10 +1,11 @@ import { memo } from 'react'; -import { AlertCircle, Circle, CheckCircle2, PlayCircle, AlertTriangle } from 'lucide-react'; -import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; +import { AlertCircle, Circle, CheckCircle2, PlayCircle, AlertTriangle, PauseCircle, XCircle } from 'lucide-react'; +import { STEP_META, type SubtaskProgressStatus, type Task, type SubtaskStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; import { formatDueDate } from '../utils'; import { DatesPopover } from './DatesPopover'; import { AssigneesPopover } from './AssigneesPopover'; +import { SubtaskProgressStatusPopover } from './SubtaskProgressStatusPopover'; function SubtaskStatusIcon({ subtask, isConcluded, isBlocked }: { subtask: Task['subtasks'][number]; @@ -12,6 +13,10 @@ function SubtaskStatusIcon({ subtask, isConcluded, isBlocked }: { isBlocked: boolean; }) { if (isConcluded) return ; + if (subtask.progressStatus === 'done') return ; + if (subtask.progressStatus === 'canceled') return ; + if (subtask.progressStatus === 'paused' || subtask.progressStatus === 'waiting') return ; + if (subtask.progressStatus === 'blocked') return ; if (isBlocked && subtask.active) return ; if (subtask.active) { const timeStatus = formatDueDate(subtask.end); @@ -29,6 +34,7 @@ interface SubtaskRowProps { onEdit: (task: Task) => void; onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; + onUpdateSubtaskProgressStatus?: (task: Task, subtaskId: string, status: SubtaskProgressStatus) => Promise; } export const SubtaskRow = memo(function SubtaskRow({ @@ -39,6 +45,7 @@ export const SubtaskRow = memo(function SubtaskRow({ onEdit, onUpdateSubtaskAssignees, onUpdateSubtaskDates, + onUpdateSubtaskProgressStatus, }: SubtaskRowProps) { const meta = STEP_META[subtask.status as SubtaskStatus]; const timeStatus = formatDueDate(subtask.end); @@ -47,7 +54,7 @@ export const SubtaskRow = memo(function SubtaskRow({ return (
+
e.stopPropagation()}> + +
+ {subtask.title} diff --git a/src/views/planning/components/TaskRow.tsx b/src/views/planning/components/TaskRow.tsx index d5a3a74..d08a4de 100644 --- a/src/views/planning/components/TaskRow.tsx +++ b/src/views/planning/components/TaskRow.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { Link2, AlertCircle, Clock } from 'lucide-react'; import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; -import { formatDueDate } from '../utils'; +import { formatDueDate, getTaskRenderSignature } from '../utils'; import { ActionMenu } from './ActionMenu'; interface TaskRowProps { @@ -136,10 +136,7 @@ export const TaskRow = memo(function TaskRow({ task, stepType, members, onToggle
); }, (prev, next) => - prev.task.id === next.task.id && - prev.task.status?.blocked === next.task.status?.blocked && - prev.task.concludedAt === next.task.concludedAt && + getTaskRenderSignature(prev.task) === getTaskRenderSignature(next.task) && prev.stepType === next.stepType && - prev.task.subtasks?.length === next.task.subtasks?.length && - prev.members.length === next.members.length + prev.members === next.members ); diff --git a/src/views/planning/components/TaskTable.tsx b/src/views/planning/components/TaskTable.tsx index 48ee72d..7400395 100644 --- a/src/views/planning/components/TaskTable.tsx +++ b/src/views/planning/components/TaskTable.tsx @@ -1,6 +1,6 @@ import { memo, useMemo, useState } from 'react'; import { Ban, CheckCircle2, UserPlus, X } from 'lucide-react'; -import type { Task } from '@/lib/steps'; +import type { SubtaskProgressStatus, Task } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; import { Button } from '@/components/ui/Button'; import { TaskTableRow } from './TaskTableRow'; @@ -17,6 +17,7 @@ interface TaskTableProps { onReorder?: (tasks: Task[]) => Promise; onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; + onUpdateSubtaskProgressStatus?: (task: Task, subtaskId: string, status: SubtaskProgressStatus) => Promise; } export const TaskTable = memo(function TaskTable({ @@ -31,6 +32,7 @@ export const TaskTable = memo(function TaskTable({ onReorder, onUpdateSubtaskAssignees, onUpdateSubtaskDates, + onUpdateSubtaskProgressStatus, }: TaskTableProps) { const [selectedIds, setSelectedIds] = useState([]); const [assignOpen, setAssignOpen] = useState(false); @@ -118,6 +120,7 @@ export const TaskTable = memo(function TaskTable({ onDragEnd={() => { setDragTaskId(null); setDragOverTaskId(null); }} onUpdateSubtaskAssignees={onUpdateSubtaskAssignees} onUpdateSubtaskDates={onUpdateSubtaskDates} + onUpdateSubtaskProgressStatus={onUpdateSubtaskProgressStatus} /> ))} diff --git a/src/views/planning/components/TaskTableRow.tsx b/src/views/planning/components/TaskTableRow.tsx index 04ad766..df99aec 100644 --- a/src/views/planning/components/TaskTableRow.tsx +++ b/src/views/planning/components/TaskTableRow.tsx @@ -1,9 +1,9 @@ import { memo, useState } from 'react'; import { ChevronDown, ChevronRight, Link2, AlertCircle, CheckCircle2, GripVertical } from 'lucide-react'; -import { STEP_META, type Task, type SubtaskStatus } from '@/lib/steps'; +import { STEP_META, type SubtaskProgressStatus, type Task, type SubtaskStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; import { Checkbox } from '@/components/ui/Checkbox'; -import { formatDueDate } from '../utils'; +import { formatDueDate, getTaskRenderSignature } from '../utils'; import { ActionMenu } from './ActionMenu'; import { MemberAvatars } from './MemberAvatars'; import { SubtaskRow } from './SubtaskRow'; @@ -26,6 +26,7 @@ interface TaskTableRowProps { onDragEnd?: () => void; onUpdateSubtaskAssignees?: (task: Task, subtaskId: string, assignees: string[]) => Promise; onUpdateSubtaskDates?: (task: Task, subtaskId: string, start: string, end: string) => Promise; + onUpdateSubtaskProgressStatus?: (task: Task, subtaskId: string, status: SubtaskProgressStatus) => Promise; } export const TaskTableRow = memo(function TaskTableRow({ @@ -46,6 +47,7 @@ export const TaskTableRow = memo(function TaskTableRow({ onDragEnd, onUpdateSubtaskAssignees, onUpdateSubtaskDates, + onUpdateSubtaskProgressStatus, }: TaskTableRowProps) { const [expanded, setExpanded] = useState(defaultExpanded); const isBlocked = task.status.blocked; @@ -54,9 +56,10 @@ export const TaskTableRow = memo(function TaskTableRow({ const activeSubtask = task.subtasks.find(s => s.active); const allAssigneeIds = [...new Set(task.subtasks.flatMap(s => s.assignees))]; const taskTimeStatus = formatDueDate(activeSubtask?.end); - const completedCount = task.subtasks.filter(s => !s.active && s.end).length; + const measurableSubtasks = task.subtasks.filter(s => s.progressStatus !== 'canceled'); + const completedCount = measurableSubtasks.filter(s => s.progressStatus === 'done').length; const progress = task.subtasks.length > 0 - ? isConcluded ? 100 : Math.round((completedCount / task.subtasks.length) * 100) + ? isConcluded ? 100 : Math.round((completedCount / Math.max(measurableSubtasks.length, 1)) * 100) : 0; return ( @@ -212,9 +215,10 @@ export const TaskTableRow = memo(function TaskTableRow({ {expanded && task.subtasks.length > 0 && (
-
+
Etapa + Status Título Período Prazo @@ -233,6 +237,7 @@ export const TaskTableRow = memo(function TaskTableRow({ onEdit={onEdit} onUpdateSubtaskAssignees={onUpdateSubtaskAssignees} onUpdateSubtaskDates={onUpdateSubtaskDates} + onUpdateSubtaskProgressStatus={onUpdateSubtaskProgressStatus} /> ))}
@@ -241,7 +246,7 @@ export const TaskTableRow = memo(function TaskTableRow({
); }, (prev, next) => - prev.task === next.task && + getTaskRenderSignature(prev.task) === getTaskRenderSignature(next.task) && prev.members === next.members && prev.selected === next.selected && prev.defaultExpanded === next.defaultExpanded && diff --git a/src/views/planning/components/__tests__/TaskTable.test.tsx b/src/views/planning/components/__tests__/TaskTable.test.tsx index 7a1d784..57f0b11 100644 --- a/src/views/planning/components/__tests__/TaskTable.test.tsx +++ b/src/views/planning/components/__tests__/TaskTable.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { TaskTable } from '../TaskTable' -import type { Task } from '@/lib/steps' +import type { SubtaskProgressStatus, Task } from '@/lib/steps' const baseTask: Omit = { clickupLink: undefined, @@ -22,6 +22,23 @@ function makeTask(id: string, title: string, priorityOrder: number): Task { } } +function makeTaskWithSubtask(progressStatus: SubtaskProgressStatus = 'todo'): Task { + return { + ...makeTask('task-1', 'Demanda A', 0), + subtasks: [{ + id: 'subtask-1', + title: 'Subtask A', + status: 'design', + progressStatus, + start: '2026-05-12', + end: '2026-05-15', + assignees: [], + active: true, + order: 0, + }], + } +} + describe('TaskTable', () => { it('chama onReorder com demandas na nova ordem ao arrastar uma linha-pai', async () => { const tasks = [ @@ -53,4 +70,29 @@ describe('TaskTable', () => { 'task-2', ]) }) + + it('atualiza o status de andamento da subtask pelo popover', async () => { + const task = makeTaskWithSubtask('todo') + const onUpdateSubtaskProgressStatus = vi.fn().mockResolvedValue(true) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'A fazer' })) + fireEvent.click(screen.getByRole('button', { name: /Em andamento/ })) + + await waitFor(() => expect(onUpdateSubtaskProgressStatus).toHaveBeenCalledWith( + task, + 'subtask-1', + 'in-progress', + )) + }) }) diff --git a/src/views/planning/utils.ts b/src/views/planning/utils.ts index 4d233b2..536f785 100644 --- a/src/views/planning/utils.ts +++ b/src/views/planning/utils.ts @@ -1,3 +1,29 @@ +import type { Task } from '@/lib/steps'; + +export function getTaskRenderSignature(task: Task): string { + return JSON.stringify([ + task.id, + task.title, + task.clickupLink ?? '', + task.priorityOrder, + task.status.blocked, + task.status.blockedAt ?? '', + task.concludedAt ?? '', + task.concludedBy ?? '', + task.subtasks.map(subtask => [ + subtask.id, + subtask.title, + subtask.status, + subtask.progressStatus, + subtask.start, + subtask.end, + subtask.active, + subtask.order, + subtask.assignees, + ]), + ]); +} + export function formatDueDate(isoDate: string | undefined): { label: string; className: string } | null { if (!isoDate) return null; const due = new Date(isoDate.length === 10 ? isoDate + 'T00:00:00' : isoDate); diff --git a/supabase/migrations/20260512000002_subtask_progress_status.sql b/supabase/migrations/20260512000002_subtask_progress_status.sql new file mode 100644 index 0000000..979cd88 --- /dev/null +++ b/supabase/migrations/20260512000002_subtask_progress_status.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- Migration: Subtask progress status +-- Adds an execution status separate from the subtask phase/type. +-- ============================================================ + +ALTER TABLE task_subtasks + ADD COLUMN IF NOT EXISTS progress_status text NOT NULL DEFAULT 'todo'; + +ALTER TABLE task_subtasks + DROP CONSTRAINT IF EXISTS task_subtasks_progress_status_check; + +ALTER TABLE task_subtasks + ADD CONSTRAINT task_subtasks_progress_status_check + CHECK ( + progress_status IN ( + 'todo', + 'ready', + 'in-progress', + 'in-review', + 'waiting', + 'blocked', + 'needs-changes', + 'paused', + 'done', + 'canceled' + ) + ); From f217bd783d6b4bf1977df0d1a2bd5377dbfb97fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 12 May 2026 17:41:23 -0300 Subject: [PATCH 07/12] feat: :sparkles: atualizado filtros do planning View --- docs/utils/date-utils.md | 26 ++++ docs/views/planning.md | 35 +++-- src/utils/holidayUtils.ts | 5 +- src/views/planning/PlanningView.tsx | 51 ++++--- .../planning/components/TasksFilters.tsx | 130 +++++++++++++++--- .../timeline/components/TaskCalendarRows.tsx | 12 +- 6 files changed, 205 insertions(+), 54 deletions(-) diff --git a/docs/utils/date-utils.md b/docs/utils/date-utils.md index 790e17a..a5ae031 100644 --- a/docs/utils/date-utils.md +++ b/docs/utils/date-utils.md @@ -77,8 +77,34 @@ export const DEFAULT_DURATIONS = { --- +--- + +## `src/utils/holidayUtils.ts` + +Utilitários para feriados, usado no `TaskModal` e em hooks de drag-and-drop. + +### Tipo `Holiday` +```ts +type Holiday = { date: string; name: string } +``` + +### `isWeekendOrHoliday(date: string, holidays: Holiday[]): boolean` +Retorna `true` se a data cair em sábado, domingo ou na lista de feriados. + +### `getHolidayName(date: string, holidays: Holiday[]): string | undefined` +Retorna o nome do feriado ou `undefined` se não for feriado. + +### `nextNonHolidayBusinessDay(date: string, holidays: Holiday[]): string` +Retorna a primeira data **a partir de `date` inclusive** que não seja fim de semana nem feriado. Avança dia a dia até encontrar um dia útil. + +> **Atenção:** a helper interna `toDateStr` usa `getFullYear/getMonth/getDate` (fuso local) em vez de `toISOString()` para evitar que a conversão UTC retorne um dia a menos em fusos negativos (ex: UTC-3 do Brasil). Não alterar para `toISOString()`. + +--- + ## Atenção: Timezone As funções criam `new Date(str)` internamente. No `DashboardView.tsx`, as datas são sempre parseadas com `new Date(str + 'T00:00:00')` para forçar hora local e evitar problemas de UTC vs. local. Ao adicionar novas funções que usem `new Date(dateString)`, usar sempre o sufixo `'T00:00:00'` para strings no formato `YYYY-MM-DD`. + +**Nunca usar `date.toISOString().split('T')[0]`** para obter a data no formato `YYYY-MM-DD` — em UTC-3 isso retorna o dia anterior. Usar sempre `getFullYear/getMonth/getDate`. diff --git a/docs/views/planning.md b/docs/views/planning.md index bd402d0..20cf054 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -28,7 +28,7 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | `src/views/planning/components/FilterBar.tsx` | Barra de filtros usada por calendar e timeline | | `src/views/planning/components/MetricsBar.tsx` | Cards de métricas (saúde operacional, em andamento, bloqueadas) | | `src/views/planning/components/StepsLegend.tsx` | Legenda de cores das fases | -| `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, etapa, responsável, período, bloqueadas, concluídas) | +| `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, status de subtask, responsável com avatares, período, bloqueadas, concluídas) | | `src/views/planning/components/TaskTable.tsx` | Componente raiz da tabela — itera tasks e delega para `TaskTableRow` | | `src/views/planning/components/TaskTableRow.tsx` | Linha-pai colapsável de uma task com progresso, prazo, avatares e painel de subtasks | | `src/views/planning/components/SubtaskRow.tsx` | Linha-filho de uma subtask com ícone de status, badge de etapa, popover de andamento, `DatesPopover` e `AssigneesPopover` | @@ -111,16 +111,31 @@ Os popovers fecham ao clicar fora ou pressionar `Escape` (hook `usePopover`). Re - Subtasks sem datas mostram `—` no campo Período ### TasksFilters -Barra de filtros com `CheckboxDropdown` customizado para etapa e responsável. Filtros: +Barra de filtros do subview `demandas`. Interface `FiltersState`: -| Filtro | Implementação | -|---|---| -| Busca por texto/ID | `task.title` e `task.id` case-insensitive | -| Etapa | `selectedSteps: SubtaskStatus[]` — verifica `subtask.status` das ativas | -| Responsável | `selectedMemberIds: string[]` — qualquer assignee de qualquer subtask | -| Período (prazo) | Tabs "Todos / 7d / 15d / 30d" — compara `end` da subtask ativa com `today + N dias` | -| Bloqueadas | Toggle — filtra `task.status.blocked === true` | -| Concluídas | Toggle — mostra tarefas com `task.concludedAt` preenchido (default: ocultas) | +```ts +{ + searchTerm: string; + selectedSteps: SubtaskStatus[]; // categoria/fase + selectedProgressStatuses: SubtaskProgressStatus[]; // andamento + selectedMemberIds: string[]; + selectedPeriod: string; + showOnlyBlocked: boolean; + showConcluded: boolean; +} +``` + +| Filtro | Campo | Implementação | +|---|---|---| +| Busca por texto/ID | `searchTerm` | `task.title`, `task.id` e `subtask.title` case-insensitive | +| Categoria | `selectedSteps: SubtaskStatus[]` | Dropdown — verifica `subtask.status` de **todas** as subtasks | +| Status | `selectedProgressStatuses: SubtaskProgressStatus[]` | Dropdown — verifica `subtask.progressStatus` de **todas** as subtasks | +| Responsável | `selectedMemberIds` | `MemberAvatarPicker` — botão exibe avatares empilhados dos selecionados | +| Período (prazo) | `selectedPeriod` | Tabs "Todos / 7d / 15d / 30d" — compara `end` da subtask ativa com `today + N dias` | +| Bloqueadas | `showOnlyBlocked` | Toggle — filtra `task.status.blocked === true` | +| Concluídas | `showConcluded` | Toggle — mostra tarefas com `task.concludedAt` preenchido (default: ocultas) | + +O botão **Nova Demanda** fica na row do título (alinhado à direita), não dentro da barra de filtros. ### Ordenação dentro dos grupos Tasks ordenadas por `end` da subtask ativa no grupo — da mais atrasada para a mais recente. Sem data ficam no final. diff --git a/src/utils/holidayUtils.ts b/src/utils/holidayUtils.ts index 63c42c7..8411a09 100644 --- a/src/utils/holidayUtils.ts +++ b/src/utils/holidayUtils.ts @@ -26,5 +26,8 @@ export function nextNonHolidayBusinessDay(date: string, holidays: Holiday[]): st } function toDateStr(d: Date): string { - return d.toISOString().split('T')[0]; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; } diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index cb86541..929b432 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -40,6 +40,7 @@ const PLANNING_BONES = { const EMPTY_FILTERS: FiltersState = { searchTerm: '', selectedSteps: [], + selectedProgressStatuses: [], selectedMemberIds: [], selectedPeriod: '', showOnlyBlocked: false, @@ -131,17 +132,24 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd const filteredDemandasTasks = useMemo(() => { if (subview !== 'demandas') return []; - const { searchTerm, selectedSteps, selectedMemberIds, showOnlyBlocked, selectedPeriod, showConcluded } = demandasFilters; + const { searchTerm, selectedSteps, selectedProgressStatuses, selectedMemberIds, showOnlyBlocked, selectedPeriod, showConcluded } = demandasFilters; return tasks.filter(task => { const isConcluded = !!task.concludedAt; if (!showConcluded && isConcluded) return false; + const lowerSearch = searchTerm.toLowerCase(); const matchSearch = - task.title.toLowerCase().includes(searchTerm.toLowerCase()) || - task.id.toLowerCase().includes(searchTerm.toLowerCase()); + task.title.toLowerCase().includes(lowerSearch) || + task.id.toLowerCase().includes(lowerSearch) || + task.subtasks.some(s => s.title.toLowerCase().includes(lowerSearch)); const currentStep = task.subtasks.find(s => s.active) ?? task.subtasks[0]; - const matchStep = selectedSteps.length > 0 ? selectedSteps.includes(currentStep?.status) : true; + const matchStep = selectedSteps.length > 0 + ? task.subtasks.some(s => selectedSteps.includes(s.status)) + : true; + const matchProgressStatus = selectedProgressStatuses.length > 0 + ? task.subtasks.some(s => selectedProgressStatuses.includes(s.progressStatus)) + : true; const matchMember = selectedMemberIds.length > 0 ? task.subtasks.some(s => s.assignees.some(a => selectedMemberIds.includes(a))) : true; @@ -157,13 +165,14 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd matchPeriod = false; } - return matchSearch && matchStep && matchMember && matchBlocked && matchPeriod; + return matchSearch && matchStep && matchProgressStatus && matchMember && matchBlocked && matchPeriod; }); }, [tasks, demandasFilters, subview]); const hasDemandasActiveFilters = demandasFilters.searchTerm !== '' || demandasFilters.selectedSteps.length > 0 || + demandasFilters.selectedProgressStatuses.length > 0 || demandasFilters.selectedMemberIds.length > 0 || demandasFilters.selectedPeriod !== '' || demandasFilters.showOnlyBlocked; @@ -208,9 +217,17 @@ const hasDemandasActiveFilters = -
-

{title}

-

{description}

+
+
+

{title}

+

{description}

+
+ {subview === 'demandas' && ( + + )}
{showFilterBar && ( @@ -253,18 +270,12 @@ const hasDemandasActiveFilters = onDelete(task.id)} onOpenNew={onOpenNew} onExport={onExport} /> ) : subview === 'demandas' ? (
-
- setDemandasFilters(prev => ({ ...prev, ...next }))} - onClear={() => setDemandasFilters(EMPTY_FILTERS)} - /> - -
+ setDemandasFilters(prev => ({ ...prev, ...next }))} + onClear={() => setDemandasFilters(EMPTY_FILTERS)} + /> {tasks.length === 0 ? (
diff --git a/src/views/planning/components/TasksFilters.tsx b/src/views/planning/components/TasksFilters.tsx index 0ce1b9c..d6f4281 100644 --- a/src/views/planning/components/TasksFilters.tsx +++ b/src/views/planning/components/TasksFilters.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { Search, User, Calendar, AlertCircle, ChevronDown, CheckCircle2 } from 'lucide-react'; +import { Search, Calendar, AlertCircle, ChevronDown, CheckCircle2 } from 'lucide-react'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; -import { STEP_META, STEP_TYPES_ORDER, type StepType } from '@/lib/steps'; +import { STEP_META, STEP_TYPES_ORDER, SUBTASK_PROGRESS_META, SUBTASK_PROGRESS_STATUS_ORDER, type StepType, type SubtaskProgressStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; const PERIOD_TABS = [ @@ -15,6 +15,7 @@ const PERIOD_TABS = [ export interface FiltersState { searchTerm: string; selectedSteps: StepType[]; + selectedProgressStatuses: SubtaskProgressStatus[]; selectedMemberIds: string[]; selectedPeriod: string; showOnlyBlocked: boolean; @@ -30,13 +31,13 @@ interface TasksFiltersProps { function CheckboxDropdown({ label, - icon, + width = 'w-44', options, selected, onToggle, }: { label: string; - icon?: React.ReactNode; + width?: string; options: { value: string; label: string }[]; selected: string[]; onToggle: (value: string) => void; @@ -55,16 +56,13 @@ function CheckboxDropdown({ @@ -93,9 +91,95 @@ function CheckboxDropdown({ ); } +function MemberAvatarPicker({ + members, + selectedIds, + onToggle, +}: { + members: Member[]; + selectedIds: string[]; + onToggle: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {members.map(m => { + const selected = selectedIds.includes(m.id); + return ( + + ); + })} +
+ + )} +
+ ); +} + export function TasksFilters({ filters, members, onChange, onClear }: TasksFiltersProps) { - const { searchTerm, selectedSteps, selectedMemberIds, selectedPeriod, showOnlyBlocked, showConcluded } = filters; - const hasActiveFilters = searchTerm !== '' || selectedSteps.length > 0 || selectedMemberIds.length > 0 || selectedPeriod !== '' || showOnlyBlocked || showConcluded; + const { searchTerm, selectedSteps, selectedProgressStatuses, selectedMemberIds, selectedPeriod, showOnlyBlocked, showConcluded } = filters; + const hasActiveFilters = + searchTerm !== '' || + selectedSteps.length > 0 || + selectedProgressStatuses.length > 0 || + selectedMemberIds.length > 0 || + selectedPeriod !== '' || + showOnlyBlocked || + showConcluded; function toggleStep(step: StepType) { const next = selectedSteps.includes(step) @@ -104,6 +188,13 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte onChange({ selectedSteps: next }); } + function toggleProgressStatus(status: SubtaskProgressStatus) { + const next = selectedProgressStatuses.includes(status) + ? selectedProgressStatuses.filter(s => s !== status) + : [...selectedProgressStatuses, status]; + onChange({ selectedProgressStatuses: next }); + } + function toggleMember(id: string) { const next = selectedMemberIds.includes(id) ? selectedMemberIds.filter(m => m !== id) @@ -117,7 +208,7 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte onChange({ searchTerm: e.target.value })} className="pl-9 w-full" @@ -126,17 +217,22 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte
({ value: s, label: STEP_META[s].label }))} selected={selectedSteps} onToggle={v => toggleStep(v as StepType)} /> } - options={members.map(m => ({ value: m.id, label: m.name }))} - selected={selectedMemberIds} + label="Status" + options={SUBTASK_PROGRESS_STATUS_ORDER.map(s => ({ value: s, label: SUBTASK_PROGRESS_META[s].label }))} + selected={selectedProgressStatuses} + onToggle={v => toggleProgressStatus(v as SubtaskProgressStatus)} + /> + + diff --git a/src/views/timeline/components/TaskCalendarRows.tsx b/src/views/timeline/components/TaskCalendarRows.tsx index 9933964..c5cf4c4 100644 --- a/src/views/timeline/components/TaskCalendarRows.tsx +++ b/src/views/timeline/components/TaskCalendarRows.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { getVisibleSteps, type DragPreview, type DragState, type Task, type Step, type StepType } from '@/utils/dashboardUtils'; +import { getVisibleSteps, type DragPreview, type DragState, type Task, type Subtask } from '@/utils/dashboardUtils'; import type { Member } from '@/types/member'; import type { Holiday } from '@/utils/holidayUtils'; import StepRow from './StepRow'; @@ -13,7 +13,7 @@ interface TaskCalendarRowsProps { daysRange: number; dragPreview: DragPreview | null; didDragRef: React.MutableRefObject; - startDrag: (e: React.MouseEvent, taskId: string, stepType: StepType, type: DragState['type'], step: Step, colWidth: number) => void; + startDrag: (e: React.MouseEvent, taskId: string, subtaskId: string, type: DragState['type'], subtask: Subtask, colWidth: number) => void; onEdit: (t: Task) => void; onDelete: (id: string) => void; taskIndex: number; @@ -34,7 +34,7 @@ const TaskCalendarRows: React.FC = ({ task, days, daysRan > {visibleSteps.length === 0 ? ( = ({ task, days, daysRan holidays={holidays} flex1 /> - ) : visibleSteps.map((step) => ( + ) : visibleSteps.map((subtask) => ( Date: Wed, 13 May 2026 10:15:11 -0300 Subject: [PATCH 08/12] feat: :sparkles: atualizado os filtros das views --- docs/todo/melhorias.md | 2 +- docs/views/planning.md | 18 +- src/components/TaskModal.tsx | 583 +++++++++--------- src/hooks/ui/usePhaseDrag.ts | 4 +- src/store/usePlanningFiltersStore.ts | 54 ++ src/types/props.ts | 4 - src/views/calendar/CalendarView.tsx | 32 +- src/views/calendar/components/WeekRow.tsx | 2 +- src/views/calendar/hooks/useCalendarDrag.ts | 4 +- src/views/planning/PlanningView.tsx | 119 ++-- .../components/PlanningViewHeader.tsx | 95 +++ src/views/planning/hooks/useTaskFilters.ts | 57 +- src/views/timeline/TimelineView.tsx | 23 +- 13 files changed, 580 insertions(+), 417 deletions(-) create mode 100644 src/store/usePlanningFiltersStore.ts create mode 100644 src/views/planning/components/PlanningViewHeader.tsx diff --git a/docs/todo/melhorias.md b/docs/todo/melhorias.md index 797a6d3..0a2930b 100644 --- a/docs/todo/melhorias.md +++ b/docs/todo/melhorias.md @@ -23,7 +23,7 @@ Todos implementados com `memo` e comparador customizado. Callbacks excluídos do |---|---| | `NotificationBell` | `unreadCount`, `notifications.length`, `selectedClientId` | | `WeekRow` | `tasks` (ref + length), `week[0]`, `currentMonth`, `viewMode`, `weekIndex`, `dragPreview`, `holidays.length` | -| `StepBar` | `bar.{taskId,stepType,startCol,endCol,slot}`, `task.{concludedAt,status.blocked}`, `isFirst/LastBarOfStep`, `viewMode`, `demandColor`, `dragPreview` | +| `StepBar` | `bar.{taskId,subtaskId,startCol,endCol,slot}`, `task.{concludedAt,status.blocked}`, `isFirst/LastBarOfStep`, `viewMode`, `demandColor`, `dragPreview` | | `PhaseBar` | `step.{type,start,end}`, `task.{id,concludedAt,status.blocked}`, `days.length`, `dragPreview` | | `MemberCard` | `member.id`, `tasks` (ref + length), `today` | | `TaskRow` | `task.{id,status.blocked,concludedAt}`, `stepType`, `members.length` | diff --git a/docs/views/planning.md b/docs/views/planning.md index 20cf054..1dfd1bd 100644 --- a/docs/views/planning.md +++ b/docs/views/planning.md @@ -4,6 +4,12 @@ `PlanningView` é o container de todas as visualizações de planejamento de demandas. Substitui a antiga `DashboardView` e absorveu a `TasksView` como subview `demandas`. +**Arquitetura de filtros:** `PlanningView` gerencia dois conjuntos de filtros independentes, ambos como `FiltersState` local: +- `demandasFilters` — filtros do subview `demandas` +- `calendarFilters` — filtros dos subviews `calendar` e `timeline` + +Ambos são renderizados pelo `PlanningViewHeader` via `TasksFilters` (o componente de filtros unificado). `PlanningView` aplica `calendarFilters` sobre as tasks já processadas por `useTaskFilters` antes de passar `filteredTasks` para `CalendarView` e `TimelineView`. `CalendarView` e `TimelineView` não renderizam mais filtros internamente — recebem apenas tasks já filtradas. O estado de `viewMode` (step/demand) para o calendário ainda vive em `usePlanningFiltersStore`. + Localização: `src/views/planning/` ## Navegação @@ -23,9 +29,11 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | Ficheiro | Responsabilidade | |---|---| -| `src/views/planning/PlanningView.tsx` | Orquestra dados, filtros e renderiza a subview correta | -| `src/views/planning/hooks/useTaskFilters.ts` | Filtros de assignee, status, subtasks, período para calendar/timeline | -| `src/views/planning/components/FilterBar.tsx` | Barra de filtros usada por calendar e timeline | +| `src/views/planning/PlanningView.tsx` | Orquestra dados globais (tasks, members, holidays), aplica filtros via `useTaskFilters` e passa `filteredTasks`, `members`, `onOpenNew` e `onExport` para `CalendarView` e `TimelineView` via prop | +| `src/views/planning/components/PlanningViewHeader.tsx` | Header compartilhado: tabs de navegação, título/descrição da view e `TasksFilters` para todos os subviews (`demandas`, `calendar`, `timeline`). Recebe `demandasFilters` e `calendarFilters` como props separados. | +| `src/store/usePlanningFiltersStore.ts` | Store Zustand com estado de `viewMode` (step/demand) e filtros legados lidos por `useTaskFilters` | +| `src/views/planning/hooks/useTaskFilters.ts` | Lê filtros de `usePlanningFiltersStore` e aplica sobre as tasks; expõe `filteredTasks`, contadores e helpers | +| `src/views/planning/components/FilterBar.tsx` | Barra de filtros legada — não mais usada por `CalendarView` ou `TimelineView` | | `src/views/planning/components/MetricsBar.tsx` | Cards de métricas (saúde operacional, em andamento, bloqueadas) | | `src/views/planning/components/StepsLegend.tsx` | Legenda de cores das fases | | `src/views/planning/components/TasksFilters.tsx` | Barra de filtros do subview `demandas` (busca, status de subtask, responsável com avatares, período, bloqueadas, concluídas) | @@ -42,8 +50,8 @@ A navegação entre modos é feita via **roteamento global** (`useUIStore`). Cad | `src/views/planning/components/TaskList.tsx` | **Legado** — lista de demandas com subtasks expandidas (substituído por `TaskTable`) | | `src/views/planning/components/ActionMenu.tsx` | Dropdown de ações rápidas (abrir, ClickUp, concluir, bloquear) | | `src/views/planning/utils.ts` | `formatDueDate` — badge de prazo relativo | -| `src/views/CalendarView.tsx` | Calendário mensal com drag-drop e slots | -| `src/views/timeline/TimelineView.tsx` | Timeline/Gantt — componente raiz | +| `src/views/calendar/CalendarView.tsx` | Calendário mensal — recebe `tasks` (já filtradas) via prop; renderiza apenas header de navegação de mês + grade de semanas. Sem filtros internos. Props: `tasks`, `onEdit`, `onUpdateTask`, `holidays` | +| `src/views/timeline/TimelineView.tsx` | Timeline/Gantt — recebe `tasks` (já filtradas) via prop; sem filtros internos. Props: `tasks`, `members`, `onEdit`, `onDelete`, `onUpdateTask`, `holidays` | | `src/views/timeline/components/TimelineHeader.tsx` | Selector de range (14/30/60/90d) | | `src/views/timeline/components/DayColumnHeaders.tsx` | Header de colunas de dias (mês + dia + feriados) | | `src/views/timeline/components/TaskCalendarRows.tsx` | Linhas de fases no calendário por tarefa | diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index a5daa71..8cce3c4 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -1,8 +1,11 @@ import React, { useState } from 'react'; import { useMembersQuery } from '@/hooks/members/useMembersQuery'; import { useClients } from '@/hooks/clients/useClients'; -import { Input, Label, Button, ConfirmModal } from './ui'; -import { Save, ExternalLink, Trash2, Users, AlertCircle, CheckCircle2, Plus, GripVertical, X } from 'lucide-react'; +import { Input, Button, ConfirmModal } from './ui'; +import { + Save, ExternalLink, Trash2, Users, AlertCircle, CheckCircle2, + Plus, GripVertical, X, Calendar, Link2, ChevronDown, ListTodo, +} from 'lucide-react'; import { STEP_META, STEP_TYPES_ORDER, @@ -23,7 +26,6 @@ function tempId() { return `__new__${++_subtaskCounter}`; } -/** Subtask com id temporário para subtasks novas (ainda não no DB) */ type SubtaskDraft = Subtask & { _tempId: string }; function draftFromSubtask(s: Subtask): SubtaskDraft { @@ -86,7 +88,7 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo const e: Record = {}; if (!title || title.length < 3) e.title = 'Título obrigatório (mín. 3 caracteres).'; if (clickupLink) { - try { new URL(clickupLink) } catch { e.clickupLink = 'Insira um URL válido (ex: https://...).'; } + try { new URL(clickupLink); } catch { e.clickupLink = 'Insira um URL válido (ex: https://...).'; } } if (activeSubtasks.length === 0) e.subtasks = 'Adicione pelo menos uma subtask ativa.'; subtasks.forEach(s => { @@ -178,244 +180,253 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo setSubtasks(prev => [...prev, newDraft(prev.length)]); }; - const removeSubtask = (tempId: string) => { - setSubtasks(prev => prev.filter(s => s._tempId !== tempId)); + const removeSubtask = (id: string) => { + setSubtasks(prev => prev.filter(s => s._tempId !== id)); }; - const updateSubtask = (tempId: string, field: K, value: SubtaskDraft[K]) => { - setSubtasks(prev => prev.map(s => s._tempId === tempId ? { ...s, [field]: value } : s)); + const updateSubtask = (id: string, field: K, value: SubtaskDraft[K]) => { + setSubtasks(prev => prev.map(s => s._tempId === id ? { ...s, [field]: value } : s)); }; - const toggleAssignee = (tempId: string, memberId: string) => { + const toggleAssignee = (id: string, memberId: string) => { setSubtasks(prev => prev.map(s => { - if (s._tempId !== tempId) return s; + if (s._tempId !== id) return s; const has = s.assignees.includes(memberId); - return { ...s, assignees: has ? s.assignees.filter(id => id !== memberId) : [...s.assignees, memberId] }; + return { ...s, assignees: has ? s.assignees.filter(mid => mid !== memberId) : [...s.assignees, memberId] }; })); }; return ( -
+ <> + +
e.stopPropagation()} + className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm" + onClick={handleRequestClose} > +
e.stopPropagation()} + > + + {/* Header */} +
+
+ + {task ? 'Editar Demanda' : 'Nova Demanda'} + + {task && ( + + #{task.id} + + )} +
+ +
- {/* Header */} -
-

- {task ? 'Editar Demanda' : 'Nova Demanda'} -

- -
+
+
-
- -
- - {/* Título */} -
- - setTitle(e.target.value)} - placeholder="Ex: Landing Page Black Friday" - /> - {errors.title &&

{errors.title}

} -
+ {/* Seção 1: Título e metadados */} +
- {/* Link ClickUp */} -
- -
- + setClickupLink(e.target.value)} - placeholder="https://app.clickup.com/t/..." - className="pr-9" + value={title} + onChange={e => setTitle(e.target.value)} + placeholder="Nome da Demanda..." + autoFocus + className="w-full bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 outline-none border-b border-transparent focus:border-primary pb-1 transition-colors" /> - {clickupLink && ( - - - - )} + {errors.title &&

{errors.title}

}
- {errors.clickupLink &&

{errors.clickupLink}

} -
- {/* Bloqueado */} -
-
-
- -
-
- Bloqueado -
-
- Subtasks a partir da data de bloqueio ficam em alerta vermelho -
+ {/* Link ClickUp + toggles Bloqueada/Concluída */} +
+ +
+
+
-
- -
- {blocked && ( -
- Data do bloqueio: setBlockedAt(e.target.value)} - className="h-7 text-xs w-auto flex-1 bg-white/70 dark:bg-red-900/30 border-red-300 dark:border-red-700" + type="text" + inputMode="url" + value={clickupLink} + onChange={e => setClickupLink(e.target.value)} + placeholder="Colar link do ClickUp..." + className="pl-9 pr-8" /> + {clickupLink && ( + + + + )} + {errors.clickupLink &&

{errors.clickupLink}

} +
+ +
+ + + +
+
+ + {/* Datas condicionais (bloqueio / conclusão) */} + {(blocked || concludedAt) && ( +
+ {blocked && ( +
+ A partir de: + setBlockedAt(e.target.value)} + className="h-7 text-xs w-auto border-red-300 dark:border-red-700" + /> +
+ )} + {concludedAt && ( +
+ Concluída em: + setConcludedAt(e.target.value ? e.target.value + 'T00:00:00' : undefined)} + className="h-7 text-xs w-auto border-emerald-300 dark:border-emerald-700" + /> +
+ )}
)}
- {/* Concluída */} -
+ {/* Seção 2: Subtasks */} +
+
- -
-
- Concluída -
-
- Demanda finalizada e entregue -
-
+ Subtasks + {activeSubtasks.length > 0 && ( + + {activeSubtasks.length} ativa{activeSubtasks.length !== 1 ? 's' : ''} + + )}
- {concludedAt && ( -
- Concluída em: - setConcludedAt(e.target.value ? e.target.value + 'T00:00:00' : undefined)} - className="h-7 text-xs w-auto flex-1 bg-white/70 dark:bg-emerald-900/30 border-emerald-300 dark:border-emerald-700" - /> -
- )} -
- {/* Subtasks */} -
-
- - - {activeSubtasks.length} ativa{activeSubtasks.length !== 1 ? 's' : ''} - -
{errors.subtasks &&

{errors.subtasks}

} -
+
{subtasks.map((subtask) => { const meta = STEP_META[subtask.status]; return (
- {/* Subtask header */} -
- - - - {/* Título livre */} - updateSubtask(subtask._tempId, 'title', e.target.value)} - placeholder="Nome da subtask…" - className="flex-1 bg-transparent text-xs font-semibold placeholder:text-current/40 outline-none min-w-0" - /> - - {/* Select de status */} - - - + {/* Lado esquerdo: grip + título + selects */} +
+ + +
+ updateSubtask(subtask._tempId, 'title', e.target.value)} + placeholder="Nome da subtask…" + className="w-full bg-transparent text-sm font-semibold text-foreground placeholder:text-muted-foreground/40 outline-none border-b border-transparent focus:border-border pb-0.5 transition-colors" + /> + +
+
+ + +
+ +
+ + +
+
- + {errors[`${subtask._tempId}-title`] && ( + {errors[`${subtask._tempId}-title`]} + )} +
- {/* Subtask body — responsáveis + datas */} -
- {errors[`${subtask._tempId}-title`] && ( -

{errors[`${subtask._tempId}-title`]}

- )} - - {/* Responsáveis */} -
- - Responsáveis - (opcional) - -
+ {/* Lado direito: responsáveis + datas + remover */} +
+ + {/* Avatares / responsáveis */} +
+ +
{resolvedMembers.map(m => { const sel = subtask.assignees.includes(m.id); return ( @@ -423,120 +434,142 @@ const TaskModal: React.FC = ({ task, members: propMembers, onClo key={m.id} type="button" onClick={() => toggleAssignee(subtask._tempId, m.id)} - className={`flex items-center gap-1.5 px-2 py-1 rounded-lg border text-xs font-medium transition-all ${ + title={m.name} + className={`relative w-7 h-7 rounded-full overflow-hidden transition-all duration-150 ${ sel - ? 'border-blue-500 bg-blue-50 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200' - : 'border-transparent bg-white/50 dark:bg-white/10 text-inherit hover:bg-white/80 dark:hover:bg-white/20' + ? 'ring-2 ring-primary ring-offset-1 ring-offset-card opacity-100 scale-110 z-10' + : 'opacity-40 grayscale hover:opacity-70 hover:grayscale-0' }`} > -
- {m.avatar} -
- {m.name} + {m.avatar_url ? ( + {m.name} + ) : ( +
+ {m.avatar} +
+ )} ); })}
- {/* Datas */} -
-
- Início - +
+ + updateSubtask(subtask._tempId, 'start', e.target.value)} - className="h-8 text-xs bg-white/70 dark:bg-black/20 border-0 focus:ring-1" + className="bg-transparent text-[11px] font-mono text-foreground py-1.5 w-[90px] outline-none" />
-
- Fim - + até + updateSubtask(subtask._tempId, 'end', e.target.value)} - className="h-8 text-xs bg-white/70 dark:bg-black/20 border-0 focus:ring-1" + className="bg-transparent text-[11px] font-mono text-foreground py-1.5 w-[90px] outline-none" />
- {errors[`${subtask._tempId}-dates`] && ( -

{errors[`${subtask._tempId}-dates`]}

- )} + +
+ + {errors[`${subtask._tempId}-dates`] && ( +

+ {errors[`${subtask._tempId}-dates`]} +

+ )}
); })}
- + {/* Empty state */} + {subtasks.length === 0 && ( +
+
+ +
+

Nenhuma subtask definida

+

+ Adicione subtasks para estruturar esta demanda, atribuir responsáveis e definir prazos de entrega. +

+
+ )} +
+ +
+ {/* Footer */} +
+
+ {task && onDelete && ( + + )}
- -
- {/* Footer */} -
- -
- {task && onDelete && ( - - )} - + +
-
- {pendingSubmitData && ( - - )} - - {showDirtyCloseConfirm && ( - { - setShowDirtyCloseConfirm(false); - onClose(); - }} - onCancel={() => setShowDirtyCloseConfirm(false)} - /> - )} + {pendingSubmitData && ( + + )} + + {showDirtyCloseConfirm && ( + { + setShowDirtyCloseConfirm(false); + onClose(); + }} + onCancel={() => setShowDirtyCloseConfirm(false)} + /> + )} +
-
+ ); }; diff --git a/src/hooks/ui/usePhaseDrag.ts b/src/hooks/ui/usePhaseDrag.ts index 3073eaa..53c59a9 100644 --- a/src/hooks/ui/usePhaseDrag.ts +++ b/src/hooks/ui/usePhaseDrag.ts @@ -64,7 +64,9 @@ export function usePhaseDrag(tasks: Task[], onUpdateTask: (task: Task) => void, } } dragStateRef.current = null; - setDragPreview(null); + // Clear preview in the next microtask so the parent's state update (onUpdateTask) + // and this state update land in the same React batch, preventing a flicker frame. + Promise.resolve().then(() => setDragPreview(null)); setTimeout(() => { didDragRef.current = false; }, 0); }; diff --git a/src/store/usePlanningFiltersStore.ts b/src/store/usePlanningFiltersStore.ts new file mode 100644 index 0000000..3f15b26 --- /dev/null +++ b/src/store/usePlanningFiltersStore.ts @@ -0,0 +1,54 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import type { StepType } from '@/lib/steps'; + +export type CalendarViewMode = 'step' | 'demand'; + +interface PlanningFiltersState { + filterAssignee: string; + setFilterAssignee: (value: string) => void; + + filterStatus: string; + setFilterStatus: (value: string) => void; + + filterSteps: StepType[]; + toggleStepFilter: (type: StepType) => void; + + filterPeriodDays: number; + setFilterPeriodDays: (value: number) => void; + + viewMode: CalendarViewMode; + setViewMode: (mode: CalendarViewMode) => void; + + clearFilters: () => void; +} + +export const usePlanningFiltersStore = create()( + devtools( + (set) => ({ + filterAssignee: '', + setFilterAssignee: (filterAssignee) => set({ filterAssignee }), + + filterStatus: '', + setFilterStatus: (filterStatus) => set({ filterStatus }), + + filterSteps: [], + toggleStepFilter: (type) => + set((s) => ({ + filterSteps: s.filterSteps.includes(type) + ? s.filterSteps.filter((t) => t !== type) + : [...s.filterSteps, type], + })), + + filterPeriodDays: 60, + setFilterPeriodDays: (filterPeriodDays) => set({ filterPeriodDays }), + + viewMode: 'step', + setViewMode: (viewMode) => set({ viewMode }), + + clearFilters: () => + set({ filterAssignee: '', filterStatus: '', filterSteps: [], filterPeriodDays: 60 }), + }), + { name: 'app/planning-filters', enabled: true } + ) +); diff --git a/src/types/props.ts b/src/types/props.ts index f324e3a..33a4c05 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -43,12 +43,9 @@ export interface PlanningViewProps { export interface CalendarViewProps { tasks: Task[]; - members: Member[]; onEdit: (task: Task) => void; - onDelete: (id: string) => void; onUpdateTask: (task: Task) => void; holidays: Holiday[]; - viewMode?: CalendarViewMode; } // ─── TimelineView ───────────────────────────────────────────────────────────── @@ -60,7 +57,6 @@ export interface TimelineViewProps { onDelete: (id: string) => void; onUpdateTask: (task: Task) => void; holidays: Holiday[]; - daysRange: number; } // ─── MembersView ───────────────────────────────────────────────────────────── diff --git a/src/views/calendar/CalendarView.tsx b/src/views/calendar/CalendarView.tsx index 09feccb..6e6643c 100644 --- a/src/views/calendar/CalendarView.tsx +++ b/src/views/calendar/CalendarView.tsx @@ -1,26 +1,36 @@ import React from 'react'; -import { MAX_SLOTS } from '../../utils/dashboardUtils'; +import { MAX_SLOTS, PT_MONTHS } from '../../utils/dashboardUtils'; import type { CalendarViewProps } from '../../types/props'; import { useCalendarNavigation } from './hooks/useCalendarNavigation'; import { useCalendarDrag } from './hooks/useCalendarDrag'; -import CalendarHeader from './components/CalendarHeader'; +import { usePlanningFiltersStore } from '@/store/usePlanningFiltersStore'; import DayHeaders from './components/DayHeaders'; import WeekRow from './components/WeekRow'; import { ConfirmModal } from '@/components/ui'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +const CalendarView: React.FC = ({ tasks: filteredTasks, onEdit, onUpdateTask, holidays }) => { + const viewMode = usePlanningFiltersStore((s) => s.viewMode); -const CalendarView: React.FC = ({ tasks, onEdit, onUpdateTask, holidays, viewMode = 'step' }) => { const { today, monthDate, weeks, prevMonth, nextMonth, goToday } = useCalendarNavigation(); - const { dragPreview, didDragRef, startDrag, pendingDragUpdate, confirmDrag, cancelDrag, postponeDragToBusinessDay } = useCalendarDrag(tasks, onUpdateTask, holidays); + const { dragPreview, didDragRef, startDrag, pendingDragUpdate, confirmDrag, cancelDrag, postponeDragToBusinessDay } = useCalendarDrag(filteredTasks, onUpdateTask, holidays); const rowHeight = `calc(var(--cal-day-header-h) + ${MAX_SLOTS} * var(--cal-slot-height) + var(--cal-row-padding))`; return (
- +
+
+

+ {PT_MONTHS[monthDate.getMonth()]} {monthDate.getFullYear()} +

+ +
+
+ + +
+
+
= ({ tasks, onEdit, onUpdateTask = React.memo(({ const demandColor = viewMode === 'demand' ? getDemandColor(bar.taskId) : undefined; return ( voi const container = (e.currentTarget as HTMLElement).closest('[data-week-row]') as HTMLElement; const colWidth = container ? container.getBoundingClientRect().width / 7 : 80; const norm = normaliseTask(task); - const step = (norm.subtasks as Step[]).find(s => s.status === bar.stepType); + const step = (norm.subtasks as Step[]).find(s => s.id === bar.subtaskId); if (!step) return; - phaseDragStart(e, bar.taskId, bar.stepType, type, step, colWidth); + phaseDragStart(e, bar.taskId, bar.subtaskId, type, step, colWidth); }, [phaseDragStart]); return { dragPreview, didDragRef, startDrag, pendingDragUpdate, confirmDrag, cancelDrag, postponeDragToBusinessDay }; diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index 929b432..c0ff928 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs'; import { useTasksQuery } from '@/hooks/tasks/useTasksQuery'; import { useMembersQuery } from '@/hooks/members/useMembersQuery'; import { useQueryClient } from '@tanstack/react-query'; @@ -12,16 +11,15 @@ import { CalendarView } from '@/views/calendar'; import TimelineView from '@/views/timeline'; import { ListView } from '@/views/list'; import { useTaskFilters } from './hooks/useTaskFilters'; -import { FilterBar } from './components/FilterBar'; +import { PlanningViewHeader } from './components/PlanningViewHeader'; import { StepsLegend } from './components/StepsLegend'; -import { TasksFilters, type FiltersState } from './components/TasksFilters'; +import { type FiltersState } from './components/TasksFilters'; import { TaskTable } from './components/TaskTable'; import type { PlanningViewProps } from '@/types/props'; import { useUIStore } from '@/store/useUIStore'; import { ViewState } from '@/components/ViewState'; -import { CalendarX2, DatabaseZap, FilterX, Search, Plus } from 'lucide-react'; +import { CalendarX2, DatabaseZap, FilterX, Search } from 'lucide-react'; import { Skeleton } from 'boneyard-js/react'; -import { Button } from '@/components/ui/Button'; import { toast } from 'sonner'; const PLANNING_BONES = { @@ -81,18 +79,38 @@ const PlanningView: React.FC = ({ subview, onViewChange, onEd isAdmin, }); - const { - filterAssignee, setFilterAssignee, - filterStatus, setFilterStatus, - filterSteps, - filterPeriodDays, setFilterPeriodDays, - viewMode, setViewMode, - hasActiveFilters, - clearFilters, toggleStepFilter, - filteredTasks, - } = useTaskFilters(tasks ?? [], subview === 'timeline', dashboardRedirect?.assigneeId ?? ''); + const { clearFilters, filteredTasks: allFilteredTasks } = useTaskFilters(tasks ?? [], subview === 'timeline', dashboardRedirect?.assigneeId ?? ''); const [demandasFilters, setDemandasFilters] = useState(EMPTY_FILTERS); + const [calendarFilters, setCalendarFilters] = useState(EMPTY_FILTERS); + + const filteredTasks = useMemo(() => { + if (subview !== 'calendar' && subview !== 'timeline') return allFilteredTasks; + const { searchTerm, selectedSteps, selectedProgressStatuses, selectedMemberIds, showOnlyBlocked, selectedPeriod, showConcluded } = calendarFilters; + return allFilteredTasks.filter(task => { + const isConcluded = !!task.concludedAt; + if (!showConcluded && isConcluded) return false; + const lowerSearch = searchTerm.toLowerCase(); + if (lowerSearch && !( + task.title.toLowerCase().includes(lowerSearch) || + task.id.toLowerCase().includes(lowerSearch) || + task.subtasks.some(s => s.title.toLowerCase().includes(lowerSearch)) + )) return false; + if (selectedSteps.length > 0 && !task.subtasks.some(s => selectedSteps.includes(s.status))) return false; + if (selectedProgressStatuses.length > 0 && !task.subtasks.some(s => selectedProgressStatuses.includes(s.progressStatus))) return false; + if (selectedMemberIds.length > 0 && !task.subtasks.some(s => s.assignees.some(a => selectedMemberIds.includes(a)))) return false; + if (showOnlyBlocked && !task.status.blocked) return false; + if (selectedPeriod) { + const currentStep = task.subtasks.find(s => s.active) ?? task.subtasks[0]; + if (!currentStep?.end) return false; + const due = new Date(currentStep.end + 'T00:00:00'); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() + parseInt(selectedPeriod)); + if (due > cutoff) return false; + } + return true; + }); + }, [allFilteredTasks, calendarFilters, subview]); const handleBulkAssign = useCallback(async (selectedTasks: typeof tasks, memberId: string) => { const tasksWithActiveSubtask = selectedTasks @@ -201,58 +219,22 @@ const hasDemandasActiveFilters = ); } - const { title, description } = VIEW_TITLES[subview] ?? VIEW_TITLES.calendar; - const showFilterBar = subview === 'calendar' || subview === 'timeline'; const content = (
- onViewChange(v)}> - - {DEMAND_TABS.map((tab) => ( - - {tab.label} - - ))} - - - -
-
-

{title}

-

{description}

-
- {subview === 'demandas' && ( - - )} -
- - {showFilterBar && ( - - )} + setDemandasFilters(prev => ({ ...prev, ...next }))} + onClearDemandasFilters={() => setDemandasFilters(EMPTY_FILTERS)} + calendarFilters={calendarFilters} + onChangeCalendarFilters={next => setCalendarFilters(prev => ({ ...prev, ...next }))} + onClearCalendarFilters={() => setCalendarFilters(EMPTY_FILTERS)} + /> {showFilterBar && filteredTasks.length === 0 ? ( ) : subview === 'calendar' ? ( - + ) : subview === 'timeline' ? ( - + ) : subview === 'list' ? ( onDelete(task.id)} onOpenNew={onOpenNew} onExport={onExport} /> ) : subview === 'demandas' ? (
- setDemandasFilters(prev => ({ ...prev, ...next }))} - onClear={() => setDemandasFilters(EMPTY_FILTERS)} - /> - {tasks.length === 0 ? (
diff --git a/src/views/planning/components/PlanningViewHeader.tsx b/src/views/planning/components/PlanningViewHeader.tsx new file mode 100644 index 0000000..3f2638a --- /dev/null +++ b/src/views/planning/components/PlanningViewHeader.tsx @@ -0,0 +1,95 @@ +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs'; +import { Button } from '@/components/ui/Button'; +import { Plus } from 'lucide-react'; +import { TasksFilters, type FiltersState } from './TasksFilters'; +import type { Member } from '@/types/member'; + +const DEMAND_TABS = [ + { value: 'demandas', label: 'Todas as Demandas' }, + { value: 'calendar', label: 'Calendário' }, + { value: 'timeline', label: 'Linha do Tempo' }, +] as const; + +const VIEW_TITLES: Record = { + calendar: { title: 'Calendário', description: 'Visualize as demandas em calendário mensal.' }, + timeline: { title: 'Linha do Tempo', description: 'Acompanhe as fases das demandas em Gantt.' }, + list: { title: 'Lista', description: 'Todas as demandas em formato de tabela.' }, + demandas: { title: 'Demandas', description: 'Visualize todas as demandas por etapa atual.' }, +}; + +interface PlanningViewHeaderProps { + subview: string; + onViewChange: (value: string) => void; + onOpenNew: () => void; + members: Member[]; + // TasksFilters props (demandas) + demandasFilters: FiltersState; + onChangeDemandasFilters: (next: Partial) => void; + onClearDemandasFilters: () => void; + // TasksFilters props (calendar / timeline) + calendarFilters: FiltersState; + onChangeCalendarFilters: (next: Partial) => void; + onClearCalendarFilters: () => void; +} + +export function PlanningViewHeader({ + subview, + onViewChange, + onOpenNew, + members, + demandasFilters, + onChangeDemandasFilters, + onClearDemandasFilters, + calendarFilters, + onChangeCalendarFilters, + onClearCalendarFilters, +}: PlanningViewHeaderProps) { + const { title, description } = VIEW_TITLES[subview] ?? VIEW_TITLES.calendar; + const showDemandasFilters = subview === 'demandas'; + const showCalendarFilters = subview === 'calendar' || subview === 'timeline'; + + return ( +
+ + + {DEMAND_TABS.map((tab) => ( + + {tab.label} + + ))} + + + +
+
+

{title}

+

{description}

+
+ {showDemandasFilters && ( + + )} +
+ + {showDemandasFilters && ( + + )} + + {showCalendarFilters && ( + + )} +
+ ); +} diff --git a/src/views/planning/hooks/useTaskFilters.ts b/src/views/planning/hooks/useTaskFilters.ts index 162e05b..372cda5 100644 --- a/src/views/planning/hooks/useTaskFilters.ts +++ b/src/views/planning/hooks/useTaskFilters.ts @@ -1,17 +1,28 @@ -import { useState, useMemo } from 'react'; -import type { StepType } from '@/lib/steps'; +import { useMemo } from 'react'; import { normaliseTask, todayStr } from '@/utils/dashboardUtils'; import { getCurrentStep } from '@/lib/steps'; import type { Task } from '@/types/task'; +import { usePlanningFiltersStore } from '@/store/usePlanningFiltersStore'; -export type CalendarViewMode = 'step' | 'demand'; +export type { CalendarViewMode } from '@/store/usePlanningFiltersStore'; export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initialAssignee = '') { - const [filterAssignee, setFilterAssignee] = useState(() => initialAssignee); - const [filterStatus, setFilterStatus] = useState(''); - const [filterSteps, setFilterSteps] = useState([]); - const [filterPeriodDays, setFilterPeriodDays] = useState(60); - const [viewMode, setViewMode] = useState('step'); + const { + filterAssignee, setFilterAssignee, + filterStatus, setFilterStatus, + filterSteps, + filterPeriodDays, setFilterPeriodDays, + viewMode, setViewMode, + toggleStepFilter, + clearFilters, + } = usePlanningFiltersStore(); + + // seed assignee from redirect only once — store handles persistence + useMemo(() => { + if (initialAssignee && !filterAssignee) setFilterAssignee(initialAssignee); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialAssignee]); + const periodStart = useMemo(() => { const start = new Date(); start.setHours(0, 0, 0, 0); @@ -31,19 +42,6 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia (enablePeriodFilter && filterPeriodDays !== 60) ); - const clearFilters = () => { - setFilterAssignee(''); - setFilterStatus(''); - setFilterSteps([]); - setFilterPeriodDays(60); - }; - - const toggleStepFilter = (type: StepType) => { - setFilterSteps(prev => - prev.includes(type) ? prev.filter(s => s !== type) : [...prev, type] - ); - }; - const filteredTasks = useMemo(() => { return tasks .filter(task => { @@ -62,7 +60,6 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia const activeTypes = norm.subtasks.filter(s => s.active).map(s => s.status); if (!filterSteps.some(ft => activeTypes.includes(ft))) return false; } - if (enablePeriodFilter) { const norm = normaliseTask(task); const intersectsPeriod = norm.subtasks.some(step => { @@ -70,10 +67,8 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia const stepStart = new Date(step.start + 'T00:00:00'); const stepEnd = new Date(step.end + 'T00:00:00'); if (Number.isNaN(stepStart.getTime()) || Number.isNaN(stepEnd.getTime())) return false; - return stepEnd >= periodStart && stepStart <= periodEnd; }); - if (!intersectsPeriod) return false; } return true; @@ -109,16 +104,12 @@ export function useTaskFilters(tasks: Task[], enablePeriodFilter = false, initia }).length; }, [tasks]); -return { - filterAssignee, - setFilterAssignee, - filterStatus, - setFilterStatus, + return { + filterAssignee, setFilterAssignee, + filterStatus, setFilterStatus, filterSteps, - filterPeriodDays, - setFilterPeriodDays, - viewMode, - setViewMode, + filterPeriodDays, setFilterPeriodDays, + viewMode, setViewMode, hasActiveFilters, clearFilters, toggleStepFilter, diff --git a/src/views/timeline/TimelineView.tsx b/src/views/timeline/TimelineView.tsx index 8ebe61c..6918aa6 100644 --- a/src/views/timeline/TimelineView.tsx +++ b/src/views/timeline/TimelineView.tsx @@ -5,7 +5,7 @@ import { usePhaseDrag } from '@/hooks/ui/usePhaseDrag'; import { useTimelineDays } from './hooks/useTimelineDays'; import { useRowHeightSync } from './hooks/useRowHeightSync'; import { useHeaderHeightSync } from './hooks/useHeaderHeightSync'; -import TimelineHeader from './components/TimelineHeader'; +import { usePlanningFiltersStore } from '@/store/usePlanningFiltersStore'; import DayColumnHeaders from './components/DayColumnHeaders'; import TaskInfoPanelWrapper from './components/TaskInfoPanelWrapper'; import TaskCalendarRows from './components/TaskCalendarRows'; @@ -18,25 +18,25 @@ const formatStepRange = (start: string, end: string) => { return `${startDate.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })} - ${endDate.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}`; }; -const TimelineView: React.FC = ({ tasks, members, onEdit, onDelete, onUpdateTask, holidays, daysRange }) => { +const TimelineView: React.FC = ({ tasks: filteredTasks, members, onEdit, onDelete, onUpdateTask, holidays }) => { + const daysRange = usePlanningFiltersStore((s) => s.filterPeriodDays); + const { days, today } = useTimelineDays(daysRange); - const { dragPreview, didDragRef, startDrag, pendingDragUpdate, confirmDrag, cancelDrag, postponeDragToBusinessDay } = usePhaseDrag(tasks, onUpdateTask, holidays); + const { dragPreview, didDragRef, startDrag, pendingDragUpdate, confirmDrag, cancelDrag, postponeDragToBusinessDay } = usePhaseDrag(filteredTasks, onUpdateTask, holidays); const containerRef = useRef(null); - const { setInfoRef, setCalRef } = useRowHeightSync(tasks.length); + const { setInfoRef, setCalRef } = useRowHeightSync(filteredTasks.length); const { infoHeaderRef, calHeaderRef } = useHeaderHeightSync(); return (
- -
- {tasks.length === 0 ? ( + {filteredTasks.length === 0 ? (
Nenhuma demanda no período selecionado.
) : (
- {tasks.map((task) => { + {filteredTasks.map((task) => { const visibleSteps = getVisibleSteps(task); const status = getTaskStatusDisplay(task); @@ -88,10 +88,9 @@ const TimelineView: React.FC = ({ tasks, members, onEdit, onD {/* Fixed info column */}
-
Demanda
- {tasks.length > 0 && tasks.map((task, i) => ( + {filteredTasks.length > 0 && filteredTasks.map((task, i) => (
= ({ tasks, members, onEdit, onD
- {tasks.length === 0 ? ( + {filteredTasks.length === 0 ? (
Nenhuma demanda. Clique em "Nova Demanda" para comecar.
- ) : tasks.map((task, i) => ( + ) : filteredTasks.map((task, i) => ( Date: Thu, 14 May 2026 20:48:48 -0300 Subject: [PATCH 09/12] feat: :sparkles: adicionado view de kanban --- docs/components/ui.md | 26 ++ docs/views/planning.md | 8 +- package-lock.json | 77 +++--- package.json | 1 + src/components/AppRouter.tsx | 4 +- src/components/AppSidebar.tsx | 2 +- src/components/ui/Tabs.tsx | 4 +- src/components/ui/ViewTabs.tsx | 39 +++ src/hooks/app/useAppNavigation.ts | 2 + src/index.css | 16 +- src/lib/accessControl.ts | 1 + src/store/useUIStore.ts | 1 + src/types/props.ts | 2 +- src/views/kanban/KanbanView.tsx | 236 ++++++++++++++++++ src/views/kanban/index.ts | 1 + src/views/planning/PlanningView.tsx | 7 +- .../components/PlanningViewHeader.tsx | 4 +- .../planning/components/TasksFilters.tsx | 23 +- 18 files changed, 386 insertions(+), 68 deletions(-) create mode 100644 src/components/ui/ViewTabs.tsx create mode 100644 src/views/kanban/KanbanView.tsx create mode 100644 src/views/kanban/index.ts diff --git a/docs/components/ui.md b/docs/components/ui.md index a0b7088..f7267a7 100644 --- a/docs/components/ui.md +++ b/docs/components/ui.md @@ -14,4 +14,30 @@ Wrapper sobre `
); From bb387878b7b61c594dc84db625273324aaea3fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Thu, 14 May 2026 21:42:27 -0300 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20:dizzy:=20adicionado=20anima?= =?UTF-8?q?=C3=A7oes=20no=20drag=20kanban?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hooks/supabase.md | 2 +- src/components/AppLayout.tsx | 2 +- src/hooks/infra/useSupabase.ts | 3 +- src/views/kanban/KanbanView.tsx | 228 +++++++++++++++++++++++--------- 4 files changed, 166 insertions(+), 69 deletions(-) diff --git a/docs/hooks/supabase.md b/docs/hooks/supabase.md index 589fb0a..2ca54ec 100644 --- a/docs/hooks/supabase.md +++ b/docs/hooks/supabase.md @@ -13,7 +13,7 @@ Hook de **mutations apenas**. Não armazena estado — após cada operação usa - `updateTask(data)` — update otimista no cache do TanStack Query, depois persiste no DB; reverte em caso de erro - `deleteTask(id)` — remove do DB e atualiza o cache local sem re-fetch -As três funções são envolvidas por `useThrottledMutation` (500ms) antes de serem expostas. Chamadas mais rápidas que o intervalo são rejeitadas com toast de aviso e retornam `false`. +`createTask` e `deleteTask` são envolvidas por `useThrottledMutation` (500ms) antes de serem expostas — chamadas mais rápidas que o intervalo são rejeitadas com toast de aviso e retornam `false`. `updateTask` é exposto sem throttle, pois é chamado de forma intencional (drag-and-drop, edição inline). ## Rate Limiting (client-side) diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index c124c5b..9a8c1b5 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -103,7 +103,7 @@ export function AppLayout(props: AppLayoutProps) { key={["calendar","timeline","list","demandas","kanban"].includes(view) ? "planning" : view} className={cn( "flex-1 overflow-auto animate-blur-fade-in", - view === "home" ? "p-0" : "px-4 sm:px-6 lg:px-8 py-8" + view === "home" ? "p-0" : "px-4 sm:px-6 lg:px-8 py-8", )} > diff --git a/src/hooks/infra/useSupabase.ts b/src/hooks/infra/useSupabase.ts index fd133ec..0d474c7 100644 --- a/src/hooks/infra/useSupabase.ts +++ b/src/hooks/infra/useSupabase.ts @@ -382,8 +382,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { }, [clientId, isAdmin, memberId, queryClient]) const throttledCreateTask = useThrottledMutation(createTask, MUTATION_THROTTLE_MS) - const throttledUpdateTask = useThrottledMutation(updateTask, MUTATION_THROTTLE_MS) const throttledDeleteTask = useThrottledMutation(deleteTask, MUTATION_THROTTLE_MS) - return { createTask: throttledCreateTask, updateTask: throttledUpdateTask, deleteTask: throttledDeleteTask } + return { createTask: throttledCreateTask, updateTask, deleteTask: throttledDeleteTask } } diff --git a/src/views/kanban/KanbanView.tsx b/src/views/kanban/KanbanView.tsx index 5f3dc29..dbd5a94 100644 --- a/src/views/kanban/KanbanView.tsx +++ b/src/views/kanban/KanbanView.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Calendar, Lock, GripVertical } from 'lucide-react'; +import { Calendar, Lock, GripVertical, Layers } from 'lucide-react'; import { STEP_META } from '@/lib/steps'; import type { Task, Subtask, SubtaskProgressStatus } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; @@ -27,16 +27,20 @@ interface KanbanCardProps { members: Member[]; onEdit: (task: Task) => void; onDragStart: (e: React.DragEvent, taskId: string, subtaskId: string) => void; + onDragEnd: () => void; + isDragging: boolean; } -function KanbanCard({ item, members, onEdit, onDragStart }: KanbanCardProps) { +function KanbanCard({ item, members, onEdit, onDragStart, onDragEnd, isDragging }: KanbanCardProps) { const { task, subtask } = item; const meta = STEP_META[subtask.status] ?? STEP_META['desenvolvimento']; const isBlocked = task.status?.blocked; - const cardStyle = isBlocked - ? 'bg-red-50 border-red-300 text-red-900 dark:bg-red-950/30 dark:border-red-800/80 dark:text-red-100' - : 'bg-white border-zinc-200 text-zinc-900 dark:bg-zinc-900/80 dark:border-zinc-700/50 dark:text-zinc-100'; + const cardStyle = isDragging + ? 'bg-indigo-50 border-indigo-400 text-zinc-900 dark:bg-indigo-950/40 dark:border-indigo-500/70 dark:text-zinc-100 opacity-60 scale-[0.97] rotate-1 shadow-xl' + : isBlocked + ? 'bg-red-50 border-red-300 text-red-900 dark:bg-red-950/30 dark:border-red-800/80 dark:text-red-100' + : 'bg-white border-zinc-200 text-zinc-900 dark:bg-zinc-900/80 dark:border-zinc-700/50 dark:text-zinc-100'; const assignedMembers = members.filter((m) => subtask.assignees?.includes(m.id)); @@ -44,11 +48,13 @@ function KanbanCard({ item, members, onEdit, onDragStart }: KanbanCardProps) {
onDragStart(e, task.id, subtask.id)} - onClick={() => onEdit(task)} + onDragEnd={onDragEnd} + onClick={() => !isDragging && onEdit(task)} className={` group relative flex flex-col gap-2.5 p-3.5 mb-3 rounded-xl border - cursor-grab active:cursor-grabbing hover:brightness-[1.03] transition-all - shadow-sm hover:shadow-md animate-in fade-in-0 zoom-in-95 duration-200 + cursor-grab active:cursor-grabbing hover:brightness-[1.03] transition-all duration-150 + shadow-sm hover:shadow-md animate-in fade-in-0 zoom-in-95 + ${isDragging ? 'animate-[wiggle_0.6s_ease-in-out_infinite]' : 'duration-200'} ${cardStyle} `} > @@ -110,6 +116,67 @@ function KanbanCard({ item, members, onEdit, onDragStart }: KanbanCardProps) { ); } +interface EmptyColumnsGroupProps { + columns: typeof COLUMNS; + isDragging: boolean; +} + +function EmptyColumnsGroup({ columns, isDragging }: EmptyColumnsGroupProps) { + const isHidden = isDragging || columns.length === 0; + + const CARD_W = 300; + const STACK_OFFSET = 12; + const stackWidth = CARD_W + (columns.length - 1) * STACK_OFFSET; + + return ( +
+
+ +

Fases Vazias

+ + {columns.length} + +
+ +
+ {[...columns].reverse().map((col, revIdx) => { + const i = columns.length - 1 - revIdx; + return ( +
+
+ {col.label} + + Vazia + +
+ ); + })} +
+
+ ); +} + interface KanbanViewProps { tasks: Task[]; members: Member[]; @@ -125,16 +192,16 @@ export function KanbanView({ tasks, members, onEdit, onUpdateTask }: KanbanViewP setBoardTasks(tasks); } - // Demandas mãe concluídas ficam fora do Kanban const activeTasks = boardTasks.filter((t) => !t.concludedAt); - // Expande tasks em itens individuais por subtask ativa const allItems: KanbanItem[] = activeTasks.flatMap((task) => (task.subtasks ?? []) .filter((s) => s.active) .map((subtask) => ({ task, subtask })), ); + const handleDragEnd = () => setDragged(null); + const handleDragStart = (e: React.DragEvent, taskId: string, subtaskId: string) => { setDragged({ taskId, subtaskId }); e.dataTransfer.effectAllowed = 'move'; @@ -149,17 +216,26 @@ export function KanbanView({ tasks, members, onEdit, onUpdateTask }: KanbanViewP e.preventDefault(); if (!dragged) return; + const draggedTask = boardTasks.find((t) => t.id === dragged.taskId); + if (!draggedTask) { setDragged(null); return; } + + const draggedSubtask = draggedTask.subtasks.find((s) => s.id === dragged.subtaskId); + if ((draggedSubtask?.progressStatus ?? 'todo') === columnId) { + setDragged(null); + return; + } + + const updatedTask = { + ...draggedTask, + subtasks: draggedTask.subtasks.map((s) => + s.id === dragged.subtaskId ? { ...s, progressStatus: columnId } : s, + ), + }; + setBoardTasks((prev) => - prev.map((task) => { - if (task.id !== dragged.taskId) return task; - const updatedSubtasks = task.subtasks.map((s) => - s.id === dragged.subtaskId ? { ...s, progressStatus: columnId } : s, - ); - const updatedTask = { ...task, subtasks: updatedSubtasks }; - onUpdateTask(updatedTask); - return updatedTask; - }), + prev.map((t) => (t.id === dragged.taskId ? updatedTask : t)), ); + onUpdateTask(updatedTask); setDragged(null); }; @@ -168,57 +244,72 @@ export function KanbanView({ tasks, members, onEdit, onUpdateTask }: KanbanViewP return acc; }, {}); - const DEFAULT_VISIBLE = new Set(['todo', 'ready', 'in-progress', 'in-review']); - const visibleColumns = COLUMNS.filter( - (col) => DEFAULT_VISIBLE.has(col.id) || (columnsData[col.id]?.length ?? 0) > 0, - ); + const isDragging = dragged !== null; - return ( -
-
- {visibleColumns.map((col) => ( -
handleDrop(e, col.id)} - > -
-
-

{col.label}

- - {columnsData[col.id]?.length ?? 0} - -
+ const stackedColumns = isDragging ? [] : COLUMNS.filter((col) => (columnsData[col.id]?.length ?? 0) === 0); + return ( +
+
+ {COLUMNS.map((col) => { + const isEmpty = (columnsData[col.id]?.length ?? 0) === 0; + const isCollapsed = isEmpty && !isDragging; + return (
- {columnsData[col.id]?.map((item) => ( - - ))} - - {columnsData[col.id]?.length === 0 && ( -
- - Nenhuma subtask - -
- )} +
+
+

{col.label}

+ + {columnsData[col.id]?.length ?? 0} + +
+ +
handleDrop(e, col.id)} + className={` + flex-1 min-h-0 flex flex-col p-2 rounded-2xl border transition-colors duration-300 + ${isDragging + ? 'bg-zinc-100/80 border-zinc-300 border-dashed dark:bg-white/[0.04] dark:border-white/10' + : 'bg-zinc-100/60 border-zinc-200/80 dark:bg-white/[0.025] dark:border-white/[0.06]'} + `} + > + {columnsData[col.id]?.map((item) => ( + + ))} + + {columnsData[col.id]?.length === 0 && ( +
+ + Nenhuma subtask + +
+ )} +
-
- ))} + ); + })} + +
); From d4fc5bb2479f718c7d5db11f7b3bd48039a6ad0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Thu, 14 May 2026 21:55:54 -0300 Subject: [PATCH 12/12] feat: :rotating_light: ajsute de lint --- src/lib/steps.ts | 2 +- src/types/props.ts | 4 ++-- src/views/calendar/hooks/useCalendarDrag.ts | 4 ++-- src/views/planning/components/PlanningViewHeader.tsx | 5 +++-- src/views/reports/utils.ts | 8 ++++---- src/views/timeline/components/TaskInfoPanel.tsx | 4 ++-- src/views/timeline/components/TaskRow.tsx | 8 ++++---- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/lib/steps.ts b/src/lib/steps.ts index a2fc196..cf1ef2f 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -307,7 +307,7 @@ export function migrateLegacyTask(task: LegacyTask): { status: TaskStatus; subta return { status, subtasks }; } - const legacyMap: Partial> = { + const legacyMap: Record = { design: 'design', approval: 'aprovacao-design', dev: 'desenvolvimento', diff --git a/src/types/props.ts b/src/types/props.ts index 53a321c..f8ba1ed 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -6,9 +6,9 @@ */ import type { Task } from '../lib/steps'; +import type { ViewType } from '@/store/useUIStore'; import type { Member } from '@/hooks/infra/useSupabase'; import type { Holiday } from '../utils/holidayUtils'; -import type { CalendarViewMode } from '../utils/dashboardUtils'; export type TaskModalPayload = Omit & Partial>; @@ -30,7 +30,7 @@ export interface TaskModalProps { export interface PlanningViewProps { subview: 'calendar' | 'timeline' | 'list' | 'demandas' | 'kanban'; - onViewChange: (view: string) => void; + onViewChange: (view: ViewType) => void; onEdit: (task: Task) => void; onDelete: (id: string) => void; onUpdateTask: (task: Task) => void; diff --git a/src/views/calendar/hooks/useCalendarDrag.ts b/src/views/calendar/hooks/useCalendarDrag.ts index 90dec42..704bb8b 100644 --- a/src/views/calendar/hooks/useCalendarDrag.ts +++ b/src/views/calendar/hooks/useCalendarDrag.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { normaliseTask, type BarItem, type DragState, type Task, type Step } from '../../../utils/dashboardUtils'; +import { normaliseTask, type BarItem, type DragState, type Task, type Subtask } from '../../../utils/dashboardUtils'; import { usePhaseDrag } from '@/hooks/ui/usePhaseDrag'; import type { Holiday } from '@/utils/holidayUtils'; @@ -23,7 +23,7 @@ export function useCalendarDrag(tasks: Task[], onUpdateTask: (task: Task) => voi const container = (e.currentTarget as HTMLElement).closest('[data-week-row]') as HTMLElement; const colWidth = container ? container.getBoundingClientRect().width / 7 : 80; const norm = normaliseTask(task); - const step = (norm.subtasks as Step[]).find(s => s.id === bar.subtaskId); + const step = (norm.subtasks as Subtask[]).find(s => s.id === bar.subtaskId); if (!step) return; phaseDragStart(e, bar.taskId, bar.subtaskId, type, step, colWidth); }, [phaseDragStart]); diff --git a/src/views/planning/components/PlanningViewHeader.tsx b/src/views/planning/components/PlanningViewHeader.tsx index 1354a54..e880aa6 100644 --- a/src/views/planning/components/PlanningViewHeader.tsx +++ b/src/views/planning/components/PlanningViewHeader.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/Button'; import { Plus } from 'lucide-react'; import { TasksFilters, type FiltersState } from './TasksFilters'; import type { Member } from '@/types/member'; +import type { ViewType } from '@/store/useUIStore'; const DEMAND_TABS = [ { value: 'demandas', label: 'Todas as Demandas' }, @@ -21,7 +22,7 @@ const VIEW_TITLES: Record = { interface PlanningViewHeaderProps { subview: string; - onViewChange: (value: string) => void; + onViewChange: (value: ViewType) => void; onOpenNew: () => void; members: Member[]; // TasksFilters props (demandas) @@ -52,7 +53,7 @@ export function PlanningViewHeader({ return (
- + onViewChange(v as ViewType)}> {DEMAND_TABS.map((tab) => ( diff --git a/src/views/reports/utils.ts b/src/views/reports/utils.ts index 5a5a567..ae76dcd 100644 --- a/src/views/reports/utils.ts +++ b/src/views/reports/utils.ts @@ -1,5 +1,5 @@ import { migrateLegacyTask, getCurrentStep } from '@/lib/steps'; -import type { Task, Step, LegacyTask, StepType } from '@/lib/steps'; +import type { Task, Subtask, LegacyTask, StepType } from '@/lib/steps'; import type { Member } from '@/hooks/infra/useSupabase'; // ─── Pure helpers ───────────────────────────────────────────────────────────── @@ -52,7 +52,7 @@ export function normalizeTask(task: Task | LegacyTask): Task { return { ...(task as object), ...migrated } as Task; } -export function getVisibleSteps(task: Task | LegacyTask): Step[] { +export function getVisibleSteps(task: Task | LegacyTask): Subtask[] { return normalizeTask(task).subtasks.filter(s => s.active && s.start && s.end); } @@ -87,14 +87,14 @@ export function getRisk(task: Task | LegacyTask, today: string): 'ok' | 'risco' // ─── Types ──────────────────────────────────────────────────────────────────── export type EnrichedTask = Task & { - visibleSteps: Step[]; + visibleSteps: Subtask[]; lastDeadline: string | null; firstStart: string | null; risk: 'ok' | 'risco' | 'atrasado' | 'concluido'; daysLeft: number; bizLeft: number; taskMembers: Member[]; - currentStep: Step | null; + currentStep: Subtask | null; progress: number; isBlocked: boolean; leadTime: number | null; diff --git a/src/views/timeline/components/TaskInfoPanel.tsx b/src/views/timeline/components/TaskInfoPanel.tsx index bbd90ff..3af9f18 100644 --- a/src/views/timeline/components/TaskInfoPanel.tsx +++ b/src/views/timeline/components/TaskInfoPanel.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { Edit2, Trash2, AlertTriangle, CheckCircle2 } from 'lucide-react'; -import { normaliseTask, getTaskStatusDisplay, type Task, type Step } from '@/utils/dashboardUtils'; +import { normaliseTask, getTaskStatusDisplay, type Task, type Subtask } from '@/utils/dashboardUtils'; import type { Member } from '@/types/member'; interface TaskInfoPanelProps { task: Task; members: Member[]; - visibleSteps: Step[]; + visibleSteps: Subtask[]; totalH: number; onEdit: (t: Task) => void; onDelete: (id: string) => void; diff --git a/src/views/timeline/components/TaskRow.tsx b/src/views/timeline/components/TaskRow.tsx index 9fc6744..0c172f9 100644 --- a/src/views/timeline/components/TaskRow.tsx +++ b/src/views/timeline/components/TaskRow.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { getVisibleSteps, type DragPreview, type DragState, type Task, type Step, type StepType } from '@/utils/dashboardUtils'; +import { getVisibleSteps, type DragPreview, type DragState, type Task, type Subtask } from '@/utils/dashboardUtils'; import type { Member } from '@/types/member'; import TaskInfoPanel from './TaskInfoPanel'; import StepRow from './StepRow'; @@ -13,7 +13,7 @@ interface TaskRowProps { daysRange: number; dragPreview: DragPreview | null; didDragRef: React.MutableRefObject; - startDrag: (e: React.MouseEvent, taskId: string, stepType: StepType, type: DragState['type'], step: Step, colWidth: number) => void; + startDrag: (e: React.MouseEvent, taskId: string, subtaskId: string, type: DragState['type'], subtask: Subtask, colWidth: number) => void; onEdit: (t: Task) => void; onDelete: (id: string) => void; } @@ -35,7 +35,7 @@ const TaskRow: React.FC = ({ task, members, days, daysRange, dragP
{visibleSteps.length === 0 ? ( = ({ task, members, days, daysRange, dragP ) : visibleSteps.map(step => (