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 {
listSubtasksByWorkspace,
listTasksByWorkspace,
listWorkspaceMembers,
type Subtask,
type Task,
} from "@/lib/data";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
......@@ -57,9 +57,9 @@ export default async function MemberFolderPage({
);
const taskIds = new Set(tasks.map((t) => t.Id));
const subtasksByTask = allSubtasks
.filter((s) => taskIds.has(s.TaskId))
.reduce<Record<number, Subtask[]>>((acc, subtask) => {
(acc[subtask.TaskId] ??= []).push(subtask);
.filter((s) => s.Entry != null && taskIds.has(s.Entry))
.reduce<Record<number, Task[]>>((acc, subtask) => {
(acc[subtask.Entry as number] ??= []).push(subtask);
return acc;
}, {});
......
......@@ -6,7 +6,7 @@ import {
listSubtasksByWorkspace,
listTasksByWorkspace,
listWorkspaceMembers,
type Subtask,
type Task,
} from "@/lib/data";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
......@@ -38,9 +38,9 @@ export default async function WorkspacePage({
listSubtasksByWorkspace(workspaceId),
]);
const subtasksByTask = subtasks.reduce<Record<number, Subtask[]>>(
const subtasksByTask = subtasks.reduce<Record<number, Task[]>>(
(acc, subtask) => {
(acc[subtask.TaskId] ??= []).push(subtask);
if (subtask.Entry != null) (acc[subtask.Entry] ??= []).push(subtask);
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 { 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 { canEditTask } from "@/lib/permissions";
import { Button } from "@/components/ui/button";
......@@ -26,7 +26,7 @@ export function TaskBoard({
tasks: Task[];
members: WorkspaceMember[];
workspaceId: number;
subtasksByTask: Record<number, Subtask[]>;
subtasksByTask: Record<number, Task[]>;
currentUserId: number;
isAdmin: boolean;
}) {
......@@ -64,7 +64,7 @@ export function TaskBoard({
workspaceId={workspaceId}
members={members}
task={toFormValues(task)}
subtasks={subtasksByTask[task.Id] ?? []}
hasSubtasks={(subtasksByTask[task.Id] ?? []).length > 0}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
......
......@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -39,7 +39,6 @@ export function TaskByPerson({
workspaceId: number;
currentUserId: number;
isAdmin: boolean;
subtasksByTask?: Record<number, Subtask[]>;
}) {
const [search, setSearch] = useState("");
......
......@@ -9,9 +9,7 @@ import {
type ActionState,
} from "@/lib/actions";
import { TASK_PRIORITIES, TASK_STATUSES } from "@/lib/constants";
import type { Subtask, WorkspaceMember } from "@/lib/data";
import { Separator } from "@/components/ui/separator";
import { SubtaskList } from "./subtask-list";
import type { WorkspaceMember } from "@/lib/data";
import { Button } from "@/components/ui/button";
import {
Dialog,
......@@ -45,7 +43,13 @@ export interface TaskFormValues {
progress: number;
}
function SubmitButton({ isEdit }: { isEdit: boolean }) {
function SubmitButton({
isEdit,
isSubtask,
}: {
isEdit: boolean;
isSubtask: boolean;
}) {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} className="w-full">
......@@ -53,7 +57,9 @@ function SubmitButton({ isEdit }: { isEdit: boolean }) {
? "Salvando..."
: isEdit
? "Salvar alterações"
: "Criar tarefa"}
: isSubtask
? "Criar subtarefa"
: "Criar tarefa"}
</Button>
);
}
......@@ -64,7 +70,8 @@ export function TaskDialog({
workspaceId,
members,
task,
subtasks,
hasSubtasks = false,
entryId,
defaultAssigneeId,
currentUserId,
isAdmin,
......@@ -73,12 +80,16 @@ export function TaskDialog({
workspaceId: number;
members: WorkspaceMember[];
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;
currentUserId: number;
isAdmin: boolean;
trigger: ReactNode;
}) {
const isSubtask = entryId !== undefined && !task;
// Membros comuns só podem atribuir tarefas a si mesmos; mantém o
// responsável atual visível em modo edição.
const selectableMembers = isAdmin
......@@ -105,11 +116,19 @@ export function TaskDialog({
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{task ? "Editar tarefa" : "Nova tarefa"}</DialogTitle>
<DialogTitle>
{task
? "Editar tarefa"
: isSubtask
? "Nova subtarefa"
: "Nova tarefa"}
</DialogTitle>
<DialogDescription>
{task
? "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>
</DialogHeader>
<form action={formAction} className="space-y-4">
......@@ -119,6 +138,9 @@ export function TaskDialog({
</Alert>
)}
<input type="hidden" name="workspaceId" value={workspaceId} />
{entryId !== undefined && (
<input type="hidden" name="entry" value={entryId} />
)}
<div className="space-y-2">
<Label htmlFor="task-title">Título</Label>
<Input
......@@ -223,22 +245,16 @@ export function TaskDialog({
min={0}
max={100}
defaultValue={task?.progress ?? 0}
disabled={(subtasks?.length ?? 0) > 0}
disabled={hasSubtasks}
/>
<p className="text-xs text-muted-foreground">
{(subtasks?.length ?? 0) > 0
{hasSubtasks
? "Calculado automaticamente pelas subtarefas."
: "Digite quanto da tarefa já foi concluído (0 a 100)."}
</p>
</div>
<SubmitButton isEdit={Boolean(task)} />
<SubmitButton isEdit={Boolean(task)} isSubtask={isSubtask} />
</form>
{task && (
<>
<Separator />
<SubtaskList taskId={task.id} subtasks={subtasks ?? []} />
</>
)}
</DialogContent>
</Dialog>
);
......
"use client";
import { Fragment, useState } from "react";
import { CheckCircle2, ChevronRight, Circle, Pencil } from "lucide-react";
import type { Subtask, Task, WorkspaceMember } from "@/lib/data";
import { AlertTriangle, ChevronRight, Pencil, Plus } from "lucide-react";
import type { Task, WorkspaceMember } from "@/lib/data";
import { TASK_STATUSES } from "@/lib/constants";
import { formatDate, getDueInfo } from "@/lib/dates";
import { AlertTriangle } from "lucide-react";
import { canEditTask } from "@/lib/permissions";
import { initials, toFormValues } from "./task-utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
......@@ -22,7 +21,6 @@ import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge";
import { PriorityBadge } from "./priority-badge";
import { StatusBadge } from "./status-badge";
import { SubtaskList } from "./subtask-list";
import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog";
import { TaskProgress } from "./task-progress";
......@@ -42,7 +40,7 @@ export function TaskTable({
tasks: Task[];
members: WorkspaceMember[];
workspaceId: number;
subtasksByTask: Record<number, Subtask[]>;
subtasksByTask: Record<number, Task[]>;
currentUserId: number;
isAdmin: boolean;
}) {
......@@ -170,7 +168,7 @@ export function TaskTable({
>
<TableCell className="min-w-[260px] py-3 align-top">
<div className="flex items-start gap-1.5">
{hasSubtasks ? (
{hasSubtasks || editable ? (
<button
type="button"
onClick={() => toggleExpanded(task.Id)}
......@@ -187,8 +185,12 @@ export function TaskTable({
isOpen && "rotate-90"
)}
/>
{subtasks.filter((s) => s.IsDone).length}/
{subtasks.length}
{hasSubtasks && (
<span className="tabular-nums">
{subtasks.filter((s) => s.Status === "done").length}/
{subtasks.length}
</span>
)}
</button>
) : (
<span className="w-5 shrink-0" />
......@@ -269,7 +271,7 @@ export function TaskTable({
workspaceId={workspaceId}
members={members}
task={toFormValues(task)}
subtasks={subtasks}
hasSubtasks={hasSubtasks}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
......@@ -293,33 +295,136 @@ export function TaskTable({
</TableRow>
{isOpen && (
<TableRow className="border-b bg-muted/20 hover:bg-muted/20 last:border-0">
<TableCell colSpan={COLUMN_COUNT} className="py-3 pl-12">
<div className="max-w-md">
{editable ? (
<SubtaskList taskId={task.Id} subtasks={subtasks} />
) : (
<ul className="space-y-1.5">
{subtasks.map((subtask) => (
<li
key={subtask.Id}
className="flex items-center gap-2 text-sm"
>
{subtask.IsDone ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
<TableCell colSpan={COLUMN_COUNT} className="py-3 pl-12 pr-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Subtarefas
</p>
{subtasks.length === 0 && (
<p className="text-sm text-muted-foreground">
Nenhuma subtarefa ainda.
</p>
)}
{subtasks.map((sub) => {
const subEditable = canEditTask(
sub,
currentUserId,
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
className={cn(
subtask.IsDone &&
"text-muted-foreground line-through"
)}
</div>
{subEditable ? (
<TaskStatusSelect
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>
</li>
))}
</ul>
)}
<div className="flex items-center gap-1.5">
<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>
</TableCell>
......
......@@ -306,6 +306,8 @@ const taskSchema = z
.min(0, "O progresso deve ser entre 0 e 100.")
.max(100, "O progresso deve ser entre 0 e 100.")
.optional(),
// Id da tarefa-mãe quando se cria uma subtarefa.
entry: z.coerce.number().int().positive().optional(),
})
.refine(
(t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate,
......@@ -323,6 +325,7 @@ function parseTaskForm(formData: FormData) {
startDate: formData.get("startDate") || undefined,
dueDate: formData.get("dueDate") || undefined,
progress: formData.get("progress") || undefined,
entry: formData.get("entry") || undefined,
});
}
......@@ -360,6 +363,7 @@ export async function createTaskAction(
startDate: parsed.data.startDate ?? null,
dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? 0,
entry: parsed.data.entry ?? null,
},
user.id
);
......@@ -464,77 +468,6 @@ export async function updateTaskProgressAction(
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> {
const user = await requireUser();
try {
......
......@@ -33,6 +33,7 @@ export interface Task {
WorkspaceName?: string;
Title: string;
Description: string | null;
Entry?: number | null;
Status: string;
Priority: string;
AssigneeId: number | null;
......@@ -46,13 +47,6 @@ export interface Task {
SubtaskDone: number;
}
export interface Subtask {
Id: number;
TaskId: number;
Title: string;
IsDone: boolean;
}
// ---------- Usuários ----------
export async function findUserByEmail(
......@@ -147,8 +141,8 @@ export async function listWorkspacesForUser(
const result = await pool.request().input("userId", sql.Int, userId).query(
`SELECT w.Id, w.Name, w.Description, w.Color, w.OwnerId,
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.Status = 'done') AS DoneCount
(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.Entry IS NULL AND t.Status = 'done') AS DoneCount
FROM dbo.Workspaces w
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id
WHERE wm.UserId = @userId
......@@ -261,10 +255,11 @@ export async function removeWorkspaceMember(
const TASK_SELECT = `
SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority,
t.Entry,
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById,
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.Subtasks s WHERE s.TaskId = t.Id AND s.IsDone = 1) AS SubtaskDone
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone
FROM dbo.Tasks t
LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`;
......@@ -278,7 +273,7 @@ export async function listTasksByWorkspace(
.input("workspaceId", sql.Int, workspaceId)
.query(
`${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`
);
return result.recordset;
......@@ -303,8 +298,8 @@ export interface TaskInput {
startDate: string | null;
dueDate: string | null;
progress: number;
entry?: number | null;
}
export async function createTask(
input: TaskInput,
createdById: number
......@@ -318,13 +313,14 @@ export async function createTask(
.input("status", sql.NVarChar(20), input.status)
.input("priority", sql.NVarChar(10), input.priority)
.input("assigneeId", sql.Int, input.assigneeId)
.input("entry", sql.Int, input.entry)
.input("startDate", sql.Date, input.startDate)
.input("dueDate", sql.Date, input.dueDate)
.input("progress", sql.Int, input.progress)
.input("createdById", sql.Int, createdById)
.query(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, AssigneeId, StartDate, DueDate, Progress, CreatedById)
VALUES (@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, @entry, @assigneeId, @startDate, @dueDate, @progress, @createdById)`
);
}
......@@ -381,88 +377,31 @@ export async function updateTaskProgress(
export async function deleteTask(id: number): Promise<void> {
const pool = await getPool();
// Apaga a tarefa e suas subtarefas (filhas com Entry = id).
await pool
.request()
.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(
workspaceId: number
): Promise<Subtask[]> {
): Promise<Task[]> {
const pool = await getPool();
const result = await pool
.request()
.input("workspaceId", sql.Int, workspaceId)
.query(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId
WHERE t.WorkspaceId = @workspaceId
ORDER BY s.Id`
`${TASK_SELECT}
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL
ORDER BY t.Id`
);
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) ----------
export interface StatusCount {
......@@ -482,6 +421,7 @@ export async function getStatusCountsForUser(
FROM dbo.Tasks t
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId
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`
);
return result.recordset;
......@@ -500,7 +440,8 @@ export async function getDueAlertsForUser(
.query(
`${TASK_SELECT}
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 <= DATEADD(day, @days, CAST(GETDATE() AS DATE))
ORDER BY t.DueDate`
......
......@@ -42,6 +42,7 @@ const DDL_STATEMENTS: string[] = [
Description NVARCHAR(2000) NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'todo',
Priority NVARCHAR(10) NOT NULL DEFAULT 'medium',
Entry INT NULL,
AssigneeId INT NULL REFERENCES dbo.Users(Id),
CreatedById INT NOT NULL REFERENCES dbo.Users(Id),
StartDate DATE NULL,
......@@ -64,6 +65,12 @@ const DDL_STATEMENTS: string[] = [
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')
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