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
987e8471
Commit
987e8471
authored
Jun 17, 2026
by
Paulo Avila
Browse files
Feature migração de subtarefas
parent
7ab38757
Changes
11
Hide whitespace changes
Inline
Side-by-side
migrations/001_subtasks_to_entry.sql
0 → 100644
View file @
987e8471
-- =============================================================================
-- Migração: subtarefas (dbo.Subtasks) -> tarefas-filhas em dbo.Tasks (coluna Entry)
-- =============================================================================
-- A estrutura antiga guardava subtarefas em dbo.Subtasks (Id, TaskId, Title,
-- IsDone). A nova trata subtarefa como uma tarefa em dbo.Tasks com Entry = Id
-- da tarefa-mãe.
--
-- IDEMPOTENTE: ao final renomeia dbo.Subtasks -> dbo.Subtasks_migrated (backup).
-- Rodar de novo não faz nada. A cópia + rename ficam numa transação atômica.
--
-- ATENÇÃO PROD: faça BACKUP do banco antes. Use um login com permissão de DDL
-- (sa ou db_owner) — ALTER TABLE / CREATE INDEX / sp_rename exigem isso.
-- Execute no banco TASK.
-- =============================================================================
SET
NOCOUNT
ON
;
SET
XACT_ABORT
ON
;
GO
-- 1) Garante a coluna Entry (idempotente)
IF
COL_LENGTH
(
'dbo.Tasks'
,
'Entry'
)
IS
NULL
ALTER
TABLE
dbo
.
Tasks
ADD
Entry
INT
NULL
;
GO
-- 2) Índice para consultas por tarefa-mãe (idempotente)
IF
NOT
EXISTS
(
SELECT
1
FROM
sys
.
indexes
WHERE
name
=
'IX_Tasks_Entry'
)
CREATE
INDEX
IX_Tasks_Entry
ON
dbo
.
Tasks
(
Entry
);
GO
-- 3) Copia as subtarefas antigas como tarefas-filhas e arquiva a tabela antiga.
-- (a coluna Entry já existe e está commitada pelos lotes acima)
IF
OBJECT_ID
(
'dbo.Subtasks'
,
'U'
)
IS
NOT
NULL
BEGIN
BEGIN
TRANSACTION
;
INSERT
INTO
dbo
.
Tasks
(
WorkspaceId
,
Title
,
Description
,
Status
,
Priority
,
Entry
,
AssigneeId
,
CreatedById
,
StartDate
,
DueDate
,
Progress
,
CreatedAt
,
UpdatedAt
)
SELECT
t
.
WorkspaceId
,
s
.
Title
,
NULL
,
CASE
WHEN
s
.
IsDone
=
1
THEN
'done'
ELSE
'todo'
END
,
'medium'
,
s
.
TaskId
,
-- Entry = id da tarefa-mãe
NULL
,
-- responsável: defina depois se quiser
t
.
CreatedById
,
-- herda o criador da mãe
NULL
,
NULL
,
CASE
WHEN
s
.
IsDone
=
1
THEN
100
ELSE
0
END
,
s
.
CreatedAt
,
SYSUTCDATETIME
()
FROM
dbo
.
Subtasks
s
JOIN
dbo
.
Tasks
t
ON
t
.
Id
=
s
.
TaskId
;
-- ignora subtarefas órfãs
PRINT
CONCAT
(
'Subtarefas migradas: '
,
@@
ROWCOUNT
);
EXEC
sp_rename
'dbo.Subtasks'
,
'Subtasks_migrated'
;
COMMIT
TRANSACTION
;
PRINT
'Migração concluída. Tabela antiga arquivada como dbo.Subtasks_migrated.'
;
END
ELSE
PRINT
'Nada a migrar: dbo.Subtasks não existe (já migrado).'
;
GO
-- Conferência (opcional):
-- SELECT Entry AS TarefaMae, COUNT(*) AS Subtarefas
-- FROM dbo.Tasks WHERE Entry IS NOT NULL GROUP BY Entry;
src/app/(app)/workspaces/[id]/membro/[memberId]/page.tsx
View file @
987e8471
...
...
@@ -7,7 +7,7 @@ import {
listSubtasksByWorkspace
,
listTasksByWorkspace
,
listWorkspaceMembers
,
type
Subt
ask
,
type
T
ask
,
}
from
"
@/lib/data
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
...
...
@@ -57,9 +57,9 @@ export default async function MemberFolderPage({
);
const
taskIds
=
new
Set
(
tasks
.
map
((
t
)
=>
t
.
Id
));
const
subtasksByTask
=
allSubtasks
.
filter
((
s
)
=>
taskIds
.
has
(
s
.
TaskId
))
.
reduce
<
Record
<
number
,
Subt
ask
[]
>>
((
acc
,
subtask
)
=>
{
(
acc
[
subtask
.
TaskId
]
??=
[]).
push
(
subtask
);
.
filter
((
s
)
=>
s
.
Entry
!=
null
&&
taskIds
.
has
(
s
.
Entry
))
.
reduce
<
Record
<
number
,
T
ask
[]
>>
((
acc
,
subtask
)
=>
{
(
acc
[
subtask
.
Entry
as
number
]
??=
[]).
push
(
subtask
);
return
acc
;
},
{});
...
...
src/app/(app)/workspaces/[id]/page.tsx
View file @
987e8471
...
...
@@ -6,7 +6,7 @@ import {
listSubtasksByWorkspace
,
listTasksByWorkspace
,
listWorkspaceMembers
,
type
Subt
ask
,
type
T
ask
,
}
from
"
@/lib/data
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Tabs
,
TabsContent
,
TabsList
,
TabsTrigger
}
from
"
@/components/ui/tabs
"
;
...
...
@@ -38,9 +38,9 @@ export default async function WorkspacePage({
listSubtasksByWorkspace
(
workspaceId
),
]);
const
subtasksByTask
=
subtasks
.
reduce
<
Record
<
number
,
Subt
ask
[]
>>
(
const
subtasksByTask
=
subtasks
.
reduce
<
Record
<
number
,
T
ask
[]
>>
(
(
acc
,
subtask
)
=>
{
(
acc
[
subtask
.
TaskId
]
??=
[]).
push
(
subtask
);
if
(
subtask
.
Entry
!=
null
)
(
acc
[
subtask
.
Entry
]
??=
[]).
push
(
subtask
);
return
acc
;
},
{}
...
...
src/components/tasks/subtask-list.tsx
deleted
100644 → 0
View file @
7ab38757
"
use client
"
;
import
{
useState
,
useTransition
}
from
"
react
"
;
import
{
Plus
,
Trash2
}
from
"
lucide-react
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
createSubtaskAction
,
deleteSubtaskAction
,
toggleSubtaskAction
,
}
from
"
@/lib/actions
"
;
import
type
{
Subtask
}
from
"
@/lib/data
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Checkbox
}
from
"
@/components/ui/checkbox
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
export
function
SubtaskList
({
taskId
,
subtasks
,
}:
{
taskId
:
number
;
subtasks
:
Subtask
[];
})
{
const
[
title
,
setTitle
]
=
useState
(
""
);
const
[
pending
,
startTransition
]
=
useTransition
();
const
total
=
subtasks
.
length
;
const
done
=
subtasks
.
filter
((
s
)
=>
s
.
IsDone
).
length
;
const
percent
=
total
>
0
?
Math
.
round
((
done
/
total
)
*
100
)
:
0
;
function
handleAdd
()
{
const
value
=
title
.
trim
();
if
(
!
value
)
return
;
startTransition
(
async
()
=>
{
const
result
=
await
createSubtaskAction
(
taskId
,
value
);
if
(
result
.
error
)
toast
.
error
(
result
.
error
);
else
setTitle
(
""
);
});
}
function
handleToggle
(
subtaskId
:
number
,
isDone
:
boolean
)
{
startTransition
(
async
()
=>
{
const
result
=
await
toggleSubtaskAction
(
subtaskId
,
isDone
);
if
(
result
.
error
)
toast
.
error
(
result
.
error
);
});
}
function
handleDelete
(
subtaskId
:
number
)
{
startTransition
(
async
()
=>
{
const
result
=
await
deleteSubtaskAction
(
subtaskId
);
if
(
result
.
error
)
toast
.
error
(
result
.
error
);
});
}
return
(
<
div
className
=
"space-y-3"
>
<
div
className
=
"flex items-center justify-between"
>
<
Label
>
Subtarefas
</
Label
>
{
total
>
0
&&
(
<
span
className
=
"text-xs text-muted-foreground"
>
{
done
}
/
{
total
}
·
{
percent
}
% concluído
</
span
>
)
}
</
div
>
{
total
>
0
&&
<
Progress
value
=
{
percent
}
/>
}
<
ul
className
=
"max-h-44 space-y-1.5 overflow-y-auto"
>
{
subtasks
.
map
((
subtask
)
=>
(
<
li
key
=
{
subtask
.
Id
}
className
=
"group flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<
Checkbox
checked
=
{
subtask
.
IsDone
}
disabled
=
{
pending
}
onCheckedChange
=
{
(
checked
)
=>
handleToggle
(
subtask
.
Id
,
checked
===
true
)
}
/>
<
span
className
=
{
cn
(
"
flex-1 truncate text-sm
"
,
subtask
.
IsDone
&&
"
text-muted-foreground line-through
"
)
}
>
{
subtask
.
Title
}
</
span
>
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-6 w-6 text-muted-foreground hover:text-destructive"
disabled
=
{
pending
}
onClick
=
{
()
=>
handleDelete
(
subtask
.
Id
)
}
title
=
"Excluir subtarefa"
>
<
Trash2
className
=
"h-3.5 w-3.5"
/>
</
Button
>
</
li
>
))
}
</
ul
>
<
div
className
=
"flex gap-2"
>
<
Input
value
=
{
title
}
onChange
=
{
(
e
)
=>
setTitle
(
e
.
target
.
value
)
}
placeholder
=
"Nova subtarefa..."
className
=
"h-8"
onKeyDown
=
{
(
e
)
=>
{
if
(
e
.
key
===
"
Enter
"
)
{
e
.
preventDefault
();
handleAdd
();
}
}
}
/>
<
Button
type
=
"button"
size
=
"sm"
variant
=
"secondary"
disabled
=
{
pending
||
!
title
.
trim
()
}
onClick
=
{
handleAdd
}
>
<
Plus
className
=
"mr-1 h-4 w-4"
/>
Adicionar
</
Button
>
</
div
>
</
div
>
);
}
src/components/tasks/task-board.tsx
View file @
987e8471
import
{
CalendarClock
,
CalendarPlus
,
Pencil
}
from
"
lucide-react
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
type
{
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
formatDate
}
from
"
@/lib/dates
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
...
...
@@ -26,7 +26,7 @@ export function TaskBoard({
tasks
:
Task
[];
members
:
WorkspaceMember
[];
workspaceId
:
number
;
subtasksByTask
:
Record
<
number
,
Subt
ask
[]
>
;
subtasksByTask
:
Record
<
number
,
T
ask
[]
>
;
currentUserId
:
number
;
isAdmin
:
boolean
;
})
{
...
...
@@ -64,7 +64,7 @@ export function TaskBoard({
workspaceId
=
{
workspaceId
}
members
=
{
members
}
task
=
{
toFormValues
(
task
)
}
s
ubtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
hasS
ubtasks
=
{
(
subtasksByTask
[
task
.
Id
]
??
[]
).
length
>
0
}
currentUserId
=
{
currentUserId
}
isAdmin
=
{
isAdmin
}
trigger
=
{
...
...
src/components/tasks/task-by-person.tsx
View file @
987e8471
...
...
@@ -3,7 +3,7 @@
import
{
useMemo
,
useState
}
from
"
react
"
;
import
Link
from
"
next/link
"
;
import
{
ChevronRight
,
Plus
,
Search
,
UserRound
}
from
"
lucide-react
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
type
{
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
...
...
@@ -39,7 +39,6 @@ export function TaskByPerson({
workspaceId
:
number
;
currentUserId
:
number
;
isAdmin
:
boolean
;
subtasksByTask
?:
Record
<
number
,
Subtask
[]
>
;
})
{
const
[
search
,
setSearch
]
=
useState
(
""
);
...
...
src/components/tasks/task-dialog.tsx
View file @
987e8471
...
...
@@ -9,9 +9,7 @@ import {
type
ActionState
,
}
from
"
@/lib/actions
"
;
import
{
TASK_PRIORITIES
,
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
type
{
Subtask
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
Separator
}
from
"
@/components/ui/separator
"
;
import
{
SubtaskList
}
from
"
./subtask-list
"
;
import
type
{
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Dialog
,
...
...
@@ -45,7 +43,13 @@ export interface TaskFormValues {
progress
:
number
;
}
function
SubmitButton
({
isEdit
}:
{
isEdit
:
boolean
})
{
function
SubmitButton
({
isEdit
,
isSubtask
,
}:
{
isEdit
:
boolean
;
isSubtask
:
boolean
;
})
{
const
{
pending
}
=
useFormStatus
();
return
(
<
Button
type
=
"submit"
disabled
=
{
pending
}
className
=
"w-full"
>
...
...
@@ -53,7 +57,9 @@ function SubmitButton({ isEdit }: { isEdit: boolean }) {
?
"
Salvando...
"
:
isEdit
?
"
Salvar alterações
"
:
"
Criar tarefa
"
}
:
isSubtask
?
"
Criar subtarefa
"
:
"
Criar tarefa
"
}
</
Button
>
);
}
...
...
@@ -64,7 +70,8 @@ export function TaskDialog({
workspaceId
,
members
,
task
,
subtasks
,
hasSubtasks
=
false
,
entryId
,
defaultAssigneeId
,
currentUserId
,
isAdmin
,
...
...
@@ -73,12 +80,16 @@ export function TaskDialog({
workspaceId
:
number
;
members
:
WorkspaceMember
[];
task
?:
TaskFormValues
;
subtasks
?:
Subtask
[];
/** True quando a tarefa-mãe possui subtarefas (progresso vem delas). */
hasSubtasks
?:
boolean
;
/** Id da tarefa-mãe ao criar uma subtarefa. */
entryId
?:
number
;
defaultAssigneeId
?:
number
;
currentUserId
:
number
;
isAdmin
:
boolean
;
trigger
:
ReactNode
;
})
{
const
isSubtask
=
entryId
!==
undefined
&&
!
task
;
// 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,11 +116,19 @@ export function TaskDialog({
<
DialogTrigger
asChild
>
{
trigger
}
</
DialogTrigger
>
<
DialogContent
className
=
"max-h-[90vh] overflow-y-auto sm:max-w-lg"
>
<
DialogHeader
>
<
DialogTitle
>
{
task
?
"
Editar tarefa
"
:
"
Nova tarefa
"
}
</
DialogTitle
>
<
DialogTitle
>
{
task
?
"
Editar tarefa
"
:
isSubtask
?
"
Nova subtarefa
"
:
"
Nova tarefa
"
}
</
DialogTitle
>
<
DialogDescription
>
{
task
?
"
Atualize as informações da tarefa.
"
:
"
Descreva a tarefa, defina prazos e o responsável.
"
}
:
isSubtask
?
"
A subtarefa tem os mesmos atributos de uma tarefa.
"
:
"
Descreva a tarefa, defina prazos e o responsável.
"
}
</
DialogDescription
>
</
DialogHeader
>
<
form
action
=
{
formAction
}
className
=
"space-y-4"
>
...
...
@@ -119,6 +138,9 @@ export function TaskDialog({
</
Alert
>
)
}
<
input
type
=
"hidden"
name
=
"workspaceId"
value
=
{
workspaceId
}
/>
{
entryId
!==
undefined
&&
(
<
input
type
=
"hidden"
name
=
"entry"
value
=
{
entryId
}
/>
)
}
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-title"
>
Título
</
Label
>
<
Input
...
...
@@ -223,22 +245,16 @@ export function TaskDialog({
min
=
{
0
}
max
=
{
100
}
defaultValue
=
{
task
?.
progress
??
0
}
disabled
=
{
(
s
ubtasks
?.
length
??
0
)
>
0
}
disabled
=
{
hasS
ubtasks
}
/>
<
p
className
=
"text-xs text-muted-foreground"
>
{
(
s
ubtasks
?.
length
??
0
)
>
0
{
hasS
ubtasks
?
"
Calculado automaticamente pelas subtarefas.
"
:
"
Digite quanto da tarefa já foi concluído (0 a 100).
"
}
</
p
>
</
div
>
<
SubmitButton
isEdit
=
{
Boolean
(
task
)
}
/>
<
SubmitButton
isEdit
=
{
Boolean
(
task
)
}
isSubtask
=
{
isSubtask
}
/>
</
form
>
{
task
&&
(
<>
<
Separator
/>
<
SubtaskList
taskId
=
{
task
.
id
}
subtasks
=
{
subtasks
??
[]
}
/>
</>
)
}
</
DialogContent
>
</
Dialog
>
);
...
...
src/components/tasks/task-table.tsx
View file @
987e8471
"
use client
"
;
import
{
Fragment
,
useState
}
from
"
react
"
;
import
{
CheckCirc
le
2
,
ChevronRight
,
Circle
,
Pencil
}
from
"
lucide-react
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
AlertTriang
le
,
ChevronRight
,
Pencil
,
Plus
}
from
"
lucide-react
"
;
import
type
{
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
{
formatDate
,
getDueInfo
}
from
"
@/lib/dates
"
;
import
{
AlertTriangle
}
from
"
lucide-react
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
initials
,
toFormValues
}
from
"
./task-utils
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
...
...
@@ -22,7 +21,6 @@ import { cn } from "@/lib/utils";
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
SubtaskList
}
from
"
./subtask-list
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDialog
}
from
"
./task-dialog
"
;
import
{
TaskProgress
}
from
"
./task-progress
"
;
...
...
@@ -42,7 +40,7 @@ export function TaskTable({
tasks
:
Task
[];
members
:
WorkspaceMember
[];
workspaceId
:
number
;
subtasksByTask
:
Record
<
number
,
Subt
ask
[]
>
;
subtasksByTask
:
Record
<
number
,
T
ask
[]
>
;
currentUserId
:
number
;
isAdmin
:
boolean
;
})
{
...
...
@@ -170,7 +168,7 @@ export function TaskTable({
>
<
TableCell
className
=
"min-w-[260px] py-3 align-top"
>
<
div
className
=
"flex items-start gap-1.5"
>
{
hasSubtasks
?
(
{
hasSubtasks
||
editable
?
(
<
button
type
=
"button"
onClick
=
{
()
=>
toggleExpanded
(
task
.
Id
)
}
...
...
@@ -187,8 +185,12 @@ export function TaskTable({
isOpen
&&
"
rotate-90
"
)
}
/>
{
subtasks
.
filter
((
s
)
=>
s
.
IsDone
).
length
}
/
{
subtasks
.
length
}
{
hasSubtasks
&&
(
<
span
className
=
"tabular-nums"
>
{
subtasks
.
filter
((
s
)
=>
s
.
Status
===
"
done
"
).
length
}
/
{
subtasks
.
length
}
</
span
>
)
}
</
button
>
)
:
(
<
span
className
=
"w-5 shrink-0"
/>
...
...
@@ -269,7 +271,7 @@ export function TaskTable({
workspaceId
=
{
workspaceId
}
members
=
{
members
}
task
=
{
toFormValues
(
task
)
}
s
ubtasks
=
{
s
ubtasks
}
hasS
ubtasks
=
{
hasS
ubtasks
}
currentUserId
=
{
currentUserId
}
isAdmin
=
{
isAdmin
}
trigger
=
{
...
...
@@ -293,33 +295,136 @@ export function TaskTable({
</
TableRow
>
{
isOpen
&&
(
<
TableRow
className
=
"border-b bg-muted/20 hover:bg-muted/20 last:border-0"
>
<
TableCell
colSpan
=
{
COLUMN_COUNT
}
className
=
"py-3 pl-12"
>
<
div
className
=
"max-w-md"
>
{
editable
?
(
<
SubtaskList
taskId
=
{
task
.
Id
}
subtasks
=
{
subtasks
}
/>
)
:
(
<
ul
className
=
"space-y-1.5"
>
{
subtasks
.
map
((
subtask
)
=>
(
<
li
key
=
{
subtask
.
Id
}
className
=
"flex items-center gap-2 text-sm"
>
{
subtask
.
IsDone
?
(
<
CheckCircle2
className
=
"h-4 w-4 shrink-0 text-green-500"
/>
)
:
(
<
Circle
className
=
"h-4 w-4 shrink-0 text-muted-foreground"
/>
<
TableCell
colSpan
=
{
COLUMN_COUNT
}
className
=
"py-3 pl-12 pr-4"
>
<
div
className
=
"space-y-2"
>
<
p
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Subtarefas
</
p
>
{
subtasks
.
length
===
0
&&
(
<
p
className
=
"text-sm text-muted-foreground"
>
Nenhuma subtarefa ainda.
</
p
>
)
}
{
subtasks
.
map
((
sub
)
=>
{
const
subEditable
=
canEditTask
(
sub
,
currentUserId
,
isAdmin
);
return
(
<
div
key
=
{
sub
.
Id
}
className
=
"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border bg-card px-3 py-2"
>
<
div
className
=
"min-w-[180px] flex-1"
>
<
div
className
=
"flex flex-wrap items-center gap-1.5"
>
<
span
className
=
"text-sm font-medium"
>
{
sub
.
Title
}
</
span
>
<
PriorityBadge
priority
=
{
sub
.
Priority
}
/>
</
div
>
{
sub
.
Description
&&
(
<
p
className
=
"mt-0.5 text-xs text-muted-foreground"
>
{
sub
.
Description
}
</
p
>
)
}
<
span
className
=
{
cn
(
subtask
.
IsDone
&&
"
text-muted-foreground line-through
"
)
}
</
div
>
{
subEditable
?
(
<
TaskStatusSelect
taskId
=
{
sub
.
Id
}
status
=
{
sub
.
Status
}
/>
)
:
(
<
StatusBadge
status
=
{
sub
.
Status
}
/>
)
}
{
subEditable
?
(
<
TaskProgressEditor
taskId
=
{
sub
.
Id
}
status
=
{
sub
.
Status
}
subtaskCount
=
{
sub
.
SubtaskCount
}
subtaskDone
=
{
sub
.
SubtaskDone
}
manualProgress
=
{
sub
.
Progress
}
/>
)
:
(
<
TaskProgress
status
=
{
sub
.
Status
}
subtaskCount
=
{
sub
.
SubtaskCount
}
subtaskDone
=
{
sub
.
SubtaskDone
}
manualProgress
=
{
sub
.
Progress
}
/>
)
}
{
sub
.
AssigneeName
?
(
<
div
className
=
"flex items-center gap-1.5"
title
=
{
sub
.
AssigneeName
}
>
{
subtask
.
Title
}
<
Avatar
className
=
"h-6 w-6"
>
<
AvatarFallback
className
=
"bg-primary/15 text-[10px] font-semibold text-primary"
>
{
initials
(
sub
.
AssigneeName
)
}
</
AvatarFallback
>
</
Avatar
>
</
div
>
)
:
(
<
span
className
=
"text-xs text-muted-foreground"
>
Sem responsável
</
span
>
</
li
>
))
}
</
ul
>
)
}
<
div
className
=
"flex items-center gap-1.5"
>
<
span
className
=
"text-xs text-muted-foreground"
>
{
formatDate
(
sub
.
DueDate
)
}
</
span
>
<
DueBadge
dueDate
=
{
sub
.
DueDate
}
status
=
{
sub
.
Status
}
/>
</
div
>
{
subEditable
&&
(
<
div
className
=
"flex items-center"
>
<
TaskDialog
workspaceId
=
{
workspaceId
}
members
=
{
members
}
task
=
{
toFormValues
(
sub
)
}
currentUserId
=
{
currentUserId
}
isAdmin
=
{
isAdmin
}
trigger
=
{
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-8 w-8 text-muted-foreground"
title
=
"Editar subtarefa"
>
<
Pencil
className
=
"h-4 w-4"
/>
</
Button
>
}
/>
<
TaskDeleteButton
taskId
=
{
sub
.
Id
}
taskTitle
=
{
sub
.
Title
}
/>
</
div
>
)
}
</
div
>
);
})
}
{
editable
&&
(
<
TaskDialog
workspaceId
=
{
workspaceId
}
members
=
{
members
}
entryId
=
{
task
.
Id
}
defaultAssigneeId
=
{
task
.
AssigneeId
??
undefined
}
currentUserId
=
{
currentUserId
}
isAdmin
=
{
isAdmin
}
trigger
=
{
<
Button
variant
=
"outline"
size
=
"sm"
className
=
"mt-1"
>
<
Plus
className
=
"mr-1.5 h-4 w-4"
/>
Adicionar subtarefa
</
Button
>
}
/>
)
}
</
div
>
</
TableCell
>
...
...
src/lib/actions.ts
View file @
987e8471
...
...
@@ -306,6 +306,8 @@ const taskSchema = z
.
min
(
0
,
"
O progresso deve ser entre 0 e 100.
"
)
.
max
(
100
,
"
O progresso deve ser entre 0 e 100.
"
)
.
optional
(),
// Id da tarefa-mãe quando se cria uma subtarefa.
entry
:
z
.
coerce
.
number
().
int
().
positive
().
optional
(),
})
.
refine
(
(
t
)
=>
!
t
.
startDate
||
!
t
.
dueDate
||
t
.
startDate
<=
t
.
dueDate
,
...
...
@@ -323,6 +325,7 @@ function parseTaskForm(formData: FormData) {
startDate
:
formData
.
get
(
"
startDate
"
)
||
undefined
,
dueDate
:
formData
.
get
(
"
dueDate
"
)
||
undefined
,
progress
:
formData
.
get
(
"
progress
"
)
||
undefined
,
entry
:
formData
.
get
(
"
entry
"
)
||
undefined
,
});
}
...
...
@@ -360,6 +363,7 @@ export async function createTaskAction(
startDate
:
parsed
.
data
.
startDate
??
null
,
dueDate
:
parsed
.
data
.
dueDate
??
null
,
progress
:
parsed
.
data
.
progress
??
0
,
entry
:
parsed
.
data
.
entry
??
null
,
},
user
.
id
);
...
...
@@ -464,77 +468,6 @@ export async function updateTaskProgressAction(
return
{
success
:
true
};
}
// ---------- Subtarefas ----------
const
subtaskTitleSchema
=
z
.
string
()
.
min
(
1
,
"
Informe o título da subtarefa.
"
)
.
max
(
200
,
"
Título muito longo.
"
);
export
async
function
createSubtaskAction
(
taskId
:
number
,
title
:
string
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
const
parsed
=
subtaskTitleSchema
.
safeParse
(
title
?.
trim
());
if
(
!
parsed
.
success
)
return
{
error
:
firstError
(
parsed
.
error
)
};
try
{
const
task
=
await
data
.
getTask
(
taskId
);
if
(
!
task
)
return
{
error
:
"
Tarefa não encontrada.
"
};
await
assertCanEditTask
(
task
,
user
);
await
data
.
createSubtask
(
taskId
,
parsed
.
data
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao criar a subtarefa.
"
,
};
}
revalidatePath
(
"
/dashboard
"
);
return
{
success
:
true
};
}
export
async
function
toggleSubtaskAction
(
subtaskId
:
number
,
isDone
:
boolean
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
try
{
const
subtask
=
await
data
.
getSubtaskWithWorkspace
(
subtaskId
);
if
(
!
subtask
)
return
{
error
:
"
Subtarefa não encontrada.
"
};
await
assertCanEditTask
(
subtask
,
user
);
await
data
.
setSubtaskDone
(
subtaskId
,
isDone
);
revalidatePath
(
`/workspaces/
${
subtask
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao atualizar a subtarefa.
"
,
};
}
revalidatePath
(
"
/dashboard
"
);
return
{
success
:
true
};
}
export
async
function
deleteSubtaskAction
(
subtaskId
:
number
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
try
{
const
subtask
=
await
data
.
getSubtaskWithWorkspace
(
subtaskId
);
if
(
!
subtask
)
return
{
error
:
"
Subtarefa não encontrada.
"
};
await
assertCanEditTask
(
subtask
,
user
);
await
data
.
deleteSubtask
(
subtaskId
);
revalidatePath
(
`/workspaces/
${
subtask
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao excluir a subtarefa.
"
,
};
}
revalidatePath
(
"
/dashboard
"
);
return
{
success
:
true
};
}
export
async
function
deleteTaskAction
(
taskId
:
number
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
try
{
...
...
src/lib/data.ts
View file @
987e8471
...
...
@@ -33,6 +33,7 @@ export interface Task {
WorkspaceName
?:
string
;
Title
:
string
;
Description
:
string
|
null
;
Entry
?:
number
|
null
;
Status
:
string
;
Priority
:
string
;
AssigneeId
:
number
|
null
;
...
...
@@ -46,13 +47,6 @@ export interface Task {
SubtaskDone
:
number
;
}
export
interface
Subtask
{
Id
:
number
;
TaskId
:
number
;
Title
:
string
;
IsDone
:
boolean
;
}
// ---------- Usuários ----------
export
async
function
findUserByEmail
(
...
...
@@ -147,8 +141,8 @@ export async function listWorkspacesForUser(
const
result
=
await
pool
.
request
().
input
(
"
userId
"
,
sql
.
Int
,
userId
).
query
(
`SELECT w.Id, w.Name, w.Description, w.Color, w.OwnerId,
wm.Role AS MemberRole,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id) AS TaskCount,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id AND t.Status = 'done') AS DoneCount
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id
AND t.Entry IS NULL
) AS TaskCount,
(SELECT COUNT(*) FROM dbo.Tasks t WHERE t.WorkspaceId = w.Id AND
t.Entry IS NULL AND
t.Status = 'done') AS DoneCount
FROM dbo.Workspaces w
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id
WHERE wm.UserId = @userId
...
...
@@ -261,10 +255,11 @@ export async function removeWorkspaceMember(
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.Progress, t.CreatedAt, w.Name AS WorkspaceName,
(SELECT COUNT(*) FROM dbo.
Subt
asks s WHERE s.
TaskId
= t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.
Subt
asks s WHERE s.
TaskId
= t.Id AND s.
IsDone = 1
) AS SubtaskDone
(SELECT COUNT(*) FROM dbo.
T
asks s WHERE s.
Entry
= t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.
T
asks s WHERE s.
Entry
= t.Id AND s.
Status = 'done'
) AS SubtaskDone
FROM dbo.Tasks t
LEFT JOIN dbo.Users a ON a.Id = t.AssigneeId
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId`
;
...
...
@@ -278,7 +273,7 @@ export async function listTasksByWorkspace(
.
input
(
"
workspaceId
"
,
sql
.
Int
,
workspaceId
)
.
query
(
`
${
TASK_SELECT
}
WHERE t.WorkspaceId = @workspaceId
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
;
...
...
@@ -303,8 +298,8 @@ export interface TaskInput {
startDate
:
string
|
null
;
dueDate
:
string
|
null
;
progress
:
number
;
entry
?:
number
|
null
;
}
export
async
function
createTask
(
input
:
TaskInput
,
createdById
:
number
...
...
@@ -318,13 +313,14 @@ export async function createTask(
.
input
(
"
status
"
,
sql
.
NVarChar
(
20
),
input
.
status
)
.
input
(
"
priority
"
,
sql
.
NVarChar
(
10
),
input
.
priority
)
.
input
(
"
assigneeId
"
,
sql
.
Int
,
input
.
assigneeId
)
.
input
(
"
entry
"
,
sql
.
Int
,
input
.
entry
)
.
input
(
"
startDate
"
,
sql
.
Date
,
input
.
startDate
)
.
input
(
"
dueDate
"
,
sql
.
Date
,
input
.
dueDate
)
.
input
(
"
progress
"
,
sql
.
Int
,
input
.
progress
)
.
input
(
"
createdById
"
,
sql
.
Int
,
createdById
)
.
query
(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, AssigneeId, StartDate, DueDate, Progress, CreatedById)
VALUES (@workspaceId, @title, @description, @status, @priority, @assigneeId, @startDate, @dueDate, @progress, @createdById)`
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority,
Entry,
AssigneeId, StartDate, DueDate, Progress, CreatedById)
VALUES (@workspaceId, @title, @description, @status, @priority,
@entry,
@assigneeId, @startDate, @dueDate, @progress, @createdById)`
);
}
...
...
@@ -381,88 +377,31 @@ export async function updateTaskProgress(
export
async
function
deleteTask
(
id
:
number
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
// Apaga a tarefa e suas subtarefas (filhas com Entry = id).
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
query
(
"
DELETE FROM dbo.Tasks WHERE Id = @id
"
);
.
query
(
"
DELETE FROM dbo.Tasks WHERE Id =
@id OR Entry =
@id
"
);
}
// ---------- Subtarefas ----------
// ---------- Subtarefas
(tarefas-filhas: Entry = Id da tarefa-mãe)
----------
/** Lista as subtarefas (tarefas-filhas) do workspace, com todos os atributos. */
export
async
function
listSubtasksByWorkspace
(
workspaceId
:
number
):
Promise
<
Subt
ask
[]
>
{
):
Promise
<
T
ask
[]
>
{
const
pool
=
await
getPool
();
const
result
=
await
pool
.
request
()
.
input
(
"
workspaceId
"
,
sql
.
Int
,
workspaceId
)
.
query
(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId
WHERE t.WorkspaceId = @workspaceId
ORDER BY s.Id`
`
${
TASK_SELECT
}
WHERE t.WorkspaceId = @workspaceId AND t.Entry IS NOT NULL
ORDER BY t.Id`
);
return
result
.
recordset
;
}
/** Retorna a subtarefa com dados da tarefa pai (para autorização). */
export
async
function
getSubtaskWithWorkspace
(
id
:
number
):
Promise
<
|
(
Subtask
&
{
WorkspaceId
:
number
;
AssigneeId
:
number
|
null
;
CreatedById
:
number
;
})
|
null
>
{
const
pool
=
await
getPool
();
const
result
=
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
query
(
`SELECT s.Id, s.TaskId, s.Title, s.IsDone,
t.WorkspaceId, t.AssigneeId, t.CreatedById
FROM dbo.Subtasks s
JOIN dbo.Tasks t ON t.Id = s.TaskId
WHERE s.Id = @id`
);
return
result
.
recordset
[
0
]
??
null
;
}
export
async
function
createSubtask
(
taskId
:
number
,
title
:
string
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
taskId
"
,
sql
.
Int
,
taskId
)
.
input
(
"
title
"
,
sql
.
NVarChar
(
200
),
title
.
trim
())
.
query
(
"
INSERT INTO dbo.Subtasks (TaskId, Title) VALUES (@taskId, @title)
"
);
}
export
async
function
setSubtaskDone
(
id
:
number
,
isDone
:
boolean
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
input
(
"
isDone
"
,
sql
.
Bit
,
isDone
)
.
query
(
"
UPDATE dbo.Subtasks SET IsDone = @isDone WHERE Id = @id
"
);
}
export
async
function
deleteSubtask
(
id
:
number
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
query
(
"
DELETE FROM dbo.Subtasks WHERE Id = @id
"
);
}
// ---------- Painel (dashboard) ----------
export
interface
StatusCount
{
...
...
@@ -482,6 +421,7 @@ export async function getStatusCountsForUser(
FROM dbo.Tasks t
JOIN dbo.Workspaces w ON w.Id = t.WorkspaceId
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = w.Id AND wm.UserId = @userId
WHERE t.Entry IS NULL
GROUP BY w.Id, w.Name, w.Color, t.Status`
);
return
result
.
recordset
;
...
...
@@ -500,7 +440,8 @@ export async function getDueAlertsForUser(
.
query
(
`
${
TASK_SELECT
}
JOIN dbo.WorkspaceMembers wm ON wm.WorkspaceId = t.WorkspaceId AND wm.UserId = @userId
WHERE t.Status <> 'done'
WHERE t.Entry IS NULL
AND t.Status <> 'done'
AND t.DueDate IS NOT NULL
AND t.DueDate <= DATEADD(day, @days, CAST(GETDATE() AS DATE))
ORDER BY t.DueDate`
...
...
src/lib/db-init.ts
View file @
987e8471
...
...
@@ -42,6 +42,7 @@ const DDL_STATEMENTS: string[] = [
Description NVARCHAR(2000) NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'todo',
Priority NVARCHAR(10) NOT NULL DEFAULT 'medium',
Entry INT NULL,
AssigneeId INT NULL REFERENCES dbo.Users(Id),
CreatedById INT NOT NULL REFERENCES dbo.Users(Id),
StartDate DATE NULL,
...
...
@@ -64,6 +65,12 @@ const DDL_STATEMENTS: string[] = [
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`
,
`IF COL_LENGTH('dbo.Tasks', 'Entry') IS NULL
ALTER TABLE dbo.Tasks ADD Entry INT NULL`
,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Tasks_Entry')
CREATE INDEX IX_Tasks_Entry ON dbo.Tasks (Entry)`
,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Subtasks_TaskId')
CREATE INDEX IX_Subtasks_TaskId ON dbo.Subtasks (TaskId)`
,
...
...
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