Commit 24d624aa authored by Paulo Avila's avatar Paulo Avila
Browse files

Tarefas compartilhadas entre colaboradores

parent ca3426d3
...@@ -53,7 +53,10 @@ export default async function MemberFolderPage({ ...@@ -53,7 +53,10 @@ export default async function MemberFolderPage({
if (memberId !== 0 && !member) notFound(); if (memberId !== 0 && !member) notFound();
const tasks = allTasks.filter((t) => const tasks = allTasks.filter((t) =>
member ? t.AssigneeId === member.UserId : t.AssigneeId === null member
? t.AssigneeId === member.UserId ||
t.Collaborators.some((c) => c.UserId === member.UserId)
: t.AssigneeId === null
); );
const taskIds = new Set(tasks.map((t) => t.Id)); const taskIds = new Set(tasks.map((t) => t.Id));
const subtasksByTask = allSubtasks const subtasksByTask = allSubtasks
......
...@@ -2,7 +2,7 @@ import { CalendarCheck, CalendarClock, CalendarPlus, Pencil } from "lucide-react ...@@ -2,7 +2,7 @@ import { CalendarCheck, CalendarClock, CalendarPlus, Pencil } from "lucide-react
import { TASK_STATUSES } from "@/lib/constants"; import { TASK_STATUSES } from "@/lib/constants";
import type { Task, WorkspaceMember } from "@/lib/data"; import type { Task, WorkspaceMember } from "@/lib/data";
import { formatDateShort } from "@/lib/dates"; import { formatDateShort } from "@/lib/dates";
import { canEditTask } from "@/lib/permissions"; import { canDeleteTask, canEditTask } from "@/lib/permissions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
...@@ -15,7 +15,7 @@ import { TaskDeleteButton } from "./task-delete-button"; ...@@ -15,7 +15,7 @@ import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog"; import { TaskDialog } from "./task-dialog";
import { taskPercent } from "./task-progress"; import { taskPercent } from "./task-progress";
import { TaskStatusSelect } from "./task-status-select"; import { TaskStatusSelect } from "./task-status-select";
import { toFormValues } from "./task-utils"; import { initials, toFormValues } from "./task-utils";
export function TaskBoard({ export function TaskBoard({
tasks, tasks,
...@@ -47,6 +47,7 @@ export function TaskBoard({ ...@@ -47,6 +47,7 @@ export function TaskBoard({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{columnTasks.map((task) => { {columnTasks.map((task) => {
const editable = canEditTask(task, currentUserId, isAdmin); const editable = canEditTask(task, currentUserId, isAdmin);
const deletable = canDeleteTask(task, currentUserId, isAdmin);
const percent = taskPercent( const percent = taskPercent(
task.Status, task.Status,
task.SubtaskCount, task.SubtaskCount,
...@@ -80,10 +81,12 @@ export function TaskBoard({ ...@@ -80,10 +81,12 @@ export function TaskBoard({
</Button> </Button>
} }
/> />
<TaskDeleteButton {deletable && (
taskId={task.Id} <TaskDeleteButton
taskTitle={task.Title} taskId={task.Id}
/> taskTitle={task.Title}
/>
)}
</div> </div>
)} )}
</div> </div>
...@@ -110,7 +113,31 @@ export function TaskBoard({ ...@@ -110,7 +113,31 @@ export function TaskBoard({
</span> </span>
</div> </div>
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-1 text-xs text-muted-foreground">
<div>{task.AssigneeName ?? "Sem responsável"}</div> <div className="flex items-center gap-1.5">
<span>{task.AssigneeName ?? "Sem responsável"}</span>
{task.Collaborators.length > 0 && (
<span
className="flex -space-x-1.5"
title={task.Collaborators
.map((c) => c.Name)
.join(", ")}
>
{task.Collaborators.slice(0, 3).map((c) => (
<span
key={c.UserId}
className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-foreground ring-1 ring-background"
>
{initials(c.Name)}
</span>
))}
{task.Collaborators.length > 3 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-foreground ring-1 ring-background">
+{task.Collaborators.length - 3}
</span>
)}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{task.StartDate && ( {task.StartDate && (
<span <span
......
...@@ -30,6 +30,7 @@ import { ...@@ -30,6 +30,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
export interface TaskFormValues { export interface TaskFormValues {
id: number; id: number;
...@@ -45,6 +46,7 @@ export interface TaskFormValues { ...@@ -45,6 +46,7 @@ export interface TaskFormValues {
entry: number | null; entry: number | null;
metaType: string | null; // 'teto' | 'piso' | null metaType: string | null; // 'teto' | 'piso' | null
metaValue: number | null; metaValue: number | null;
collaboratorIds: number[];
} }
function SubmitButton({ function SubmitButton({
...@@ -94,8 +96,8 @@ export function TaskDialog({ ...@@ -94,8 +96,8 @@ export function TaskDialog({
trigger: ReactNode; trigger: ReactNode;
}) { }) {
const isSubtask = entryId !== undefined && !task; const isSubtask = entryId !== undefined && !task;
// Meta só existe em tarefa-mãe: some ao criar/editar subtarefa. // Meta e compartilhamento só existem em tarefa-mãe.
const showMeta = !isSubtask && !(task && task.entry != null); const isMotherTask = !isSubtask && !(task && task.entry != null);
// 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,6 +107,9 @@ export function TaskDialog({ ...@@ -105,6 +107,9 @@ export function TaskDialog({
); );
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [metaType, setMetaType] = useState<string>(task?.metaType ?? "none"); const [metaType, setMetaType] = useState<string>(task?.metaType ?? "none");
const [collabs, setCollabs] = useState<number[]>(
task?.collaboratorIds ?? []
);
const action = task const action = task
? updateTaskAction.bind(null, task.id) ? updateTaskAction.bind(null, task.id)
: createTaskAction; : createTaskAction;
...@@ -223,6 +228,49 @@ export function TaskDialog({ ...@@ -223,6 +228,49 @@ export function TaskDialog({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{isMotherTask && members.length > 1 && (
<div className="space-y-2">
<Label>Compartilhar com</Label>
<div className="flex flex-wrap gap-1.5">
{members.map((m) => {
const selected = collabs.includes(m.UserId);
return (
<button
key={m.UserId}
type="button"
onClick={() =>
setCollabs((prev) =>
selected
? prev.filter((id) => id !== m.UserId)
: [...prev, m.UserId]
)
}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted"
)}
>
{m.Name}
</button>
);
})}
</div>
{collabs.map((id) => (
<input
key={id}
type="hidden"
name="collaborators"
value={id}
/>
))}
<p className="text-xs text-muted-foreground">
Quem for marcado também vê e edita a tarefa, e ela aparece na
pasta dele. O responsável não precisa ser marcado.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="task-start">Início</Label> <Label htmlFor="task-start">Início</Label>
...@@ -282,7 +330,7 @@ export function TaskDialog({ ...@@ -282,7 +330,7 @@ export function TaskDialog({
: "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>
{showMeta && ( {isMotherTask && (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3"> <div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
......
...@@ -6,7 +6,7 @@ import type { Task, WorkspaceMember } from "@/lib/data"; ...@@ -6,7 +6,7 @@ import type { Task, WorkspaceMember } from "@/lib/data";
import { TASK_STATUSES } from "@/lib/constants"; import { TASK_STATUSES } from "@/lib/constants";
import { formatDateShort, getDueInfo } from "@/lib/dates"; import { formatDateShort, getDueInfo } from "@/lib/dates";
import { getMetaInfo } from "@/lib/meta"; import { getMetaInfo } from "@/lib/meta";
import { canEditTask } from "@/lib/permissions"; import { canDeleteTask, 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";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -196,6 +196,7 @@ export function TaskTable({ ...@@ -196,6 +196,7 @@ export function TaskTable({
<TableBody> <TableBody>
{filteredTasks.map((task) => { {filteredTasks.map((task) => {
const editable = canEditTask(task, currentUserId, isAdmin); const editable = canEditTask(task, currentUserId, isAdmin);
const deletable = canDeleteTask(task, currentUserId, isAdmin);
const subtasks = subtasksByTask[task.Id] ?? []; const subtasks = subtasksByTask[task.Id] ?? [];
const hasSubtasks = subtasks.length > 0; const hasSubtasks = subtasks.length > 0;
const isOpen = expanded.includes(task.Id); const isOpen = expanded.includes(task.Id);
...@@ -284,16 +285,46 @@ export function TaskTable({ ...@@ -284,16 +285,46 @@ export function TaskTable({
)} )}
</TableCell> </TableCell>
<TableCell className="py-3"> <TableCell className="py-3">
{task.AssigneeName ? ( {task.AssigneeName || task.Collaborators.length > 0 ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="h-7 w-7"> <div className="flex -space-x-2">
<AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary"> {task.AssigneeName && (
{initials(task.AssigneeName)} <Avatar
</AvatarFallback> className="h-7 w-7 ring-2 ring-background"
</Avatar> title={`${task.AssigneeName} (responsável)`}
<span className="hidden text-sm lg:inline"> >
{task.AssigneeName} <AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary">
</span> {initials(task.AssigneeName)}
</AvatarFallback>
</Avatar>
)}
{task.Collaborators.slice(0, 3).map((c) => (
<Avatar
key={c.UserId}
className="h-7 w-7 ring-2 ring-background"
title={`${c.Name} (compartilhada)`}
>
<AvatarFallback className="bg-muted text-[10px] font-semibold text-foreground">
{initials(c.Name)}
</AvatarFallback>
</Avatar>
))}
{task.Collaborators.length > 3 && (
<span
className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-[10px] font-semibold ring-2 ring-background"
title={task.Collaborators
.map((c) => c.Name)
.join(", ")}
>
+{task.Collaborators.length - 3}
</span>
)}
</div>
{task.AssigneeName && (
<span className="hidden text-sm lg:inline">
{task.AssigneeName}
</span>
)}
</div> </div>
) : ( ) : (
<span className="text-sm text-muted-foreground"></span> <span className="text-sm text-muted-foreground"></span>
...@@ -344,10 +375,12 @@ export function TaskTable({ ...@@ -344,10 +375,12 @@ export function TaskTable({
</Button> </Button>
} }
/> />
<TaskDeleteButton {deletable && (
taskId={task.Id} <TaskDeleteButton
taskTitle={task.Title} taskId={task.Id}
/> taskTitle={task.Title}
/>
)}
</div> </div>
)} )}
</TableCell> </TableCell>
......
...@@ -27,6 +27,7 @@ export function toFormValues(task: Task): TaskFormValues { ...@@ -27,6 +27,7 @@ export function toFormValues(task: Task): TaskFormValues {
entry: task.Entry ?? null, entry: task.Entry ?? null,
metaType: task.MetaType, metaType: task.MetaType,
metaValue: task.MetaValue, metaValue: task.MetaValue,
collaboratorIds: task.Collaborators.map((c) => c.UserId),
}; };
} }
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
verifyPassword, verifyPassword,
} from "./auth"; } from "./auth";
import { import {
assertCanDeleteTask,
assertCanEditTask, assertCanEditTask,
assertSuperAdmin, assertSuperAdmin,
assertWorkspaceAdmin, assertWorkspaceAdmin,
...@@ -315,6 +316,8 @@ const taskSchema = z ...@@ -315,6 +316,8 @@ const taskSchema = z
.int() .int()
.positive("A meta deve ser um número maior que zero.") .positive("A meta deve ser um número maior que zero.")
.optional(), .optional(),
// Usuários vinculados (compartilhamento) — só na tarefa-mãe.
collaborators: z.array(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,
...@@ -345,9 +348,28 @@ function parseTaskForm(formData: FormData) { ...@@ -345,9 +348,28 @@ function parseTaskForm(formData: FormData) {
entry: formData.get("entry") || undefined, entry: formData.get("entry") || undefined,
metaType: parseMetaType(formData.get("metaType")), metaType: parseMetaType(formData.get("metaType")),
metaValue: formData.get("metaValue") || undefined, metaValue: formData.get("metaValue") || undefined,
collaborators: formData.getAll("collaborators"),
}); });
} }
/**
* Restringe os colaboradores aos membros do espaço, remove o próprio
* responsável (para não duplicar) e deduplica.
*/
async function resolveCollaborators(
workspaceId: number,
assigneeId: number | null,
collaborators: number[] | undefined
): Promise<number[]> {
if (!collaborators || collaborators.length === 0) return [];
const memberIds = new Set(
(await data.listWorkspaceMembers(workspaceId)).map((m) => m.UserId)
);
return Array.from(new Set(collaborators)).filter(
(id) => memberIds.has(id) && id !== assigneeId
);
}
export async function createTaskAction( export async function createTaskAction(
_prev: ActionState, _prev: ActionState,
formData: FormData formData: FormData
...@@ -371,7 +393,7 @@ export async function createTaskAction( ...@@ -371,7 +393,7 @@ export async function createTaskAction(
error: "Apenas admins podem criar tarefas para outra pessoa.", error: "Apenas admins podem criar tarefas para outra pessoa.",
}; };
} }
await data.createTask( const newTaskId = await data.createTask(
{ {
workspaceId: parsed.data.workspaceId, workspaceId: parsed.data.workspaceId,
title: parsed.data.title, title: parsed.data.title,
...@@ -389,6 +411,15 @@ export async function createTaskAction( ...@@ -389,6 +411,15 @@ export async function createTaskAction(
}, },
user.id user.id
); );
// Compartilhamento só em tarefa-mãe.
if (!parsed.data.entry) {
const collaborators = await resolveCollaborators(
parsed.data.workspaceId,
parsed.data.assigneeId ?? null,
parsed.data.collaborators
);
await data.setTaskCollaborators(newTaskId, collaborators);
}
} catch (err) { } catch (err) {
return { return {
error: err instanceof Error ? err.message : "Erro ao criar a tarefa.", error: err instanceof Error ? err.message : "Erro ao criar a tarefa.",
...@@ -438,6 +469,15 @@ export async function updateTaskAction( ...@@ -438,6 +469,15 @@ export async function updateTaskAction(
metaType: task.Entry != null ? null : (parsed.data.metaType ?? null), metaType: task.Entry != null ? null : (parsed.data.metaType ?? null),
metaValue: task.Entry != null ? null : (parsed.data.metaValue ?? null), metaValue: task.Entry != null ? null : (parsed.data.metaValue ?? null),
}); });
// Compartilhamento só em tarefa-mãe.
if (task.Entry == null) {
const collaborators = await resolveCollaborators(
task.WorkspaceId,
parsed.data.assigneeId ?? null,
parsed.data.collaborators
);
await data.setTaskCollaborators(taskId, collaborators);
}
revalidatePath(`/workspaces/${task.WorkspaceId}`); revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) { } catch (err) {
return { return {
...@@ -498,7 +538,7 @@ export async function deleteTaskAction(taskId: number): Promise<ActionState> { ...@@ -498,7 +538,7 @@ export async function deleteTaskAction(taskId: number): Promise<ActionState> {
try { try {
const task = await data.getTask(taskId); const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." }; if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user); await assertCanDeleteTask(task, user);
await data.deleteTask(taskId); await data.deleteTask(taskId);
revalidatePath(`/workspaces/${task.WorkspaceId}`); revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) { } catch (err) {
......
...@@ -2,7 +2,7 @@ import "server-only"; ...@@ -2,7 +2,7 @@ import "server-only";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getPool, sql } from "./db"; import { getPool, sql } from "./db";
import { getSession, type SessionUser } from "./auth"; import { getSession, type SessionUser } from "./auth";
import { canEditTask } from "./permissions"; import { canDeleteTask, canEditTask } from "./permissions";
/** /**
* Políticas de autorização: * Políticas de autorização:
...@@ -101,7 +101,12 @@ export async function assertWorkspaceMember( ...@@ -101,7 +101,12 @@ export async function assertWorkspaceMember(
* a tarefa não tem responsável). * a tarefa não tem responsável).
*/ */
export async function assertCanEditTask( export async function assertCanEditTask(
task: { WorkspaceId: number; AssigneeId: number | null; CreatedById: number }, task: {
WorkspaceId: number;
AssigneeId: number | null;
CreatedById: number;
Collaborators?: { UserId: number }[];
},
user: SessionUser user: SessionUser
): Promise<void> { ): Promise<void> {
const role = await getWorkspaceRole(task.WorkspaceId, user.id); const role = await getWorkspaceRole(task.WorkspaceId, user.id);
...@@ -112,3 +117,17 @@ export async function assertCanEditTask( ...@@ -112,3 +117,17 @@ export async function assertCanEditTask(
throw new Error("Apenas o responsável pela tarefa pode alterá-la."); throw new Error("Apenas o responsável pela tarefa pode alterá-la.");
} }
} }
/** Como assertCanEditTask, mas para exclusão (colaboradores não excluem). */
export async function assertCanDeleteTask(
task: { WorkspaceId: number; AssigneeId: number | null; CreatedById: number },
user: SessionUser
): Promise<void> {
const role = await getWorkspaceRole(task.WorkspaceId, user.id);
if (!role) {
throw new Error("Você não tem acesso a este espaço de trabalho.");
}
if (!canDeleteTask(task, user.id, role === "admin")) {
throw new Error("Apenas o responsável pode excluir esta tarefa.");
}
}
...@@ -48,6 +48,18 @@ export interface Task { ...@@ -48,6 +48,18 @@ export interface Task {
CreatedAt: Date; CreatedAt: Date;
SubtaskCount: number; SubtaskCount: number;
SubtaskDone: number; SubtaskDone: number;
/** Usuários vinculados além do responsável (compartilhamento). */
Collaborators: { UserId: number; Name: string }[];
}
/** Converte a coluna CollaboratorsJson (FOR JSON) no array Collaborators. */
function mapTask(row: Record<string, unknown>): Task {
const { CollaboratorsJson, ...rest } = row;
return {
...(rest as unknown as Task),
Collaborators:
typeof CollaboratorsJson === "string" ? JSON.parse(CollaboratorsJson) : [],
};
} }
// ---------- Usuários ---------- // ---------- Usuários ----------
...@@ -262,7 +274,11 @@ const TASK_SELECT = ` ...@@ -262,7 +274,11 @@ const TASK_SELECT = `
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById, 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.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) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone (SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone,
(SELECT u.Id AS UserId, u.Name
FROM dbo.TaskCollaborators tc JOIN dbo.Users u ON u.Id = tc.UserId
WHERE tc.TaskId = t.Id
FOR JSON PATH) AS CollaboratorsJson
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`;
...@@ -279,7 +295,7 @@ export async function listTasksByWorkspace( ...@@ -279,7 +295,7 @@ export async function listTasksByWorkspace(
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NULL 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.map(mapTask);
} }
export async function getTask(id: number): Promise<Task | null> { export async function getTask(id: number): Promise<Task | null> {
...@@ -288,7 +304,8 @@ export async function getTask(id: number): Promise<Task | null> { ...@@ -288,7 +304,8 @@ export async function getTask(id: number): Promise<Task | null> {
.request() .request()
.input("id", sql.Int, id) .input("id", sql.Int, id)
.query(`${TASK_SELECT} WHERE t.Id = @id`); .query(`${TASK_SELECT} WHERE t.Id = @id`);
return result.recordset[0] ?? null; const row = result.recordset[0];
return row ? mapTask(row) : null;
} }
export interface TaskInput { export interface TaskInput {
...@@ -308,9 +325,9 @@ export interface TaskInput { ...@@ -308,9 +325,9 @@ export interface TaskInput {
export async function createTask( export async function createTask(
input: TaskInput, input: TaskInput,
createdById: number createdById: number
): Promise<void> { ): Promise<number> {
const pool = await getPool(); const pool = await getPool();
await pool const result = await pool
.request() .request()
.input("workspaceId", sql.Int, input.workspaceId) .input("workspaceId", sql.Int, input.workspaceId)
.input("title", sql.NVarChar(200), input.title.trim()) .input("title", sql.NVarChar(200), input.title.trim())
...@@ -327,10 +344,41 @@ export async function createTask( ...@@ -327,10 +344,41 @@ export async function createTask(
.input("createdById", sql.Int, createdById) .input("createdById", sql.Int, createdById)
.query( .query(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress, MetaType, MetaValue, CreatedById) `INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress, MetaType, MetaValue, CreatedById)
OUTPUT INSERTED.Id
VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate, VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END, CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
@progress, @metaType, @metaValue, @createdById)` @progress, @metaType, @metaValue, @createdById)`
); );
return result.recordset[0].Id;
}
/** Substitui a lista de colaboradores (usuários vinculados) de uma tarefa. */
export async function setTaskCollaborators(
taskId: number,
userIds: number[]
): Promise<void> {
const pool = await getPool();
const tx = new sql.Transaction(pool);
await tx.begin();
try {
await tx
.request()
.input("taskId", sql.Int, taskId)
.query("DELETE FROM dbo.TaskCollaborators WHERE TaskId = @taskId");
for (const userId of userIds) {
await tx
.request()
.input("taskId", sql.Int, taskId)
.input("userId", sql.Int, userId)
.query(
"INSERT INTO dbo.TaskCollaborators (TaskId, UserId) VALUES (@taskId, @userId)"
);
}
await tx.commit();
} catch (err) {
await tx.rollback();
throw err;
}
} }
export async function updateTask(id: number, input: TaskInput): Promise<void> { export async function updateTask(id: number, input: TaskInput): Promise<void> {
...@@ -419,7 +467,7 @@ export async function listSubtasksByWorkspace( ...@@ -419,7 +467,7 @@ export async function listSubtasksByWorkspace(
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL
ORDER BY t.Id DESC` ORDER BY t.Id DESC`
); );
return result.recordset; return result.recordset.map(mapTask);
} }
// ---------- Painel (dashboard) ---------- // ---------- Painel (dashboard) ----------
...@@ -466,5 +514,5 @@ export async function getDueAlertsForUser( ...@@ -466,5 +514,5 @@ export async function getDueAlertsForUser(
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`
); );
return result.recordset; return result.recordset.map(mapTask);
} }
...@@ -75,6 +75,17 @@ const DDL_STATEMENTS: string[] = [ ...@@ -75,6 +75,17 @@ const DDL_STATEMENTS: string[] = [
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL `IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`, ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`,
// Tarefas compartilhadas: usuários vinculados além do responsável
`IF OBJECT_ID('dbo.TaskCollaborators', 'U') IS NULL
CREATE TABLE dbo.TaskCollaborators (
TaskId INT NOT NULL REFERENCES dbo.Tasks(Id) ON DELETE CASCADE,
UserId INT NOT NULL REFERENCES dbo.Users(Id),
CONSTRAINT PK_TaskCollaborators PRIMARY KEY (TaskId, UserId)
)`,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TaskCollaborators_UserId')
CREATE INDEX IX_TaskCollaborators_UserId ON dbo.TaskCollaborators (UserId)`,
`IF OBJECT_ID('dbo.Subtasks', 'U') IS NULL `IF OBJECT_ID('dbo.Subtasks', 'U') IS NULL
CREATE TABLE dbo.Subtasks ( CREATE TABLE dbo.Subtasks (
Id INT IDENTITY(1,1) PRIMARY KEY, Id INT IDENTITY(1,1) PRIMARY KEY,
......
...@@ -2,9 +2,30 @@ ...@@ -2,9 +2,30 @@
* Política de edição de tarefas (compartilhada entre servidor e UI): * Política de edição de tarefas (compartilhada entre servidor e UI):
* - admins do workspace podem editar/excluir qualquer tarefa; * - admins do workspace podem editar/excluir qualquer tarefa;
* - o responsável (assignee) edita as tarefas dele; * - o responsável (assignee) edita as tarefas dele;
* - colaboradores vinculados também podem editar/atualizar a tarefa;
* - tarefas sem responsável podem ser editadas por quem as criou. * - tarefas sem responsável podem ser editadas por quem as criou.
*/ */
export function canEditTask( export function canEditTask(
task: {
AssigneeId: number | null;
CreatedById: number;
Collaborators?: { UserId: number }[];
},
userId: number,
isAdmin: boolean
): boolean {
if (isAdmin) return true;
if (task.AssigneeId === userId) return true;
if (task.Collaborators?.some((c) => c.UserId === userId)) return true;
return task.AssigneeId === null && task.CreatedById === userId;
}
/**
* Exclusão é mais restrita que edição: colaboradores podem atualizar, mas não
* excluir a tarefa compartilhada — só admin, responsável ou (sem responsável)
* o criador.
*/
export function canDeleteTask(
task: { AssigneeId: number | null; CreatedById: number }, task: { AssigneeId: number | null; CreatedById: number },
userId: number, userId: number,
isAdmin: boolean isAdmin: boolean
......
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