Commit 987e8471 authored by Paulo Avila's avatar Paulo Avila
Browse files

Feature migração de subtarefas

parent 7ab38757
-- =============================================================================
-- Migração: subtarefas (dbo.Subtasks) -> tarefas-filhas em dbo.Tasks (coluna Entry)
-- =============================================================================
-- A estrutura antiga guardava subtarefas em dbo.Subtasks (Id, TaskId, Title,
-- IsDone). A nova trata subtarefa como uma tarefa em dbo.Tasks com Entry = Id
-- da tarefa-mãe.
--
-- IDEMPOTENTE: ao final renomeia dbo.Subtasks -> dbo.Subtasks_migrated (backup).
-- Rodar de novo não faz nada. A cópia + rename ficam numa transação atômica.
--
-- ATENÇÃO PROD: faça BACKUP do banco antes. Use um login com permissão de DDL
-- (sa ou db_owner) — ALTER TABLE / CREATE INDEX / sp_rename exigem isso.
-- Execute no banco TASK.
-- =============================================================================
SET NOCOUNT ON;
SET XACT_ABORT ON;
GO
-- 1) Garante a coluna Entry (idempotente)
IF COL_LENGTH('dbo.Tasks', 'Entry') IS NULL
ALTER TABLE dbo.Tasks ADD Entry INT NULL;
GO
-- 2) Índice para consultas por tarefa-mãe (idempotente)
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Tasks_Entry')
CREATE INDEX IX_Tasks_Entry ON dbo.Tasks (Entry);
GO
-- 3) Copia as subtarefas antigas como tarefas-filhas e arquiva a tabela antiga.
-- (a coluna Entry já existe e está commitada pelos lotes acima)
IF OBJECT_ID('dbo.Subtasks', 'U') IS NOT NULL
BEGIN
BEGIN TRANSACTION;
INSERT INTO dbo.Tasks
(WorkspaceId, Title, Description, Status, Priority, Entry,
AssigneeId, CreatedById, StartDate, DueDate, Progress, CreatedAt, UpdatedAt)
SELECT t.WorkspaceId,
s.Title,
NULL,
CASE WHEN s.IsDone = 1 THEN 'done' ELSE 'todo' END,
'medium',
s.TaskId, -- Entry = id da tarefa-mãe
NULL, -- responsável: defina depois se quiser
t.CreatedById, -- herda o criador da mãe
NULL, NULL,
CASE WHEN s.IsDone = 1 THEN 100 ELSE 0 END,
s.CreatedAt,
SYSUTCDATETIME()
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId; -- ignora subtarefas órfãs
PRINT CONCAT('Subtarefas migradas: ', @@ROWCOUNT);
EXEC sp_rename 'dbo.Subtasks', 'Subtasks_migrated';
COMMIT TRANSACTION;
PRINT 'Migração concluída. Tabela antiga arquivada como dbo.Subtasks_migrated.';
END
ELSE
PRINT 'Nada a migrar: dbo.Subtasks não existe (já migrado).';
GO
-- Conferência (opcional):
-- SELECT Entry AS TarefaMae, COUNT(*) AS Subtarefas
-- FROM dbo.Tasks WHERE Entry IS NOT NULL GROUP BY Entry;
...@@ -7,7 +7,7 @@ import { ...@@ -7,7 +7,7 @@ import {
listSubtasksByWorkspace, listSubtasksByWorkspace,
listTasksByWorkspace, listTasksByWorkspace,
listWorkspaceMembers, listWorkspaceMembers,
type Subtask, type Task,
} from "@/lib/data"; } from "@/lib/data";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -57,9 +57,9 @@ export default async function MemberFolderPage({ ...@@ -57,9 +57,9 @@ export default async function MemberFolderPage({
); );
const taskIds = new Set(tasks.map((t) => t.Id)); const taskIds = new Set(tasks.map((t) => t.Id));
const subtasksByTask = allSubtasks const subtasksByTask = allSubtasks
.filter((s) => taskIds.has(s.TaskId)) .filter((s) => s.Entry != null && taskIds.has(s.Entry))
.reduce<Record<number, Subtask[]>>((acc, subtask) => { .reduce<Record<number, Task[]>>((acc, subtask) => {
(acc[subtask.TaskId] ??= []).push(subtask); (acc[subtask.Entry as number] ??= []).push(subtask);
return acc; return acc;
}, {}); }, {});
......
...@@ -6,7 +6,7 @@ import { ...@@ -6,7 +6,7 @@ import {
listSubtasksByWorkspace, listSubtasksByWorkspace,
listTasksByWorkspace, listTasksByWorkspace,
listWorkspaceMembers, listWorkspaceMembers,
type Subtask, type Task,
} from "@/lib/data"; } from "@/lib/data";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
...@@ -38,9 +38,9 @@ export default async function WorkspacePage({ ...@@ -38,9 +38,9 @@ export default async function WorkspacePage({
listSubtasksByWorkspace(workspaceId), listSubtasksByWorkspace(workspaceId),
]); ]);
const subtasksByTask = subtasks.reduce<Record<number, Subtask[]>>( const subtasksByTask = subtasks.reduce<Record<number, Task[]>>(
(acc, subtask) => { (acc, subtask) => {
(acc[subtask.TaskId] ??= []).push(subtask); if (subtask.Entry != null) (acc[subtask.Entry] ??= []).push(subtask);
return acc; return acc;
}, },
{} {}
......
"use client";
import { useState, useTransition } from "react";
import { Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
createSubtaskAction,
deleteSubtaskAction,
toggleSubtaskAction,
} from "@/lib/actions";
import type { Subtask } from "@/lib/data";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
export function SubtaskList({
taskId,
subtasks,
}: {
taskId: number;
subtasks: Subtask[];
}) {
const [title, setTitle] = useState("");
const [pending, startTransition] = useTransition();
const total = subtasks.length;
const done = subtasks.filter((s) => s.IsDone).length;
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
function handleAdd() {
const value = title.trim();
if (!value) return;
startTransition(async () => {
const result = await createSubtaskAction(taskId, value);
if (result.error) toast.error(result.error);
else setTitle("");
});
}
function handleToggle(subtaskId: number, isDone: boolean) {
startTransition(async () => {
const result = await toggleSubtaskAction(subtaskId, isDone);
if (result.error) toast.error(result.error);
});
}
function handleDelete(subtaskId: number) {
startTransition(async () => {
const result = await deleteSubtaskAction(subtaskId);
if (result.error) toast.error(result.error);
});
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Subtarefas</Label>
{total > 0 && (
<span className="text-xs text-muted-foreground">
{done}/{total} · {percent}% concluído
</span>
)}
</div>
{total > 0 && <Progress value={percent} />}
<ul className="max-h-44 space-y-1.5 overflow-y-auto">
{subtasks.map((subtask) => (
<li
key={subtask.Id}
className="group flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<Checkbox
checked={subtask.IsDone}
disabled={pending}
onCheckedChange={(checked) =>
handleToggle(subtask.Id, checked === true)
}
/>
<span
className={cn(
"flex-1 truncate text-sm",
subtask.IsDone && "text-muted-foreground line-through"
)}
>
{subtask.Title}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
disabled={pending}
onClick={() => handleDelete(subtask.Id)}
title="Excluir subtarefa"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
<div className="flex gap-2">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nova subtarefa..."
className="h-8"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
/>
<Button
type="button"
size="sm"
variant="secondary"
disabled={pending || !title.trim()}
onClick={handleAdd}
>
<Plus className="mr-1 h-4 w-4" />
Adicionar
</Button>
</div>
</div>
);
}
import { CalendarClock, CalendarPlus, Pencil } from "lucide-react"; import { CalendarClock, CalendarPlus, Pencil } from "lucide-react";
import { TASK_STATUSES } from "@/lib/constants"; import { TASK_STATUSES } from "@/lib/constants";
import type { Subtask, Task, WorkspaceMember } from "@/lib/data"; import type { Task, WorkspaceMember } from "@/lib/data";
import { formatDate } from "@/lib/dates"; import { formatDate } from "@/lib/dates";
import { canEditTask } from "@/lib/permissions"; import { canEditTask } from "@/lib/permissions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -26,7 +26,7 @@ export function TaskBoard({ ...@@ -26,7 +26,7 @@ export function TaskBoard({
tasks: Task[]; tasks: Task[];
members: WorkspaceMember[]; members: WorkspaceMember[];
workspaceId: number; workspaceId: number;
subtasksByTask: Record<number, Subtask[]>; subtasksByTask: Record<number, Task[]>;
currentUserId: number; currentUserId: number;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
...@@ -64,7 +64,7 @@ export function TaskBoard({ ...@@ -64,7 +64,7 @@ export function TaskBoard({
workspaceId={workspaceId} workspaceId={workspaceId}
members={members} members={members}
task={toFormValues(task)} task={toFormValues(task)}
subtasks={subtasksByTask[task.Id] ?? []} hasSubtasks={(subtasksByTask[task.Id] ?? []).length > 0}
currentUserId={currentUserId} currentUserId={currentUserId}
isAdmin={isAdmin} isAdmin={isAdmin}
trigger={ trigger={
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ChevronRight, Plus, Search, UserRound } from "lucide-react"; import { ChevronRight, Plus, Search, UserRound } from "lucide-react";
import type { Subtask, Task, WorkspaceMember } from "@/lib/data"; import type { Task, WorkspaceMember } from "@/lib/data";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
...@@ -39,7 +39,6 @@ export function TaskByPerson({ ...@@ -39,7 +39,6 @@ export function TaskByPerson({
workspaceId: number; workspaceId: number;
currentUserId: number; currentUserId: number;
isAdmin: boolean; isAdmin: boolean;
subtasksByTask?: Record<number, Subtask[]>;
}) { }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
......
...@@ -9,9 +9,7 @@ import { ...@@ -9,9 +9,7 @@ import {
type ActionState, type ActionState,
} from "@/lib/actions"; } from "@/lib/actions";
import { TASK_PRIORITIES, TASK_STATUSES } from "@/lib/constants"; import { TASK_PRIORITIES, TASK_STATUSES } from "@/lib/constants";
import type { Subtask, WorkspaceMember } from "@/lib/data"; import type { WorkspaceMember } from "@/lib/data";
import { Separator } from "@/components/ui/separator";
import { SubtaskList } from "./subtask-list";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
...@@ -45,7 +43,13 @@ export interface TaskFormValues { ...@@ -45,7 +43,13 @@ export interface TaskFormValues {
progress: number; progress: number;
} }
function SubmitButton({ isEdit }: { isEdit: boolean }) { function SubmitButton({
isEdit,
isSubtask,
}: {
isEdit: boolean;
isSubtask: boolean;
}) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
return ( return (
<Button type="submit" disabled={pending} className="w-full"> <Button type="submit" disabled={pending} className="w-full">
...@@ -53,7 +57,9 @@ function SubmitButton({ isEdit }: { isEdit: boolean }) { ...@@ -53,7 +57,9 @@ function SubmitButton({ isEdit }: { isEdit: boolean }) {
? "Salvando..." ? "Salvando..."
: isEdit : isEdit
? "Salvar alterações" ? "Salvar alterações"
: "Criar tarefa"} : isSubtask
? "Criar subtarefa"
: "Criar tarefa"}
</Button> </Button>
); );
} }
...@@ -64,7 +70,8 @@ export function TaskDialog({ ...@@ -64,7 +70,8 @@ export function TaskDialog({
workspaceId, workspaceId,
members, members,
task, task,
subtasks, hasSubtasks = false,
entryId,
defaultAssigneeId, defaultAssigneeId,
currentUserId, currentUserId,
isAdmin, isAdmin,
...@@ -73,12 +80,16 @@ export function TaskDialog({ ...@@ -73,12 +80,16 @@ export function TaskDialog({
workspaceId: number; workspaceId: number;
members: WorkspaceMember[]; members: WorkspaceMember[];
task?: TaskFormValues; task?: TaskFormValues;
subtasks?: Subtask[]; /** True quando a tarefa-mãe possui subtarefas (progresso vem delas). */
hasSubtasks?: boolean;
/** Id da tarefa-mãe ao criar uma subtarefa. */
entryId?: number;
defaultAssigneeId?: number; defaultAssigneeId?: number;
currentUserId: number; currentUserId: number;
isAdmin: boolean; isAdmin: boolean;
trigger: ReactNode; trigger: ReactNode;
}) { }) {
const isSubtask = entryId !== undefined && !task;
// Membros comuns só podem atribuir tarefas a si mesmos; mantém o // Membros comuns só podem atribuir tarefas a si mesmos; mantém o
// responsável atual visível em modo edição. // responsável atual visível em modo edição.
const selectableMembers = isAdmin const selectableMembers = isAdmin
...@@ -105,11 +116,19 @@ export function TaskDialog({ ...@@ -105,11 +116,19 @@ export function TaskDialog({
<DialogTrigger asChild>{trigger}</DialogTrigger> <DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{task ? "Editar tarefa" : "Nova tarefa"}</DialogTitle> <DialogTitle>
{task
? "Editar tarefa"
: isSubtask
? "Nova subtarefa"
: "Nova tarefa"}
</DialogTitle>
<DialogDescription> <DialogDescription>
{task {task
? "Atualize as informações da tarefa." ? "Atualize as informações da tarefa."
: "Descreva a tarefa, defina prazos e o responsável."} : isSubtask
? "A subtarefa tem os mesmos atributos de uma tarefa."
: "Descreva a tarefa, defina prazos e o responsável."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form action={formAction} className="space-y-4"> <form action={formAction} className="space-y-4">
...@@ -119,6 +138,9 @@ export function TaskDialog({ ...@@ -119,6 +138,9 @@ export function TaskDialog({
</Alert> </Alert>
)} )}
<input type="hidden" name="workspaceId" value={workspaceId} /> <input type="hidden" name="workspaceId" value={workspaceId} />
{entryId !== undefined && (
<input type="hidden" name="entry" value={entryId} />
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="task-title">Título</Label> <Label htmlFor="task-title">Título</Label>
<Input <Input
...@@ -223,22 +245,16 @@ export function TaskDialog({ ...@@ -223,22 +245,16 @@ export function TaskDialog({
min={0} min={0}
max={100} max={100}
defaultValue={task?.progress ?? 0} defaultValue={task?.progress ?? 0}
disabled={(subtasks?.length ?? 0) > 0} disabled={hasSubtasks}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(subtasks?.length ?? 0) > 0 {hasSubtasks
? "Calculado automaticamente pelas subtarefas." ? "Calculado automaticamente pelas subtarefas."
: "Digite quanto da tarefa já foi concluído (0 a 100)."} : "Digite quanto da tarefa já foi concluído (0 a 100)."}
</p> </p>
</div> </div>
<SubmitButton isEdit={Boolean(task)} /> <SubmitButton isEdit={Boolean(task)} isSubtask={isSubtask} />
</form> </form>
{task && (
<>
<Separator />
<SubtaskList taskId={task.id} subtasks={subtasks ?? []} />
</>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
......
"use client"; "use client";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { CheckCircle2, ChevronRight, Circle, Pencil } from "lucide-react"; import { AlertTriangle, ChevronRight, Pencil, Plus } from "lucide-react";
import type { Subtask, Task, WorkspaceMember } from "@/lib/data"; import type { Task, WorkspaceMember } from "@/lib/data";
import { TASK_STATUSES } from "@/lib/constants"; import { TASK_STATUSES } from "@/lib/constants";
import { formatDate, getDueInfo } from "@/lib/dates"; import { formatDate, getDueInfo } from "@/lib/dates";
import { AlertTriangle } from "lucide-react";
import { canEditTask } from "@/lib/permissions"; import { canEditTask } from "@/lib/permissions";
import { initials, toFormValues } from "./task-utils"; import { initials, toFormValues } from "./task-utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
...@@ -22,7 +21,6 @@ import { cn } from "@/lib/utils"; ...@@ -22,7 +21,6 @@ import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge"; import { DueBadge } from "./due-badge";
import { PriorityBadge } from "./priority-badge"; import { PriorityBadge } from "./priority-badge";
import { StatusBadge } from "./status-badge"; import { StatusBadge } from "./status-badge";
import { SubtaskList } from "./subtask-list";
import { TaskDeleteButton } from "./task-delete-button"; import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog"; import { TaskDialog } from "./task-dialog";
import { TaskProgress } from "./task-progress"; import { TaskProgress } from "./task-progress";
...@@ -42,7 +40,7 @@ export function TaskTable({ ...@@ -42,7 +40,7 @@ export function TaskTable({
tasks: Task[]; tasks: Task[];
members: WorkspaceMember[]; members: WorkspaceMember[];
workspaceId: number; workspaceId: number;
subtasksByTask: Record<number, Subtask[]>; subtasksByTask: Record<number, Task[]>;
currentUserId: number; currentUserId: number;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
...@@ -170,7 +168,7 @@ export function TaskTable({ ...@@ -170,7 +168,7 @@ export function TaskTable({
> >
<TableCell className="min-w-[260px] py-3 align-top"> <TableCell className="min-w-[260px] py-3 align-top">
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5">
{hasSubtasks ? ( {hasSubtasks || editable ? (
<button <button
type="button" type="button"
onClick={() => toggleExpanded(task.Id)} onClick={() => toggleExpanded(task.Id)}
...@@ -187,8 +185,12 @@ export function TaskTable({ ...@@ -187,8 +185,12 @@ export function TaskTable({
isOpen && "rotate-90" isOpen && "rotate-90"
)} )}
/> />
{subtasks.filter((s) => s.IsDone).length}/ {hasSubtasks && (
{subtasks.length} <span className="tabular-nums">
{subtasks.filter((s) => s.Status === "done").length}/
{subtasks.length}
</span>
)}
</button> </button>
) : ( ) : (
<span className="w-5 shrink-0" /> <span className="w-5 shrink-0" />
...@@ -269,7 +271,7 @@ export function TaskTable({ ...@@ -269,7 +271,7 @@ export function TaskTable({
workspaceId={workspaceId} workspaceId={workspaceId}
members={members} members={members}
task={toFormValues(task)} task={toFormValues(task)}
subtasks={subtasks} hasSubtasks={hasSubtasks}
currentUserId={currentUserId} currentUserId={currentUserId}
isAdmin={isAdmin} isAdmin={isAdmin}
trigger={ trigger={
...@@ -293,33 +295,136 @@ export function TaskTable({ ...@@ -293,33 +295,136 @@ export function TaskTable({
</TableRow> </TableRow>
{isOpen && ( {isOpen && (
<TableRow className="border-b bg-muted/20 hover:bg-muted/20 last:border-0"> <TableRow className="border-b bg-muted/20 hover:bg-muted/20 last:border-0">
<TableCell colSpan={COLUMN_COUNT} className="py-3 pl-12"> <TableCell colSpan={COLUMN_COUNT} className="py-3 pl-12 pr-4">
<div className="max-w-md"> <div className="space-y-2">
{editable ? ( <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<SubtaskList taskId={task.Id} subtasks={subtasks} /> Subtarefas
) : ( </p>
<ul className="space-y-1.5"> {subtasks.length === 0 && (
{subtasks.map((subtask) => ( <p className="text-sm text-muted-foreground">
<li Nenhuma subtarefa ainda.
key={subtask.Id} </p>
className="flex items-center gap-2 text-sm" )}
> {subtasks.map((sub) => {
{subtask.IsDone ? ( const subEditable = canEditTask(
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" /> sub,
) : ( currentUserId,
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" /> isAdmin
);
return (
<div
key={sub.Id}
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border bg-card px-3 py-2"
>
<div className="min-w-[180px] flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium">
{sub.Title}
</span>
<PriorityBadge priority={sub.Priority} />
</div>
{sub.Description && (
<p className="mt-0.5 text-xs text-muted-foreground">
{sub.Description}
</p>
)} )}
<span </div>
className={cn( {subEditable ? (
subtask.IsDone && <TaskStatusSelect
"text-muted-foreground line-through" taskId={sub.Id}
)} status={sub.Status}
/>
) : (
<StatusBadge status={sub.Status} />
)}
{subEditable ? (
<TaskProgressEditor
taskId={sub.Id}
status={sub.Status}
subtaskCount={sub.SubtaskCount}
subtaskDone={sub.SubtaskDone}
manualProgress={sub.Progress}
/>
) : (
<TaskProgress
status={sub.Status}
subtaskCount={sub.SubtaskCount}
subtaskDone={sub.SubtaskDone}
manualProgress={sub.Progress}
/>
)}
{sub.AssigneeName ? (
<div
className="flex items-center gap-1.5"
title={sub.AssigneeName}
> >
{subtask.Title} <Avatar className="h-6 w-6">
<AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary">
{initials(sub.AssigneeName)}
</AvatarFallback>
</Avatar>
</div>
) : (
<span className="text-xs text-muted-foreground">
Sem responsável
</span> </span>
</li> )}
))} <div className="flex items-center gap-1.5">
</ul> <span className="text-xs text-muted-foreground">
{formatDate(sub.DueDate)}
</span>
<DueBadge
dueDate={sub.DueDate}
status={sub.Status}
/>
</div>
{subEditable && (
<div className="flex items-center">
<TaskDialog
workspaceId={workspaceId}
members={members}
task={toFormValues(sub)}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
title="Editar subtarefa"
>
<Pencil className="h-4 w-4" />
</Button>
}
/>
<TaskDeleteButton
taskId={sub.Id}
taskTitle={sub.Title}
/>
</div>
)}
</div>
);
})}
{editable && (
<TaskDialog
workspaceId={workspaceId}
members={members}
entryId={task.Id}
defaultAssigneeId={task.AssigneeId ?? undefined}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="outline"
size="sm"
className="mt-1"
>
<Plus className="mr-1.5 h-4 w-4" />
Adicionar subtarefa
</Button>
}
/>
)} )}
</div> </div>
</TableCell> </TableCell>
......
...@@ -306,6 +306,8 @@ const taskSchema = z ...@@ -306,6 +306,8 @@ const taskSchema = z
.min(0, "O progresso deve ser entre 0 e 100.") .min(0, "O progresso deve ser entre 0 e 100.")
.max(100, "O progresso deve ser entre 0 e 100.") .max(100, "O progresso deve ser entre 0 e 100.")
.optional(), .optional(),
// Id da tarefa-mãe quando se cria uma subtarefa.
entry: z.coerce.number().int().positive().optional(),
}) })
.refine( .refine(
(t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate, (t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate,
...@@ -323,6 +325,7 @@ function parseTaskForm(formData: FormData) { ...@@ -323,6 +325,7 @@ function parseTaskForm(formData: FormData) {
startDate: formData.get("startDate") || undefined, startDate: formData.get("startDate") || undefined,
dueDate: formData.get("dueDate") || undefined, dueDate: formData.get("dueDate") || undefined,
progress: formData.get("progress") || undefined, progress: formData.get("progress") || undefined,
entry: formData.get("entry") || undefined,
}); });
} }
...@@ -360,6 +363,7 @@ export async function createTaskAction( ...@@ -360,6 +363,7 @@ export async function createTaskAction(
startDate: parsed.data.startDate ?? null, startDate: parsed.data.startDate ?? null,
dueDate: parsed.data.dueDate ?? null, dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? 0, progress: parsed.data.progress ?? 0,
entry: parsed.data.entry ?? null,
}, },
user.id user.id
); );
...@@ -464,77 +468,6 @@ export async function updateTaskProgressAction( ...@@ -464,77 +468,6 @@ export async function updateTaskProgressAction(
return { success: true }; return { success: true };
} }
// ---------- Subtarefas ----------
const subtaskTitleSchema = z
.string()
.min(1, "Informe o título da subtarefa.")
.max(200, "Título muito longo.");
export async function createSubtaskAction(
taskId: number,
title: string
): Promise<ActionState> {
const user = await requireUser();
const parsed = subtaskTitleSchema.safeParse(title?.trim());
if (!parsed.success) return { error: firstError(parsed.error) };
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await data.createSubtask(taskId, parsed.data);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao criar a subtarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function toggleSubtaskAction(
subtaskId: number,
isDone: boolean
): Promise<ActionState> {
const user = await requireUser();
try {
const subtask = await data.getSubtaskWithWorkspace(subtaskId);
if (!subtask) return { error: "Subtarefa não encontrada." };
await assertCanEditTask(subtask, user);
await data.setSubtaskDone(subtaskId, isDone);
revalidatePath(`/workspaces/${subtask.WorkspaceId}`);
} catch (err) {
return {
error:
err instanceof Error ? err.message : "Erro ao atualizar a subtarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function deleteSubtaskAction(
subtaskId: number
): Promise<ActionState> {
const user = await requireUser();
try {
const subtask = await data.getSubtaskWithWorkspace(subtaskId);
if (!subtask) return { error: "Subtarefa não encontrada." };
await assertCanEditTask(subtask, user);
await data.deleteSubtask(subtaskId);
revalidatePath(`/workspaces/${subtask.WorkspaceId}`);
} catch (err) {
return {
error:
err instanceof Error ? err.message : "Erro ao excluir a subtarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function deleteTaskAction(taskId: number): Promise<ActionState> { export async function deleteTaskAction(taskId: number): Promise<ActionState> {
const user = await requireUser(); const user = await requireUser();
try { try {
......
...@@ -33,6 +33,7 @@ export interface Task { ...@@ -33,6 +33,7 @@ export interface Task {
WorkspaceName?: string; WorkspaceName?: string;
Title: string; Title: string;
Description: string | null; Description: string | null;
Entry?: number | null;
Status: string; Status: string;
Priority: string; Priority: string;
AssigneeId: number | null; AssigneeId: number | null;
...@@ -46,13 +47,6 @@ export interface Task { ...@@ -46,13 +47,6 @@ export interface Task {
SubtaskDone: number; SubtaskDone: number;
} }
export interface Subtask {
Id: number;
TaskId: number;
Title: string;
IsDone: boolean;
}
// ---------- Usuários ---------- // ---------- Usuários ----------
export async function findUserByEmail( export async function findUserByEmail(
...@@ -147,8 +141,8 @@ export async function listWorkspacesForUser( ...@@ -147,8 +141,8 @@ export async function listWorkspacesForUser(
const result = await pool.request().input("userId", sql.Int, userId).query( const result = await pool.request().input("userId", sql.Int, userId).query(
`SELECT w.Id, w.Name, w.Description, w.Color, w.OwnerId, `SELECT w.Id, w.Name, w.Description, w.Color, w.OwnerId,
wm.Role AS MemberRole, wm.Role AS MemberRole,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id) AS TaskCount, (SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id AND t.Entry IS NULL) AS TaskCount,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id AND t.Status = 'done') AS DoneCount (SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id AND t.Entry IS NULL AND t.Status = 'done') AS DoneCount
FROM dbo.Workspaces w FROM dbo.Workspaces w
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id
WHERE wm.UserId = @userId WHERE wm.UserId = @userId
...@@ -261,10 +255,11 @@ export async function removeWorkspaceMember( ...@@ -261,10 +255,11 @@ export async function removeWorkspaceMember(
const TASK_SELECT = ` const TASK_SELECT = `
SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority, SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority,
t.Entry,
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById, t.AssigneeId, a.Name AS AssigneeName, t.CreatedById,
t.StartDate, t.DueDate, t.Progress, t.CreatedAt, w.Name AS WorkspaceName, t.StartDate, t.DueDate, t.Progress, t.CreatedAt, w.Name AS WorkspaceName,
(SELECT COUNT(*) FROM dbo.Subtasks s WHERE s.TaskId = t.Id) AS SubtaskCount, (SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Subtasks s WHERE s.TaskId = t.Id AND s.IsDone = 1) AS SubtaskDone (SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone
FROM dbo.Tasks t FROM dbo.Tasks t
LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`; JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`;
...@@ -278,7 +273,7 @@ export async function listTasksByWorkspace( ...@@ -278,7 +273,7 @@ export async function listTasksByWorkspace(
.input("workspaceId", sql.Int, workspaceId) .input("workspaceId", sql.Int, workspaceId)
.query( .query(
`${TASK_SELECT} `${TASK_SELECT}
WHERE t.WorkspaceId = @workspaceId WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NULL
ORDER BY CASE WHEN t.DueDate IS NULL THEN 1 ELSE 0 END, t.DueDate, t.Id DESC` ORDER BY CASE WHEN t.DueDate IS NULL THEN 1 ELSE 0 END, t.DueDate, t.Id DESC`
); );
return result.recordset; return result.recordset;
...@@ -303,8 +298,8 @@ export interface TaskInput { ...@@ -303,8 +298,8 @@ export interface TaskInput {
startDate: string | null; startDate: string | null;
dueDate: string | null; dueDate: string | null;
progress: number; progress: number;
entry?: number | null;
} }
export async function createTask( export async function createTask(
input: TaskInput, input: TaskInput,
createdById: number createdById: number
...@@ -318,13 +313,14 @@ export async function createTask( ...@@ -318,13 +313,14 @@ export async function createTask(
.input("status", sql.NVarChar(20), input.status) .input("status", sql.NVarChar(20), input.status)
.input("priority", sql.NVarChar(10), input.priority) .input("priority", sql.NVarChar(10), input.priority)
.input("assigneeId", sql.Int, input.assigneeId) .input("assigneeId", sql.Int, input.assigneeId)
.input("entry", sql.Int, input.entry)
.input("startDate", sql.Date, input.startDate) .input("startDate", sql.Date, input.startDate)
.input("dueDate", sql.Date, input.dueDate) .input("dueDate", sql.Date, input.dueDate)
.input("progress", sql.Int, input.progress) .input("progress", sql.Int, input.progress)
.input("createdById", sql.Int, createdById) .input("createdById", sql.Int, createdById)
.query( .query(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, AssigneeId, StartDate, DueDate, Progress, CreatedById) `INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, Progress, CreatedById)
VALUES (@workspaceId, @title, @description, @status, @priority, @assigneeId, @startDate, @dueDate, @progress, @createdById)` VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate, @progress, @createdById)`
); );
} }
...@@ -381,88 +377,31 @@ export async function updateTaskProgress( ...@@ -381,88 +377,31 @@ export async function updateTaskProgress(
export async function deleteTask(id: number): Promise<void> { export async function deleteTask(id: number): Promise<void> {
const pool = await getPool(); const pool = await getPool();
// Apaga a tarefa e suas subtarefas (filhas com Entry = id).
await pool await pool
.request() .request()
.input("id", sql.Int, id) .input("id", sql.Int, id)
.query("DELETE FROM dbo.Tasks WHERE Id = @id"); .query("DELETE FROM dbo.Tasks WHERE Id = @id OR Entry = @id");
} }
// ---------- Subtarefas ---------- // ---------- Subtarefas (tarefas-filhas: Entry = Id da tarefa-mãe) ----------
/** Lista as subtarefas (tarefas-filhas) do workspace, com todos os atributos. */
export async function listSubtasksByWorkspace( export async function listSubtasksByWorkspace(
workspaceId: number workspaceId: number
): Promise<Subtask[]> { ): Promise<Task[]> {
const pool = await getPool(); const pool = await getPool();
const result = await pool const result = await pool
.request() .request()
.input("workspaceId", sql.Int, workspaceId) .input("workspaceId", sql.Int, workspaceId)
.query( .query(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone `${TASK_SELECT}
FROM dbo.Subtasks s WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL
JOIN dbo.Tasks t ON t.Id = s.TaskId ORDER BY t.Id`
WHERE t.WorkspaceId = @workspaceId
ORDER BY s.Id`
); );
return result.recordset; return result.recordset;
} }
/** Retorna a subtarefa com dados da tarefa pai (para autorização). */
export async function getSubtaskWithWorkspace(
id: number
): Promise<
| (Subtask & {
WorkspaceId: number;
AssigneeId: number | null;
CreatedById: number;
})
| null
> {
const pool = await getPool();
const result = await pool
.request()
.input("id", sql.Int, id)
.query(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone,
t.WorkspaceId, t.AssigneeId, t.CreatedById
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId
WHERE s.Id = @id`
);
return result.recordset[0] ?? null;
}
export async function createSubtask(
taskId: number,
title: string
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("taskId", sql.Int, taskId)
.input("title", sql.NVarChar(200), title.trim())
.query("INSERT INTO dbo.Subtasks (TaskId, Title) VALUES (@taskId, @title)");
}
export async function setSubtaskDone(
id: number,
isDone: boolean
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.input("isDone", sql.Bit, isDone)
.query("UPDATE dbo.Subtasks SET IsDone = @isDone WHERE Id = @id");
}
export async function deleteSubtask(id: number): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.query("DELETE FROM dbo.Subtasks WHERE Id = @id");
}
// ---------- Painel (dashboard) ---------- // ---------- Painel (dashboard) ----------
export interface StatusCount { export interface StatusCount {
...@@ -482,6 +421,7 @@ export async function getStatusCountsForUser( ...@@ -482,6 +421,7 @@ export async function getStatusCountsForUser(
FROM dbo.Tasks t FROM dbo.Tasks t
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id AND wm.UserId = @userId JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id AND wm.UserId = @userId
WHERE t.Entry IS NULL
GROUP BY w.Id, w.Name, w.Color, t.Status` GROUP BY w.Id, w.Name, w.Color, t.Status`
); );
return result.recordset; return result.recordset;
...@@ -500,7 +440,8 @@ export async function getDueAlertsForUser( ...@@ -500,7 +440,8 @@ export async function getDueAlertsForUser(
.query( .query(
`${TASK_SELECT} `${TASK_SELECT}
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = t.WorkspaceId AND wm.UserId = @userId JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = t.WorkspaceId AND wm.UserId = @userId
WHERE t.Status <> 'done' WHERE t.Entry IS NULL
AND t.Status <> 'done'
AND t.DueDate IS NOT NULL AND t.DueDate IS NOT NULL
AND t.DueDate <= DATEADD(day, @days, CAST(GETDATE() AS DATE)) AND t.DueDate <= DATEADD(day, @days, CAST(GETDATE() AS DATE))
ORDER BY t.DueDate` ORDER BY t.DueDate`
......
...@@ -42,6 +42,7 @@ const DDL_STATEMENTS: string[] = [ ...@@ -42,6 +42,7 @@ const DDL_STATEMENTS: string[] = [
Description NVARCHAR(2000) NULL, Description NVARCHAR(2000) NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'todo', Status NVARCHAR(20) NOT NULL DEFAULT 'todo',
Priority NVARCHAR(10) NOT NULL DEFAULT 'medium', Priority NVARCHAR(10) NOT NULL DEFAULT 'medium',
Entry INT NULL,
AssigneeId INT NULL REFERENCES dbo.Users(Id), AssigneeId INT NULL REFERENCES dbo.Users(Id),
CreatedById INT NOT NULL REFERENCES dbo.Users(Id), CreatedById INT NOT NULL REFERENCES dbo.Users(Id),
StartDate DATE NULL, StartDate DATE NULL,
...@@ -64,6 +65,12 @@ const DDL_STATEMENTS: string[] = [ ...@@ -64,6 +65,12 @@ const DDL_STATEMENTS: string[] = [
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`, )`,
`IF COL_LENGTH('dbo.Tasks', 'Entry') IS NULL
ALTER TABLE dbo.Tasks ADD Entry INT NULL`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Tasks_Entry')
CREATE INDEX IX_Tasks_Entry ON dbo.Tasks (Entry)`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Subtasks_TaskId') `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Subtasks_TaskId')
CREATE INDEX IX_Subtasks_TaskId ON dbo.Subtasks (TaskId)`, CREATE INDEX IX_Subtasks_TaskId ON dbo.Subtasks (TaskId)`,
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment