Commit 8db9e2b0 authored by Paulo Avila's avatar Paulo Avila
Browse files

Criação de fluxo para modificação de prazo de tarefa

parent 24d624aa
import { History } from "lucide-react";
import { formatDateShort } from "@/lib/dates";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/** Marca a tarefa que teve a previsão adiada após vencer. */
export function PostponedBadge({
postponed,
originalDueDate,
className,
}: {
postponed: boolean;
originalDueDate: Date | string | null;
className?: string;
}) {
if (!postponed) return null;
const original = originalDueDate ? formatDateShort(originalDueDate) : null;
return (
<Badge
title={
original
? `Adiada — previsão original: ${original}`
: "Adiada após o vencimento"
}
className={cn(
"gap-0.5 border-transparent bg-amber-500 px-1.5 py-0 text-[10px] font-medium leading-4 text-white hover:bg-amber-500",
className
)}
>
<History className="h-2.5 w-2.5 shrink-0" />
Adiada
</Badge>
);
}
......@@ -6,10 +6,13 @@ import { canDeleteTask, canEditTask } from "@/lib/permissions";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge";
import { MetaBadge } from "./meta-badge";
import { PostponedBadge } from "./postponed-badge";
import { PriorityBadge } from "./priority-badge";
import { SubtaskOverdueBadge } from "./subtask-overdue-badge";
import { TaskPostponeButton } from "./task-postpone-button";
import { StatusBadge } from "./status-badge";
import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog";
......@@ -63,6 +66,11 @@ export function TaskBoard({
</p>
{editable && (
<div className="flex shrink-0 items-center">
<TaskPostponeButton
taskId={task.Id}
dueDate={task.DueDate}
status={task.Status}
/>
<TaskDialog
workspaceId={workspaceId}
members={members}
......@@ -103,6 +111,10 @@ export function TaskBoard({
subtasks={subtasksByTask[task.Id] ?? []}
compact
/>
<PostponedBadge
postponed={task.Postponed}
originalDueDate={task.OriginalDueDate}
/>
</div>
<div className="flex items-center gap-2">
<Progress value={percent} className="h-1.5" />
......@@ -159,10 +171,20 @@ export function TaskBoard({
)}
{task.CompletedDate && (
<span
className="flex items-center gap-1 text-green-600 dark:text-green-400"
title="Concluída em"
className={cn(
"flex items-center gap-1",
task.Postponed
? "font-medium text-amber-600 dark:text-amber-400"
: "text-green-600 dark:text-green-400"
)}
title={
task.Postponed
? "Concluída após adiamento"
: "Concluída no prazo"
}
>
<CalendarCheck className="h-3 w-3" />
{task.Postponed && ""}
{formatDateShort(task.CompletedDate)}
</span>
)}
......
"use client";
import { useState, useTransition } from "react";
import { CalendarClock } from "lucide-react";
import { toast } from "sonner";
import { postponeTaskAction } from "@/lib/actions";
import { formatDate, getDueInfo, toISODate } from "@/lib/dates";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/** "Hoje" no fuso do navegador (toISOString deslocaria o dia em fusos negativos). */
function todayISO(): string {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/**
* Só aparece em tarefa vencida: move a previsão de término e marca a flag
* "Adiada". Fora do vencimento, adiar não faz sentido e o botão some.
*/
export function TaskPostponeButton({
taskId,
dueDate,
status,
}: {
taskId: number;
dueDate: Date | string | null;
status: string;
}) {
const [open, setOpen] = useState(false);
const [newDate, setNewDate] = useState("");
const [pending, startTransition] = useTransition();
const info = getDueInfo(dueDate, status);
if (info?.state !== "overdue") return null;
const currentDue = toISODate(dueDate);
function handlePostpone() {
startTransition(async () => {
const result = await postponeTaskAction(taskId, newDate);
if (result.error) toast.error(result.error);
else {
toast.success("Tarefa adiada.");
setOpen(false);
}
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-amber-600"
title="Adiar tarefa vencida"
>
<CalendarClock className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Adiar tarefa</DialogTitle>
<DialogDescription>
{currentDue
? `Previsão atual: ${formatDate(currentDue)} — vencida. A tarefa ficará marcada como adiada, e a previsão original é preservada.`
: "Escolha a nova previsão de término."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`postpone-${taskId}`}>Nova previsão de término</Label>
<Input
id={`postpone-${taskId}`}
type="date"
value={newDate}
min={todayISO()}
onChange={(e) => setNewDate(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={handlePostpone}
disabled={pending || !newDate}
className="w-full"
>
{pending ? "Adiando..." : "Adiar tarefa"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
......@@ -21,8 +21,10 @@ import {
import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge";
import { MetaBadge } from "./meta-badge";
import { PostponedBadge } from "./postponed-badge";
import { PriorityBadge } from "./priority-badge";
import { SubtaskOverdueBadge } from "./subtask-overdue-badge";
import { TaskPostponeButton } from "./task-postpone-button";
import { StatusBadge } from "./status-badge";
import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog";
......@@ -250,6 +252,10 @@ export function TaskTable({
subtaskDone={task.SubtaskDone}
/>
<SubtaskOverdueBadge subtasks={subtasks} />
<PostponedBadge
postponed={task.Postponed}
originalDueDate={task.OriginalDueDate}
/>
</div>
{task.Description && (
<p className="mt-0.5 text-xs text-muted-foreground">
......@@ -346,7 +352,19 @@ export function TaskTable({
<TableCell className="py-3 align-top">
<span className="block whitespace-nowrap text-sm leading-6 text-muted-foreground">
{task.CompletedDate ? (
<span className="text-green-600 dark:text-green-400">
<span
className={cn(
task.Postponed
? "font-medium text-amber-600 dark:text-amber-400"
: "text-green-600 dark:text-green-400"
)}
title={
task.Postponed
? "Concluída após adiamento"
: "Concluída no prazo"
}
>
{task.Postponed && ""}
{formatDateShort(task.CompletedDate)}
</span>
) : (
......@@ -357,6 +375,11 @@ export function TaskTable({
<TableCell className="py-3">
{editable && (
<div className="flex items-center justify-end transition-opacity md:opacity-0 md:group-hover:opacity-100">
<TaskPostponeButton
taskId={task.Id}
dueDate={task.DueDate}
status={task.Status}
/>
<TaskDialog
workspaceId={workspaceId}
members={members}
......@@ -414,6 +437,10 @@ export function TaskTable({
{sub.Title}
</span>
<PriorityBadge priority={sub.Priority} />
<PostponedBadge
postponed={sub.Postponed}
originalDueDate={sub.OriginalDueDate}
/>
</div>
{sub.Description && (
<p className="mt-0.5 text-xs text-muted-foreground">
......@@ -474,6 +501,11 @@ export function TaskTable({
</div>
{subEditable && (
<div className="flex items-center">
<TaskPostponeButton
taskId={sub.Id}
dueDate={sub.DueDate}
status={sub.Status}
/>
<TaskDialog
workspaceId={workspaceId}
members={members}
......
......@@ -19,6 +19,7 @@ import {
} from "./authz";
import * as data from "./data";
import { TASK_PRIORITIES, TASK_STATUSES } from "./constants";
import { getDueInfo, toISODate } from "./dates";
export interface ActionState {
error?: string;
......@@ -533,6 +534,52 @@ export async function updateTaskProgressAction(
return { success: true };
}
/**
* Adia uma tarefa vencida para uma nova previsão de término e marca a flag
* "adiada" — é a única forma de mexer na previsão depois da criação.
*/
export async function postponeTaskAction(
taskId: number,
newDueDate: string
): Promise<ActionState> {
const user = await requireUser();
if (!/^\d{4}-\d{2}-\d{2}$/.test(newDueDate)) {
return { error: "Data inválida." };
}
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
if (task.Status === "done") {
return { error: "Uma tarefa concluída não pode ser adiada." };
}
const currentDue = toISODate(task.DueDate);
if (!currentDue) {
return { error: "Tarefa sem previsão de término não pode ser adiada." };
}
if (getDueInfo(task.DueDate, task.Status)?.state !== "overdue") {
return { error: "Só é possível adiar uma tarefa já vencida." };
}
if (newDueDate <= currentDue) {
return { error: "A nova previsão deve ser posterior à atual." };
}
// Reaproveita a classificação de vencimento p/ barrar data no passado.
if (getDueInfo(newDueDate, "todo")?.state === "overdue") {
return { error: "A nova previsão não pode estar no passado." };
}
await data.postponeTask(taskId, newDueDate);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao adiar a tarefa.",
};
}
revalidatePath("/dashboard");
return { success: true };
}
export async function deleteTaskAction(taskId: number): Promise<ActionState> {
const user = await requireUser();
try {
......
......@@ -42,6 +42,10 @@ export interface Task {
StartDate: Date | null;
DueDate: Date | null;
CompletedDate: Date | null;
/** True quando a tarefa foi adiada após vencer. */
Postponed: boolean;
/** Previsão de término antes do primeiro adiamento. */
OriginalDueDate: Date | null;
Progress: number;
MetaType: string | null;
MetaValue: number | null;
......@@ -272,7 +276,8 @@ 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.CompletedDate, t.Progress, t.MetaType, t.MetaValue, t.CreatedAt, w.Name AS WorkspaceName,
t.StartDate, t.DueDate, t.CompletedDate, t.Postponed, t.OriginalDueDate,
t.Progress, t.MetaType, t.MetaValue, t.CreatedAt, w.Name AS WorkspaceName,
(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,
(SELECT u.Id AS UserId, u.Name
......@@ -429,6 +434,30 @@ export async function updateTaskStatus(
);
}
/**
* Adia uma tarefa vencida: move a previsão de término e marca a flag.
* COALESCE preserva a previsão original mesmo em adiamentos seguintes —
* é a única exceção à imutabilidade de DueDate.
*/
export async function postponeTask(
id: number,
newDueDate: string
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, id)
.input("dueDate", sql.Date, newDueDate)
.query(
`UPDATE dbo.Tasks
SET OriginalDueDate = COALESCE(OriginalDueDate, DueDate),
DueDate = @dueDate,
Postponed = 1,
UpdatedAt = SYSUTCDATETIME()
WHERE Id = @id`
);
}
export async function updateTaskProgress(
id: number,
progress: number
......
......@@ -48,6 +48,8 @@ const DDL_STATEMENTS: string[] = [
StartDate DATE NULL,
DueDate DATE NULL,
CompletedDate DATE NULL,
Postponed BIT NOT NULL DEFAULT 0,
OriginalDueDate DATE NULL,
Progress INT NOT NULL DEFAULT 0,
MetaType NVARCHAR(10) NULL,
MetaValue INT NULL,
......@@ -75,6 +77,13 @@ const DDL_STATEMENTS: string[] = [
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`,
// Adiamento de tarefa vencida: flag + previsão original preservada
`IF COL_LENGTH('dbo.Tasks', 'Postponed') IS NULL
ALTER TABLE dbo.Tasks ADD Postponed BIT NOT NULL DEFAULT 0`,
`IF COL_LENGTH('dbo.Tasks', 'OriginalDueDate') IS NULL
ALTER TABLE dbo.Tasks ADD OriginalDueDate DATE NULL`,
// Tarefas compartilhadas: usuários vinculados além do responsável
`IF OBJECT_ID('dbo.TaskCollaborators', 'U') IS NULL
CREATE TABLE dbo.TaskCollaborators (
......
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