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({
if (memberId !== 0 && !member) notFound();
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 subtasksByTask = allSubtasks
......
......@@ -2,7 +2,7 @@ import { CalendarCheck, CalendarClock, CalendarPlus, Pencil } from "lucide-react
import { TASK_STATUSES } from "@/lib/constants";
import type { Task, WorkspaceMember } from "@/lib/data";
import { formatDateShort } from "@/lib/dates";
import { canEditTask } from "@/lib/permissions";
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";
......@@ -15,7 +15,7 @@ import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog";
import { taskPercent } from "./task-progress";
import { TaskStatusSelect } from "./task-status-select";
import { toFormValues } from "./task-utils";
import { initials, toFormValues } from "./task-utils";
export function TaskBoard({
tasks,
......@@ -47,6 +47,7 @@ export function TaskBoard({
<div className="flex flex-col gap-2">
{columnTasks.map((task) => {
const editable = canEditTask(task, currentUserId, isAdmin);
const deletable = canDeleteTask(task, currentUserId, isAdmin);
const percent = taskPercent(
task.Status,
task.SubtaskCount,
......@@ -80,10 +81,12 @@ export function TaskBoard({
</Button>
}
/>
<TaskDeleteButton
taskId={task.Id}
taskTitle={task.Title}
/>
{deletable && (
<TaskDeleteButton
taskId={task.Id}
taskTitle={task.Title}
/>
)}
</div>
)}
</div>
......@@ -110,7 +113,31 @@ export function TaskBoard({
</span>
</div>
<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">
{task.StartDate && (
<span
......
......@@ -30,6 +30,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
export interface TaskFormValues {
id: number;
......@@ -45,6 +46,7 @@ export interface TaskFormValues {
entry: number | null;
metaType: string | null; // 'teto' | 'piso' | null
metaValue: number | null;
collaboratorIds: number[];
}
function SubmitButton({
......@@ -94,8 +96,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);
// Meta e compartilhamento só existem em tarefa-mãe.
const isMotherTask = !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
......@@ -105,6 +107,9 @@ export function TaskDialog({
);
const [open, setOpen] = useState(false);
const [metaType, setMetaType] = useState<string>(task?.metaType ?? "none");
const [collabs, setCollabs] = useState<number[]>(
task?.collaboratorIds ?? []
);
const action = task
? updateTaskAction.bind(null, task.id)
: createTaskAction;
......@@ -223,6 +228,49 @@ export function TaskDialog({
</SelectContent>
</Select>
</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="space-y-2">
<Label htmlFor="task-start">Início</Label>
......@@ -282,7 +330,7 @@ export function TaskDialog({
: "Digite quanto da tarefa já foi concluído (0 a 100)."}
</p>
</div>
{showMeta && (
{isMotherTask && (
<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">
......
......@@ -6,7 +6,7 @@ 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 { canDeleteTask, canEditTask } from "@/lib/permissions";
import { initials, toFormValues } from "./task-utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
......@@ -196,6 +196,7 @@ export function TaskTable({
<TableBody>
{filteredTasks.map((task) => {
const editable = canEditTask(task, currentUserId, isAdmin);
const deletable = canDeleteTask(task, currentUserId, isAdmin);
const subtasks = subtasksByTask[task.Id] ?? [];
const hasSubtasks = subtasks.length > 0;
const isOpen = expanded.includes(task.Id);
......@@ -284,16 +285,46 @@ export function TaskTable({
)}
</TableCell>
<TableCell className="py-3">
{task.AssigneeName ? (
{task.AssigneeName || task.Collaborators.length > 0 ? (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary">
{initials(task.AssigneeName)}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm lg:inline">
{task.AssigneeName}
</span>
<div className="flex -space-x-2">
{task.AssigneeName && (
<Avatar
className="h-7 w-7 ring-2 ring-background"
title={`${task.AssigneeName} (responsável)`}
>
<AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary">
{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>
) : (
<span className="text-sm text-muted-foreground"></span>
......@@ -344,10 +375,12 @@ export function TaskTable({
</Button>
}
/>
<TaskDeleteButton
taskId={task.Id}
taskTitle={task.Title}
/>
{deletable && (
<TaskDeleteButton
taskId={task.Id}
taskTitle={task.Title}
/>
)}
</div>
)}
</TableCell>
......
......@@ -27,6 +27,7 @@ export function toFormValues(task: Task): TaskFormValues {
entry: task.Entry ?? null,
metaType: task.MetaType,
metaValue: task.MetaValue,
collaboratorIds: task.Collaborators.map((c) => c.UserId),
};
}
......
......@@ -10,6 +10,7 @@ import {
verifyPassword,
} from "./auth";
import {
assertCanDeleteTask,
assertCanEditTask,
assertSuperAdmin,
assertWorkspaceAdmin,
......@@ -315,6 +316,8 @@ const taskSchema = z
.int()
.positive("A meta deve ser um número maior que zero.")
.optional(),
// Usuários vinculados (compartilhamento) — só na tarefa-mãe.
collaborators: z.array(z.coerce.number().int().positive()).optional(),
})
.refine(
(t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate,
......@@ -345,9 +348,28 @@ function parseTaskForm(formData: FormData) {
entry: formData.get("entry") || undefined,
metaType: parseMetaType(formData.get("metaType")),
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(
_prev: ActionState,
formData: FormData
......@@ -371,7 +393,7 @@ export async function createTaskAction(
error: "Apenas admins podem criar tarefas para outra pessoa.",
};
}
await data.createTask(
const newTaskId = await data.createTask(
{
workspaceId: parsed.data.workspaceId,
title: parsed.data.title,
......@@ -389,6 +411,15 @@ export async function createTaskAction(
},
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) {
return {
error: err instanceof Error ? err.message : "Erro ao criar a tarefa.",
......@@ -438,6 +469,15 @@ export async function updateTaskAction(
metaType: task.Entry != null ? null : (parsed.data.metaType ?? 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}`);
} catch (err) {
return {
......@@ -498,7 +538,7 @@ export async function deleteTaskAction(taskId: number): Promise<ActionState> {
try {
const task = await data.getTask(taskId);
if (!task) return { error: "Tarefa não encontrada." };
await assertCanEditTask(task, user);
await assertCanDeleteTask(task, user);
await data.deleteTask(taskId);
revalidatePath(`/workspaces/${task.WorkspaceId}`);
} catch (err) {
......
......@@ -2,7 +2,7 @@ import "server-only";
import { redirect } from "next/navigation";
import { getPool, sql } from "./db";
import { getSession, type SessionUser } from "./auth";
import { canEditTask } from "./permissions";
import { canDeleteTask, canEditTask } from "./permissions";
/**
* Políticas de autorização:
......@@ -101,7 +101,12 @@ export async function assertWorkspaceMember(
* a tarefa não tem responsável).
*/
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
): Promise<void> {
const role = await getWorkspaceRole(task.WorkspaceId, user.id);
......@@ -112,3 +117,17 @@ export async function assertCanEditTask(
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 {
CreatedAt: Date;
SubtaskCount: 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 ----------
......@@ -262,7 +274,11 @@ const TASK_SELECT = `
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,
(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
LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`;
......@@ -279,7 +295,7 @@ export async function listTasksByWorkspace(
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;
return result.recordset.map(mapTask);
}
export async function getTask(id: number): Promise<Task | null> {
......@@ -288,7 +304,8 @@ export async function getTask(id: number): Promise<Task | null> {
.request()
.input("id", sql.Int, 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 {
......@@ -308,9 +325,9 @@ export interface TaskInput {
export async function createTask(
input: TaskInput,
createdById: number
): Promise<void> {
): Promise<number> {
const pool = await getPool();
await pool
const result = await pool
.request()
.input("workspaceId", sql.Int, input.workspaceId)
.input("title", sql.NVarChar(200), input.title.trim())
......@@ -327,10 +344,41 @@ export async function createTask(
.input("createdById", sql.Int, createdById)
.query(
`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,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
@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> {
......@@ -419,7 +467,7 @@ export async function listSubtasksByWorkspace(
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL
ORDER BY t.Id DESC`
);
return result.recordset;
return result.recordset.map(mapTask);
}
// ---------- Painel (dashboard) ----------
......@@ -466,5 +514,5 @@ export async function getDueAlertsForUser(
AND t.DueDate <= DATEADD(day, @days, CAST(GETDATE() AS DATE))
ORDER BY t.DueDate`
);
return result.recordset;
return result.recordset.map(mapTask);
}
......@@ -75,6 +75,17 @@ const DDL_STATEMENTS: string[] = [
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS 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
CREATE TABLE dbo.Subtasks (
Id INT IDENTITY(1,1) PRIMARY KEY,
......
......@@ -2,9 +2,30 @@
* Política de edição de tarefas (compartilhada entre servidor e UI):
* - admins do workspace podem editar/excluir qualquer tarefa;
* - 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.
*/
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 },
userId: number,
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