Skip to content
GitLab
Projects
Groups
Topics
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Register
Sign in
Toggle navigation
Menu
Paulo Avila
petruz-tasks
Commits
24d624aa
Commit
24d624aa
authored
Jul 11, 2026
by
Paulo Avila
Browse files
Tarefas compartilhadas entre colaboradores
parent
ca3426d3
Changes
10
Hide whitespace changes
Inline
Side-by-side
src/app/(app)/workspaces/[id]/membro/[memberId]/page.tsx
View file @
24d624aa
...
@@ -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
...
...
src/components/tasks/task-board.tsx
View file @
24d624aa
...
@@ -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
...
...
src/components/tasks/task-dialog.tsx
View file @
24d624aa
...
@@ -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ó existe
m
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"
>
...
...
src/components/tasks/task-table.tsx
View file @
24d624aa
...
@@ -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
>
...
...
src/components/tasks/task-utils.ts
View file @
24d624aa
...
@@ -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
),
};
};
}
}
...
...
src/lib/actions.ts
View file @
24d624aa
...
@@ -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
assertCan
Edit
Task
(
task
,
user
);
await
assertCan
Delete
Task
(
task
,
user
);
await
data
.
deleteTask
(
taskId
);
await
data
.
deleteTask
(
taskId
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
}
catch
(
err
)
{
...
...
src/lib/authz.ts
View file @
24d624aa
...
@@ -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.
"
);
}
}
src/lib/data.ts
View file @
24d624aa
...
@@ -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
)
;
}
}
src/lib/db-init.ts
View file @
24d624aa
...
@@ -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,
...
...
src/lib/permissions.ts
View file @
24d624aa
...
@@ -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
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment