Commit 6ccc2156 authored by Paulo Avila's avatar Paulo Avila
Browse files

Criação de tarefas com tipo de meta

parent 7a97aeb5
import { Target } from "lucide-react";
import { getMetaInfo } from "@/lib/meta";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Indicador da meta de subtarefas em aberto na tarefa-mãe.
* Verde = dentro da meta; vermelho = acima do teto; âmbar = abaixo do piso.
*/
export function MetaBadge({
metaType,
metaValue,
subtaskCount,
subtaskDone,
className,
}: {
metaType: string | null;
metaValue: number | null;
subtaskCount: number;
subtaskDone: number;
className?: string;
}) {
const info = getMetaInfo(metaType, metaValue, subtaskCount, subtaskDone);
if (!info) return null;
const styles = {
within: "bg-emerald-500 text-white hover:bg-emerald-500",
exceeded: "bg-destructive text-destructive-foreground hover:bg-destructive",
below: "bg-amber-500 text-white hover:bg-amber-500",
} as const;
return (
<Badge
title={info.label}
className={cn(
"gap-0.5 px-1.5 py-0 text-[10px] font-medium leading-4",
styles[info.state],
className
)}
>
<Target className="h-2.5 w-2.5 shrink-0" />
{info.shortLabel}
</Badge>
);
}
......@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { DueBadge } from "./due-badge";
import { MetaBadge } from "./meta-badge";
import { PriorityBadge } from "./priority-badge";
import { SubtaskOverdueBadge } from "./subtask-overdue-badge";
import { StatusBadge } from "./status-badge";
......@@ -89,6 +90,12 @@ export function TaskBoard({
<div className="flex flex-wrap items-center gap-1.5">
<PriorityBadge priority={task.Priority} />
<DueBadge dueDate={task.DueDate} status={task.Status} />
<MetaBadge
metaType={task.MetaType}
metaValue={task.MetaValue}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
/>
<SubtaskOverdueBadge
subtasks={subtasksByTask[task.Id] ?? []}
compact
......
......@@ -42,6 +42,9 @@ export interface TaskFormValues {
dueDate: string | null; // yyyy-MM-dd
completedDate: string | null; // yyyy-MM-dd
progress: number;
entry: number | null;
metaType: string | null; // 'teto' | 'piso' | null
metaValue: number | null;
}
function SubmitButton({
......@@ -91,6 +94,8 @@ export function TaskDialog({
trigger: ReactNode;
}) {
const isSubtask = entryId !== undefined && !task;
// Meta só existe em tarefa-mãe: some ao criar/editar subtarefa.
const showMeta = !isSubtask && !(task && task.entry != null);
// Membros comuns só podem atribuir tarefas a si mesmos; mantém o
// responsável atual visível em modo edição.
const selectableMembers = isAdmin
......@@ -99,6 +104,7 @@ export function TaskDialog({
(m) => m.UserId === currentUserId || m.UserId === task?.assigneeId
);
const [open, setOpen] = useState(false);
const [metaType, setMetaType] = useState<string>(task?.metaType ?? "none");
const action = task
? updateTaskAction.bind(null, task.id)
: createTaskAction;
......@@ -276,6 +282,48 @@ export function TaskDialog({
: "Digite quanto da tarefa já foi concluído (0 a 100)."}
</p>
</div>
{showMeta && (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Meta de subtarefas em aberto</Label>
<Select
name="metaType"
value={metaType}
onValueChange={setMetaType}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Sem meta</SelectItem>
<SelectItem value="teto">Teto (máximo)</SelectItem>
<SelectItem value="piso">Piso (mínimo)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="task-meta-value">Quantidade</Label>
<Input
id="task-meta-value"
name="metaValue"
type="number"
min={1}
defaultValue={task?.metaValue ?? ""}
disabled={metaType === "none"}
placeholder="Ex.: 10"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{metaType === "teto"
? "Alerta quando houver mais subtarefas em aberto que a meta."
: metaType === "piso"
? "Alerta quando houver menos subtarefas em aberto que a meta."
: "Acompanhe quantas subtarefas podem ficar em aberto sem recriar a tarefa a cada ciclo."}
</p>
</div>
)}
<SubmitButton isEdit={Boolean(task)} isSubtask={isSubtask} />
</form>
</DialogContent>
......
"use client";
import { Fragment, useState } from "react";
import { AlertTriangle, ChevronRight, Pencil, Plus } from "lucide-react";
import { AlertTriangle, ChevronRight, Pencil, Plus, Target } from "lucide-react";
import type { Task, WorkspaceMember } from "@/lib/data";
import { TASK_STATUSES } from "@/lib/constants";
import { formatDateShort, getDueInfo } from "@/lib/dates";
import { getMetaInfo } from "@/lib/meta";
import { canEditTask } from "@/lib/permissions";
import { initials, toFormValues } from "./task-utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
......@@ -19,6 +20,7 @@ import {
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge";
import { MetaBadge } from "./meta-badge";
import { PriorityBadge } from "./priority-badge";
import { SubtaskOverdueBadge } from "./subtask-overdue-badge";
import { StatusBadge } from "./status-badge";
......@@ -67,6 +69,16 @@ export function TaskTable({
const isOverdue = (t: Task) =>
getDueInfo(t.DueDate, t.Status)?.state === "overdue";
const isMetaOffTarget = (t: Task) => {
const info = getMetaInfo(
t.MetaType,
t.MetaValue,
t.SubtaskCount,
t.SubtaskDone
);
return info != null && !info.ok;
};
// Ordena por criação mais recente primeiro (ID decrescente), em todos os
// estados — a tarefa adicionada por último aparece no topo.
const sortTasks = (list: Task[]) => [...list].sort((a, b) => b.Id - a.Id);
......@@ -76,19 +88,33 @@ export function TaskTable({
? tasks
: statusFilter === "overdue"
? tasks.filter(isOverdue)
: tasks.filter((t) => t.Status === statusFilter)
: statusFilter === "off_target"
? tasks.filter(isMetaOffTarget)
: tasks.filter((t) => t.Status === statusFilter)
);
const overdueCount = tasks.filter(isOverdue).length;
const offTargetCount = tasks.filter(isMetaOffTarget).length;
const filterOptions = [
const filterOptions: {
value: string;
label: string;
count: number;
tone?: "danger" | "warning";
}[] = [
{ value: "all", label: "Todas", count: tasks.length },
...TASK_STATUSES.map((s) => ({
value: s.value,
label: s.label,
count: tasks.filter((t) => t.Status === s.value).length,
})),
{ value: "overdue", label: "Vencidas", count: overdueCount },
{ value: "overdue", label: "Vencidas", count: overdueCount, tone: "danger" },
{
value: "off_target",
label: "Fora da meta",
count: offTargetCount,
tone: "warning",
},
];
return (
......@@ -96,7 +122,24 @@ export function TaskTable({
<div className="flex flex-wrap items-center gap-2">
{filterOptions.map((option) => {
const isActive = statusFilter === option.value;
const isOverdueFilter = option.value === "overdue";
const Icon =
option.tone === "danger"
? AlertTriangle
: option.tone === "warning"
? Target
: null;
const activeClass =
option.tone === "danger"
? "border-destructive bg-destructive text-destructive-foreground"
: option.tone === "warning"
? "border-amber-500 bg-amber-500 text-white"
: "border-primary bg-primary text-primary-foreground";
const idleClass =
option.tone === "danger" && option.count > 0
? "border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20"
: option.tone === "warning" && option.count > 0
? "border-amber-500/40 bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 dark:text-amber-400"
: "border-border bg-card text-muted-foreground hover:bg-muted";
return (
<button
key={option.value}
......@@ -104,16 +147,10 @@ export function TaskTable({
onClick={() => setStatusFilter(option.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
isActive
? isOverdueFilter
? "border-destructive bg-destructive text-destructive-foreground"
: "border-primary bg-primary text-primary-foreground"
: isOverdueFilter && option.count > 0
? "border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20"
: "border-border bg-card text-muted-foreground hover:bg-muted"
isActive ? activeClass : idleClass
)}
>
{isOverdueFilter && <AlertTriangle className="h-3 w-3" />}
{Icon && <Icon className="h-3 w-3" />}
{option.label}
<span
className={cn(
......@@ -205,6 +242,12 @@ export function TaskTable({
{task.Title}
</span>
<PriorityBadge priority={task.Priority} />
<MetaBadge
metaType={task.MetaType}
metaValue={task.MetaValue}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
/>
<SubtaskOverdueBadge subtasks={subtasks} />
</div>
{task.Description && (
......
......@@ -24,6 +24,9 @@ export function toFormValues(task: Task): TaskFormValues {
dueDate: toISODate(task.DueDate),
completedDate: toISODate(task.CompletedDate),
progress: task.Progress,
entry: task.Entry ?? null,
metaType: task.MetaType,
metaValue: task.MetaValue,
};
}
......
......@@ -308,11 +308,28 @@ const taskSchema = z
.optional(),
// Id da tarefa-mãe quando se cria uma subtarefa.
entry: z.coerce.number().int().positive().optional(),
// Meta de subtarefas em aberto (só na tarefa-mãe).
metaType: z.enum(["teto", "piso"]).optional(),
metaValue: z.coerce
.number()
.int()
.positive("A meta deve ser um número maior que zero.")
.optional(),
})
.refine(
(t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate,
{ message: "A data de início não pode ser depois do vencimento." }
);
)
.refine((t) => !t.metaType || t.metaValue !== undefined, {
message: "Informe a quantidade da meta.",
path: ["metaValue"],
});
function parseMetaType(
value: FormDataEntryValue | null
): "teto" | "piso" | undefined {
return value === "teto" || value === "piso" ? value : undefined;
}
function parseTaskForm(formData: FormData) {
return taskSchema.safeParse({
......@@ -326,6 +343,8 @@ function parseTaskForm(formData: FormData) {
dueDate: formData.get("dueDate") || undefined,
progress: formData.get("progress") || undefined,
entry: formData.get("entry") || undefined,
metaType: parseMetaType(formData.get("metaType")),
metaValue: formData.get("metaValue") || undefined,
});
}
......@@ -364,6 +383,9 @@ export async function createTaskAction(
dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? 0,
entry: parsed.data.entry ?? null,
// Meta só vale para tarefa-mãe (subtarefa não carrega meta).
metaType: parsed.data.entry ? null : (parsed.data.metaType ?? null),
metaValue: parsed.data.entry ? null : (parsed.data.metaValue ?? null),
},
user.id
);
......@@ -412,6 +434,9 @@ export async function updateTaskAction(
startDate: parsed.data.startDate ?? null,
dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? task.Progress,
// Meta só vale para tarefa-mãe (subtarefa não carrega meta).
metaType: task.Entry != null ? null : (parsed.data.metaType ?? null),
metaValue: task.Entry != null ? null : (parsed.data.metaValue ?? null),
});
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
......
......@@ -43,6 +43,8 @@ export interface Task {
DueDate: Date | null;
CompletedDate: Date | null;
Progress: number;
MetaType: string | null;
MetaValue: number | null;
CreatedAt: Date;
SubtaskCount: number;
SubtaskDone: number;
......@@ -258,7 +260,7 @@ 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.CreatedAt, w.Name AS WorkspaceName,
t.StartDate, t.DueDate, t.CompletedDate, 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
FROM dbo.Tasks t
......@@ -300,6 +302,8 @@ export interface TaskInput {
dueDate: string | null;
progress: number;
entry?: number | null;
metaType?: string | null;
metaValue?: number | null;
}
export async function createTask(
input: TaskInput,
......@@ -318,12 +322,14 @@ export async function createTask(
.input("startDate", sql.Date, input.startDate)
.input("dueDate", sql.Date, input.dueDate)
.input("progress", sql.Int, input.progress)
.input("metaType", sql.NVarChar(10), input.metaType ?? null)
.input("metaValue", sql.Int, input.metaValue ?? null)
.input("createdById", sql.Int, createdById)
.query(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress, CreatedById)
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress, MetaType, MetaValue, CreatedById)
VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
@progress, @createdById)`
@progress, @metaType, @metaValue, @createdById)`
);
}
......@@ -340,10 +346,13 @@ export async function updateTask(id: number, input: TaskInput): Promise<void> {
.input("priority", sql.NVarChar(10), input.priority)
.input("assigneeId", sql.Int, input.assigneeId)
.input("progress", sql.Int, input.progress)
.input("metaType", sql.NVarChar(10), input.metaType ?? null)
.input("metaValue", sql.Int, input.metaValue ?? null)
.query(
`UPDATE dbo.Tasks
SET Title = @title, Description = @description, Status = @status,
Priority = @priority, AssigneeId = @assigneeId, Progress = @progress,
MetaType = @metaType, MetaValue = @metaValue,
CompletedDate = CASE WHEN @status = 'done'
THEN COALESCE(CompletedDate, CAST(GETDATE() AS DATE))
ELSE NULL END,
......
......@@ -49,6 +49,8 @@ const DDL_STATEMENTS: string[] = [
DueDate DATE NULL,
CompletedDate DATE NULL,
Progress INT NOT NULL DEFAULT 0,
MetaType NVARCHAR(10) NULL,
MetaValue INT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`,
......@@ -66,6 +68,13 @@ const DDL_STATEMENTS: string[] = [
SET CompletedDate = CAST(UpdatedAt AS DATE)
WHERE Status = 'done' AND CompletedDate IS NULL`,
// Meta de subtarefas em aberto para tarefas recorrentes (teto/piso)
`IF COL_LENGTH('dbo.Tasks', 'MetaType') IS NULL
ALTER TABLE dbo.Tasks ADD MetaType NVARCHAR(10) NULL`,
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`,
`IF OBJECT_ID('dbo.Subtasks', 'U') IS NULL
CREATE TABLE dbo.Subtasks (
Id INT IDENTITY(1,1) PRIMARY KEY,
......
export type MetaKind = "teto" | "piso";
export type MetaState = "within" | "exceeded" | "below";
export interface MetaInfo {
kind: MetaKind;
state: MetaState;
/** true quando está dentro da meta. */
ok: boolean;
/** Subtarefas em aberto (não concluídas). */
open: number;
/** Valor da meta. */
target: number;
/** Texto completo (tooltip). */
label: string;
/** Texto curto para o badge ("8/10"). */
shortLabel: string;
}
/**
* Avalia a meta de subtarefas em aberto de uma tarefa-mãe recorrente.
* "Em aberto" = subtarefas não concluídas (total − concluídas).
* - teto: dentro quando em aberto ≤ meta; estoura acima.
* - piso: dentro quando em aberto ≥ meta; fica abaixo.
* Retorna null quando a tarefa não usa meta.
*/
export function getMetaInfo(
metaType: string | null,
metaValue: number | null,
subtaskCount: number,
subtaskDone: number
): MetaInfo | null {
if ((metaType !== "teto" && metaType !== "piso") || metaValue == null) {
return null;
}
const open = Math.max(0, subtaskCount - subtaskDone);
const target = metaValue;
if (metaType === "teto") {
const ok = open <= target;
return {
kind: "teto",
state: ok ? "within" : "exceeded",
ok,
open,
target,
label: ok
? `Dentro da meta: ${open} em aberto (teto ${target})`
: `Acima da meta: ${open} em aberto (teto ${target})`,
shortLabel: `${open}/${target}`,
};
}
const ok = open >= target;
return {
kind: "piso",
state: ok ? "within" : "below",
ok,
open,
target,
label: ok
? `Dentro da meta: ${open} em aberto (piso ${target})`
: `Abaixo da meta: ${open} em aberto (piso ${target})`,
shortLabel: `${open}/${target}`,
};
}
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