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
f5fa1ac0
Commit
f5fa1ac0
authored
Jun 10, 2026
by
Paulo Avila
Browse files
Versão 1.0 Petruz Task
parent
95bad46e
Changes
74
Hide whitespace changes
Inline
Side-by-side
src/components/auth/login-form.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
Link
from
"
next/link
"
;
import
{
useFormState
,
useFormStatus
}
from
"
react-dom
"
;
import
{
loginAction
,
type
ActionState
}
from
"
@/lib/actions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardFooter
,
CardHeader
,
CardTitle
,
}
from
"
@/components/ui/card
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
function
SubmitButton
()
{
const
{
pending
}
=
useFormStatus
();
return
(
<
Button
type
=
"submit"
className
=
"w-full"
disabled
=
{
pending
}
>
{
pending
?
"
Entrando...
"
:
"
Entrar
"
}
</
Button
>
);
}
const
initialState
:
ActionState
=
{};
export
function
LoginForm
()
{
const
[
state
,
formAction
]
=
useFormState
(
loginAction
,
initialState
);
return
(
<
Card
className
=
"w-full max-w-sm"
>
<
CardHeader
>
<
CardTitle
>
Entrar
</
CardTitle
>
<
CardDescription
>
Acesse com seu e-mail e senha cadastrados.
</
CardDescription
>
</
CardHeader
>
<
form
action
=
{
formAction
}
>
<
CardContent
className
=
"space-y-4"
>
{
state
.
error
&&
(
<
Alert
variant
=
"destructive"
>
<
AlertDescription
>
{
state
.
error
}
</
AlertDescription
>
</
Alert
>
)
}
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"email"
>
E-mail
</
Label
>
<
Input
id
=
"email"
name
=
"email"
type
=
"email"
placeholder
=
"voce@petruz.com"
autoComplete
=
"email"
required
/>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"password"
>
Senha
</
Label
>
<
Input
id
=
"password"
name
=
"password"
type
=
"password"
autoComplete
=
"current-password"
required
/>
</
div
>
</
CardContent
>
<
CardFooter
className
=
"flex flex-col gap-3"
>
<
SubmitButton
/>
<
p
className
=
"text-sm text-muted-foreground"
>
Não tem conta?
{
"
"
}
<
Link
href
=
"/register"
className
=
"text-primary underline"
>
Cadastre-se
</
Link
>
</
p
>
</
CardFooter
>
</
form
>
</
Card
>
);
}
src/components/auth/register-form.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
Link
from
"
next/link
"
;
import
{
useFormState
,
useFormStatus
}
from
"
react-dom
"
;
import
{
registerAction
,
type
ActionState
}
from
"
@/lib/actions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardFooter
,
CardHeader
,
CardTitle
,
}
from
"
@/components/ui/card
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
function
SubmitButton
()
{
const
{
pending
}
=
useFormStatus
();
return
(
<
Button
type
=
"submit"
className
=
"w-full"
disabled
=
{
pending
}
>
{
pending
?
"
Criando conta...
"
:
"
Criar conta
"
}
</
Button
>
);
}
const
initialState
:
ActionState
=
{};
export
function
RegisterForm
()
{
const
[
state
,
formAction
]
=
useFormState
(
registerAction
,
initialState
);
return
(
<
Card
className
=
"w-full max-w-sm"
>
<
CardHeader
>
<
CardTitle
>
Criar conta
</
CardTitle
>
<
CardDescription
>
Cadastre-se para começar a organizar suas tarefas.
</
CardDescription
>
</
CardHeader
>
<
form
action
=
{
formAction
}
>
<
CardContent
className
=
"space-y-4"
>
{
state
.
error
&&
(
<
Alert
variant
=
"destructive"
>
<
AlertDescription
>
{
state
.
error
}
</
AlertDescription
>
</
Alert
>
)
}
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"name"
>
Nome
</
Label
>
<
Input
id
=
"name"
name
=
"name"
placeholder
=
"Seu nome completo"
autoComplete
=
"name"
required
/>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"email"
>
E-mail
</
Label
>
<
Input
id
=
"email"
name
=
"email"
type
=
"email"
placeholder
=
"voce@petruz.com"
autoComplete
=
"email"
required
/>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"password"
>
Senha
</
Label
>
<
Input
id
=
"password"
name
=
"password"
type
=
"password"
autoComplete
=
"new-password"
minLength
=
{
8
}
required
/>
<
p
className
=
"text-xs text-muted-foreground"
>
Mínimo de 8 caracteres.
</
p
>
</
div
>
</
CardContent
>
<
CardFooter
className
=
"flex flex-col gap-3"
>
<
SubmitButton
/>
<
p
className
=
"text-sm text-muted-foreground"
>
Já tem conta?
{
"
"
}
<
Link
href
=
"/login"
className
=
"text-primary underline"
>
Entrar
</
Link
>
</
p
>
</
CardFooter
>
</
form
>
</
Card
>
);
}
src/components/layout/sidebar.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
useEffect
,
useState
}
from
"
react
"
;
import
Image
from
"
next/image
"
;
import
Link
from
"
next/link
"
;
import
{
Folder
,
LayoutDashboard
,
PanelLeftClose
,
PanelLeftOpen
,
Plus
,
}
from
"
lucide-react
"
;
import
type
{
Workspace
}
from
"
@/lib/data
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
const
STORAGE_KEY
=
"
petruz-sidebar-collapsed
"
;
export
function
Sidebar
({
workspaces
}:
{
workspaces
:
Workspace
[]
})
{
const
[
collapsed
,
setCollapsed
]
=
useState
(
false
);
useEffect
(()
=>
{
setCollapsed
(
localStorage
.
getItem
(
STORAGE_KEY
)
===
"
1
"
);
},
[]);
function
toggle
()
{
setCollapsed
((
prev
)
=>
{
localStorage
.
setItem
(
STORAGE_KEY
,
prev
?
"
0
"
:
"
1
"
);
return
!
prev
;
});
}
return
(
<
aside
className
=
{
cn
(
"
hidden shrink-0 flex-col bg-sidebar text-sidebar-foreground transition-[width] duration-200 md:flex
"
,
collapsed
?
"
w-16
"
:
"
w-64
"
)
}
>
<
div
className
=
{
cn
(
"
flex items-center gap-3 py-5
"
,
collapsed
?
"
justify-center px-2
"
:
"
px-5
"
)
}
>
<
Image
src
=
"/petruz.png"
alt
=
"Petruz"
width
=
{
collapsed
?
36
:
44
}
height
=
{
collapsed
?
36
:
44
}
/>
{
!
collapsed
&&
(
<
div
>
<
p
className
=
"text-base font-semibold leading-tight"
>
Petruz Tasks
</
p
>
<
p
className
=
"text-xs text-sidebar-foreground/60"
>
Gerenciador de tarefas
</
p
>
</
div
>
)
}
</
div
>
<
nav
className
=
{
cn
(
"
flex flex-1 flex-col gap-1 overflow-y-auto py-2
"
,
collapsed
?
"
items-center px-2
"
:
"
px-3
"
)
}
>
<
Link
href
=
"/dashboard"
title
=
"Painel"
className
=
{
cn
(
"
flex items-center gap-3 rounded-md text-sm font-medium hover:bg-sidebar-accent
"
,
collapsed
?
"
justify-center p-2.5
"
:
"
px-3 py-2
"
)
}
>
<
LayoutDashboard
className
=
"h-4 w-4 shrink-0"
/>
{
!
collapsed
&&
"
Painel
"
}
</
Link
>
<
Link
href
=
"/workspaces"
title
=
"Espaços de trabalho"
className
=
{
cn
(
"
flex items-center gap-3 rounded-md text-sm font-medium hover:bg-sidebar-accent
"
,
collapsed
?
"
justify-center p-2.5
"
:
"
px-3 py-2
"
)
}
>
<
Folder
className
=
"h-4 w-4 shrink-0"
/>
{
!
collapsed
&&
"
Espaços de trabalho
"
}
</
Link
>
{
!
collapsed
&&
(
<
p
className
=
"mt-4 px-3 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50"
>
Meus espaços
</
p
>
)
}
{
collapsed
&&
(
<
div
className
=
"my-2 h-px w-8 bg-sidebar-border"
/>
)
}
{
workspaces
.
map
((
ws
)
=>
(
<
Link
key
=
{
ws
.
Id
}
href
=
{
`/workspaces/
${
ws
.
Id
}
`
}
title
=
{
ws
.
Name
}
className
=
{
cn
(
"
flex items-center gap-3 rounded-md text-sm hover:bg-sidebar-accent
"
,
collapsed
?
"
justify-center p-2.5
"
:
"
px-3 py-2
"
)
}
>
<
span
className
=
"h-2.5 w-2.5 shrink-0 rounded-full"
style
=
{
{
backgroundColor
:
ws
.
Color
}
}
/>
{
!
collapsed
&&
<
span
className
=
"truncate"
>
{
ws
.
Name
}
</
span
>
}
</
Link
>
))
}
<
Link
href
=
"/workspaces?new=1"
title
=
"Novo espaço"
className
=
{
cn
(
"
flex items-center gap-3 rounded-md text-sm text-sidebar-foreground/70 hover:bg-sidebar-accent
"
,
collapsed
?
"
justify-center p-2.5
"
:
"
px-3 py-2
"
)
}
>
<
Plus
className
=
"h-4 w-4 shrink-0"
/>
{
!
collapsed
&&
"
Novo espaço
"
}
</
Link
>
</
nav
>
<
div
className
=
{
cn
(
"
border-t border-sidebar-border p-2
"
,
collapsed
?
"
flex justify-center
"
:
"
flex justify-end
"
)
}
>
<
Button
variant
=
"ghost"
size
=
"icon"
onClick
=
{
toggle
}
title
=
{
collapsed
?
"
Expandir menu
"
:
"
Encolher menu
"
}
className
=
"text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{
collapsed
?
(
<
PanelLeftOpen
className
=
"h-5 w-5"
/>
)
:
(
<
PanelLeftClose
className
=
"h-5 w-5"
/>
)
}
<
span
className
=
"sr-only"
>
{
collapsed
?
"
Expandir menu
"
:
"
Encolher menu
"
}
</
span
>
</
Button
>
</
div
>
</
aside
>
);
}
src/components/layout/topbar.tsx
0 → 100644
View file @
f5fa1ac0
import
{
LogOut
}
from
"
lucide-react
"
;
import
{
logoutAction
}
from
"
@/lib/actions
"
;
import
type
{
SessionUser
}
from
"
@/lib/auth
"
;
import
{
ThemeToggle
}
from
"
@/components/theme-toggle
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
function
initials
(
name
:
string
):
string
{
return
name
.
split
(
"
"
)
.
filter
(
Boolean
)
.
slice
(
0
,
2
)
.
map
((
part
)
=>
part
[
0
]?.
toUpperCase
())
.
join
(
""
);
}
export
function
Topbar
({
user
}:
{
user
:
SessionUser
})
{
return
(
<
header
className
=
"flex h-14 items-center justify-between border-b bg-card px-4 md:px-6"
>
<
div
className
=
"flex items-center gap-2 md:hidden"
>
<
span
className
=
"font-semibold text-primary"
>
Petruz Tasks
</
span
>
</
div
>
<
div
className
=
"ml-auto flex items-center gap-3"
>
<
ThemeToggle
/>
<
div
className
=
"flex items-center gap-2"
>
<
Avatar
className
=
"h-8 w-8"
>
<
AvatarFallback
className
=
"bg-primary text-primary-foreground text-xs"
>
{
initials
(
user
.
name
)
}
</
AvatarFallback
>
</
Avatar
>
<
div
className
=
"hidden text-sm leading-tight sm:block"
>
<
p
className
=
"font-medium"
>
{
user
.
name
}
</
p
>
<
p
className
=
"text-xs text-muted-foreground"
>
{
user
.
email
}
</
p
>
</
div
>
</
div
>
<
form
action
=
{
logoutAction
}
>
<
Button
variant
=
"ghost"
size
=
"icon"
title
=
"Sair"
type
=
"submit"
>
<
LogOut
className
=
"h-4 w-4"
/>
<
span
className
=
"sr-only"
>
Sair
</
span
>
</
Button
>
</
form
>
</
div
>
</
header
>
);
}
src/components/tasks/due-badge.tsx
0 → 100644
View file @
f5fa1ac0
import
{
AlarmClock
,
AlertTriangle
,
CalendarClock
}
from
"
lucide-react
"
;
import
{
getDueInfo
}
from
"
@/lib/dates
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
export
function
DueBadge
({
dueDate
,
status
,
}:
{
dueDate
:
Date
|
string
|
null
;
status
:
string
;
})
{
const
info
=
getDueInfo
(
dueDate
,
status
);
if
(
!
info
||
info
.
state
===
"
ok
"
)
return
null
;
const
styles
=
{
overdue
:
"
bg-destructive text-destructive-foreground hover:bg-destructive
"
,
today
:
"
bg-orange-500 text-white hover:bg-orange-500
"
,
soon
:
"
bg-amber-400 text-black hover:bg-amber-400
"
,
}
as
const
;
const
Icon
=
info
.
state
===
"
overdue
"
?
AlertTriangle
:
info
.
state
===
"
today
"
?
AlarmClock
:
CalendarClock
;
return
(
<
Badge
className
=
{
cn
(
"
gap-1
"
,
styles
[
info
.
state
])
}
>
<
Icon
className
=
"h-3 w-3"
/>
{
info
.
label
}
</
Badge
>
);
}
src/components/tasks/priority-badge.tsx
0 → 100644
View file @
f5fa1ac0
import
{
priorityLabel
}
from
"
@/lib/constants
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
const
PRIORITY_STYLES
:
Record
<
string
,
string
>
=
{
low
:
"
bg-muted text-muted-foreground hover:bg-muted
"
,
medium
:
"
bg-blue-500/15 text-blue-700 dark:text-blue-300 hover:bg-blue-500/15
"
,
high
:
"
bg-orange-500/15 text-orange-700 dark:text-orange-300 hover:bg-orange-500/15
"
,
urgent
:
"
bg-red-500/15 text-red-700 dark:text-red-300 hover:bg-red-500/15
"
,
};
export
function
PriorityBadge
({
priority
}:
{
priority
:
string
})
{
return
(
<
Badge
className
=
{
cn
(
"
font-normal
"
,
PRIORITY_STYLES
[
priority
])
}
>
{
priorityLabel
(
priority
)
}
</
Badge
>
);
}
src/components/tasks/status-badge.tsx
0 → 100644
View file @
f5fa1ac0
import
{
statusLabel
}
from
"
@/lib/constants
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
const
STATUS_BADGE_STYLES
:
Record
<
string
,
string
>
=
{
todo
:
"
bg-slate-500/15 text-slate-700 dark:text-slate-300 hover:bg-slate-500/15
"
,
in_progress
:
"
bg-blue-500/15 text-blue-700 dark:text-blue-300 hover:bg-blue-500/15
"
,
review
:
"
bg-purple-500/15 text-purple-700 dark:text-purple-300 hover:bg-purple-500/15
"
,
done
:
"
bg-green-500/15 text-green-700 dark:text-green-300 hover:bg-green-500/15
"
,
};
/** Versão somente leitura do status (para quem não pode editar a tarefa). */
export
function
StatusBadge
({
status
}:
{
status
:
string
})
{
return
(
<
Badge
className
=
{
cn
(
"
font-normal
"
,
STATUS_BADGE_STYLES
[
status
])
}
>
{
statusLabel
(
status
)
}
</
Badge
>
);
}
src/components/tasks/status-summary.tsx
0 → 100644
View file @
f5fa1ac0
import
{
CheckCircle2
,
Circle
,
Eye
,
LoaderCircle
,
}
from
"
lucide-react
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
type
{
Task
}
from
"
@/lib/data
"
;
import
{
Card
,
CardContent
}
from
"
@/components/ui/card
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
const
STATUS_VISUALS
:
Record
<
string
,
{
icon
:
typeof
Circle
;
text
:
string
;
bar
:
string
}
>
=
{
todo
:
{
icon
:
Circle
,
text
:
"
text-slate-500 dark:text-slate-400
"
,
bar
:
"
bg-slate-400
"
,
},
in_progress
:
{
icon
:
LoaderCircle
,
text
:
"
text-blue-600 dark:text-blue-400
"
,
bar
:
"
bg-blue-500
"
,
},
review
:
{
icon
:
Eye
,
text
:
"
text-purple-600 dark:text-purple-400
"
,
bar
:
"
bg-purple-500
"
,
},
done
:
{
icon
:
CheckCircle2
,
text
:
"
text-green-600 dark:text-green-400
"
,
bar
:
"
bg-green-500
"
,
},
};
/** Cartões de acompanhamento por status + barra geral do espaço. */
export
function
StatusSummary
({
tasks
}:
{
tasks
:
Task
[]
})
{
const
total
=
tasks
.
length
;
const
done
=
tasks
.
filter
((
t
)
=>
t
.
Status
===
"
done
"
).
length
;
const
overallPercent
=
total
>
0
?
Math
.
round
((
done
/
total
)
*
100
)
:
0
;
return
(
<
div
className
=
"space-y-3"
>
<
div
className
=
"grid grid-cols-2 gap-3 lg:grid-cols-4"
>
{
TASK_STATUSES
.
map
((
status
)
=>
{
const
visual
=
STATUS_VISUALS
[
status
.
value
];
const
count
=
tasks
.
filter
((
t
)
=>
t
.
Status
===
status
.
value
).
length
;
const
percent
=
total
>
0
?
Math
.
round
((
count
/
total
)
*
100
)
:
0
;
const
Icon
=
visual
.
icon
;
return
(
<
Card
key
=
{
status
.
value
}
>
<
CardContent
className
=
"space-y-2 p-4"
>
<
div
className
=
"flex items-center justify-between"
>
<
span
className
=
{
cn
(
"
flex items-center gap-1.5 text-sm font-medium
"
,
visual
.
text
)
}
>
<
Icon
className
=
"h-4 w-4"
/>
{
status
.
label
}
</
span
>
<
span
className
=
"text-2xl font-bold"
>
{
count
}
</
span
>
</
div
>
<
div
className
=
"h-1.5 overflow-hidden rounded-full bg-muted"
>
<
div
className
=
{
cn
(
"
h-full rounded-full transition-all
"
,
visual
.
bar
)
}
style
=
{
{
width
:
`
${
percent
}
%`
}
}
/>
</
div
>
<
p
className
=
"text-xs text-muted-foreground"
>
{
percent
}
% das tarefas
</
p
>
</
CardContent
>
</
Card
>
);
})
}
</
div
>
{
total
>
0
&&
(
<
div
className
=
"flex items-center gap-3 rounded-lg border bg-card px-4 py-3"
>
<
span
className
=
"whitespace-nowrap text-sm font-medium"
>
Andamento geral
</
span
>
<
Progress
value
=
{
overallPercent
}
className
=
"h-2.5"
/>
<
span
className
=
"whitespace-nowrap text-sm font-semibold text-primary"
>
{
overallPercent
}
%
</
span
>
</
div
>
)
}
</
div
>
);
}
src/components/tasks/subtask-list.tsx
0 → 100644
View file @
f5fa1ac0
"
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
0 → 100644
View file @
f5fa1ac0
import
{
Pencil
}
from
"
lucide-react
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
formatDate
}
from
"
@/lib/dates
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Card
,
CardContent
}
from
"
@/components/ui/card
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
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-table
"
;
export
function
TaskBoard
({
tasks
,
members
,
workspaceId
,
subtasksByTask
,
currentUserId
,
isAdmin
,
}:
{
tasks
:
Task
[];
members
:
WorkspaceMember
[];
workspaceId
:
number
;
subtasksByTask
:
Record
<
number
,
Subtask
[]
>
;
currentUserId
:
number
;
isAdmin
:
boolean
;
})
{
return
(
<
div
className
=
"grid gap-4 md:grid-cols-2 xl:grid-cols-4"
>
{
TASK_STATUSES
.
map
((
status
)
=>
{
const
columnTasks
=
tasks
.
filter
((
t
)
=>
t
.
Status
===
status
.
value
);
return
(
<
div
key
=
{
status
.
value
}
className
=
"flex flex-col gap-3"
>
<
div
className
=
"flex items-center justify-between rounded-md bg-muted px-3 py-2"
>
<
span
className
=
"text-sm font-semibold"
>
{
status
.
label
}
</
span
>
<
span
className
=
"text-xs text-muted-foreground"
>
{
columnTasks
.
length
}
</
span
>
</
div
>
<
div
className
=
"flex flex-col gap-2"
>
{
columnTasks
.
map
((
task
)
=>
{
const
editable
=
canEditTask
(
task
,
currentUserId
,
isAdmin
);
const
percent
=
taskPercent
(
task
.
Status
,
task
.
SubtaskCount
,
task
.
SubtaskDone
,
task
.
Progress
);
return
(
<
Card
key
=
{
task
.
Id
}
>
<
CardContent
className
=
"space-y-2 p-3"
>
<
div
className
=
"flex items-start justify-between gap-1"
>
<
p
className
=
"flex-1 text-sm font-medium"
>
{
task
.
Title
}
</
p
>
{
editable
&&
(
<
div
className
=
"flex shrink-0 items-center"
>
<
TaskDialog
workspaceId
=
{
workspaceId
}
members
=
{
members
}
task
=
{
toFormValues
(
task
)
}
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
trigger
=
{
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-6 w-6 text-muted-foreground"
title
=
"Editar tarefa"
>
<
Pencil
className
=
"h-3 w-3"
/>
</
Button
>
}
/>
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
taskTitle
=
{
task
.
Title
}
/>
</
div
>
)
}
</
div
>
<
div
className
=
"flex flex-wrap items-center gap-1.5"
>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
<
DueBadge
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
</
div
>
<
div
className
=
"flex items-center gap-2"
>
<
Progress
value
=
{
percent
}
className
=
"h-1.5"
/>
<
span
className
=
"whitespace-nowrap text-xs text-muted-foreground"
>
{
percent
}
%
{
task
.
SubtaskCount
>
0
&&
` (
${
task
.
SubtaskDone
}
/
${
task
.
SubtaskCount
}
)`
}
</
span
>
</
div
>
<
div
className
=
"flex items-center justify-between text-xs text-muted-foreground"
>
<
span
>
{
task
.
AssigneeName
??
"
Sem responsável
"
}
</
span
>
<
span
>
{
formatDate
(
task
.
DueDate
)
}
</
span
>
</
div
>
{
editable
?
(
<
TaskStatusSelect
taskId
=
{
task
.
Id
}
status
=
{
task
.
Status
}
/>
)
:
(
<
StatusBadge
status
=
{
task
.
Status
}
/>
)
}
</
CardContent
>
</
Card
>
);
})
}
{
columnTasks
.
length
===
0
&&
(
<
div
className
=
"rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground"
>
Sem tarefas
</
div
>
)
}
</
div
>
</
div
>
);
})
}
</
div
>
);
}
src/components/tasks/task-by-person.tsx
0 → 100644
View file @
f5fa1ac0
import
Link
from
"
next/link
"
;
import
{
CheckCircle2
,
ChevronRight
,
Circle
,
Eye
,
Folder
,
LoaderCircle
,
Plus
,
UserRound
,
}
from
"
lucide-react
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
TaskDialog
}
from
"
./task-dialog
"
;
import
{
taskPercent
}
from
"
./task-progress
"
;
function
initials
(
name
:
string
):
string
{
return
name
.
split
(
"
"
)
.
filter
(
Boolean
)
.
slice
(
0
,
2
)
.
map
((
part
)
=>
part
[
0
]?.
toUpperCase
())
.
join
(
""
);
}
export
const
STATUS_CHIPS
=
[
{
value
:
"
todo
"
,
label
:
"
A Fazer
"
,
icon
:
Circle
,
className
:
"
text-slate-500 dark:text-slate-400
"
},
{
value
:
"
in_progress
"
,
label
:
"
Em Andamento
"
,
icon
:
LoaderCircle
,
className
:
"
text-blue-600 dark:text-blue-400
"
},
{
value
:
"
review
"
,
label
:
"
Em Revisão
"
,
icon
:
Eye
,
className
:
"
text-purple-600 dark:text-purple-400
"
},
{
value
:
"
done
"
,
label
:
"
Concluídas
"
,
icon
:
CheckCircle2
,
className
:
"
text-green-600 dark:text-green-400
"
},
]
as
const
;
interface
PersonGroup
{
key
:
string
;
name
:
string
|
null
;
userId
:
number
|
null
;
tasks
:
Task
[];
}
/**
* Pastas por membro: clicar na pasta abre a página da pessoa
* com apenas as tarefas dela.
*/
export
function
TaskByPerson
({
tasks
,
members
,
workspaceId
,
}:
{
tasks
:
Task
[];
members
:
WorkspaceMember
[];
workspaceId
:
number
;
subtasksByTask
?:
Record
<
number
,
Subtask
[]
>
;
})
{
const
groups
:
PersonGroup
[]
=
members
.
map
((
member
)
=>
({
key
:
String
(
member
.
UserId
),
name
:
member
.
Name
,
userId
:
member
.
UserId
,
tasks
:
tasks
.
filter
((
t
)
=>
t
.
AssigneeId
===
member
.
UserId
),
}));
const
unassigned
=
tasks
.
filter
((
t
)
=>
t
.
AssigneeId
===
null
);
if
(
unassigned
.
length
>
0
)
{
groups
.
push
({
key
:
"
unassigned
"
,
name
:
null
,
userId
:
null
,
tasks
:
unassigned
,
});
}
return
(
<
div
className
=
"space-y-3"
>
{
groups
.
map
((
group
)
=>
{
const
total
=
group
.
tasks
.
length
;
// Média do % das tarefas: reflete progresso parcial, não só concluídas.
const
percent
=
total
>
0
?
Math
.
round
(
group
.
tasks
.
reduce
(
(
sum
,
t
)
=>
sum
+
taskPercent
(
t
.
Status
,
t
.
SubtaskCount
,
t
.
SubtaskDone
,
t
.
Progress
),
0
)
/
total
)
:
0
;
const
href
=
`/workspaces/
${
workspaceId
}
/membro/
${
group
.
userId
??
0
}
`
;
return
(
<
div
key
=
{
group
.
key
}
className
=
"flex items-center gap-2 rounded-lg border bg-card pr-2 transition hover:border-primary/60 hover:shadow-sm"
>
<
Link
href
=
{
href
}
className
=
"flex min-w-0 flex-1 flex-wrap items-center gap-3 px-4 py-3"
>
<
Folder
className
=
"h-5 w-5 shrink-0 text-primary"
/>
<
Avatar
className
=
"h-8 w-8"
>
<
AvatarFallback
className
=
{
group
.
name
?
"
bg-primary text-primary-foreground text-xs
"
:
"
bg-muted text-muted-foreground
"
}
>
{
group
.
name
?
(
initials
(
group
.
name
)
)
:
(
<
UserRound
className
=
"h-4 w-4"
/>
)
}
</
AvatarFallback
>
</
Avatar
>
<
div
className
=
"min-w-0 flex-1"
>
<
p
className
=
"truncate font-medium leading-tight"
>
{
group
.
name
??
"
Sem responsável
"
}
</
p
>
<
p
className
=
"text-xs text-muted-foreground"
>
{
total
===
0
?
"
Nenhuma tarefa atribuída
"
:
`
${
total
}
tarefa
${
total
>
1
?
"
s
"
:
""
}
— clique para abrir a pasta`
}
</
p
>
</
div
>
<
div
className
=
"hidden items-center gap-3 md:flex"
>
{
STATUS_CHIPS
.
map
((
chip
)
=>
{
const
count
=
group
.
tasks
.
filter
(
(
t
)
=>
t
.
Status
===
chip
.
value
).
length
;
const
Icon
=
chip
.
icon
;
return
(
<
span
key
=
{
chip
.
value
}
className
=
{
cn
(
"
flex items-center gap-1 text-xs
"
,
count
>
0
?
chip
.
className
:
"
text-muted-foreground/40
"
)
}
title
=
{
chip
.
label
}
>
<
Icon
className
=
"h-3.5 w-3.5"
/>
{
count
}
</
span
>
);
})
}
</
div
>
<
div
className
=
"flex w-36 items-center gap-2"
>
<
Progress
value
=
{
percent
}
className
=
"h-2"
/>
<
span
className
=
"text-xs font-semibold text-muted-foreground"
>
{
percent
}
%
</
span
>
</
div
>
<
ChevronRight
className
=
"h-4 w-4 shrink-0 text-muted-foreground"
/>
</
Link
>
{
group
.
userId
!==
null
&&
(
<
TaskDialog
workspaceId
=
{
workspaceId
}
members
=
{
members
}
defaultAssigneeId
=
{
group
.
userId
}
trigger
=
{
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-8 w-8 shrink-0 text-muted-foreground"
title
=
{
`Nova tarefa para
${
group
.
name
}
`
}
>
<
Plus
className
=
"h-4 w-4"
/>
</
Button
>
}
/>
)
}
</
div
>
);
})
}
</
div
>
);
}
src/components/tasks/task-delete-button.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
useState
,
useTransition
}
from
"
react
"
;
import
{
Trash2
}
from
"
lucide-react
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
deleteTaskAction
}
from
"
@/lib/actions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogFooter
,
DialogHeader
,
DialogTitle
,
DialogTrigger
,
}
from
"
@/components/ui/dialog
"
;
export
function
TaskDeleteButton
({
taskId
,
taskTitle
,
}:
{
taskId
:
number
;
taskTitle
?:
string
;
})
{
const
[
open
,
setOpen
]
=
useState
(
false
);
const
[
pending
,
startTransition
]
=
useTransition
();
function
handleDelete
()
{
startTransition
(
async
()
=>
{
const
result
=
await
deleteTaskAction
(
taskId
);
if
(
result
.
error
)
toast
.
error
(
result
.
error
);
else
{
toast
.
success
(
"
Tarefa excluída.
"
);
setOpen
(
false
);
}
});
}
return
(
<
Dialog
open
=
{
open
}
onOpenChange
=
{
setOpen
}
>
<
DialogTrigger
asChild
>
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-8 w-8 text-muted-foreground hover:text-destructive"
title
=
"Excluir tarefa"
>
<
Trash2
className
=
"h-4 w-4"
/>
</
Button
>
</
DialogTrigger
>
<
DialogContent
className
=
"sm:max-w-sm"
>
<
DialogHeader
>
<
DialogTitle
>
Excluir
{
taskTitle
?
`“
${
taskTitle
}
”`
:
"
tarefa
"
}
?
</
DialogTitle
>
<
DialogDescription
>
A tarefa e suas subtarefas serão excluídas permanentemente.
</
DialogDescription
>
</
DialogHeader
>
<
DialogFooter
>
<
Button
variant
=
"destructive"
onClick
=
{
handleDelete
}
disabled
=
{
pending
}
className
=
"w-full"
>
{
pending
?
"
Excluindo...
"
:
"
Excluir tarefa
"
}
</
Button
>
</
DialogFooter
>
</
DialogContent
>
</
Dialog
>
);
}
src/components/tasks/task-dialog.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
useEffect
,
useState
,
type
ReactNode
}
from
"
react
"
;
import
{
useFormState
,
useFormStatus
}
from
"
react-dom
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
createTaskAction
,
updateTaskAction
,
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
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogHeader
,
DialogTitle
,
DialogTrigger
,
}
from
"
@/components/ui/dialog
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
import
{
Textarea
}
from
"
@/components/ui/textarea
"
;
import
{
Select
,
SelectContent
,
SelectItem
,
SelectTrigger
,
SelectValue
,
}
from
"
@/components/ui/select
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
export
interface
TaskFormValues
{
id
:
number
;
title
:
string
;
description
:
string
|
null
;
status
:
string
;
priority
:
string
;
assigneeId
:
number
|
null
;
startDate
:
string
|
null
;
// yyyy-MM-dd
dueDate
:
string
|
null
;
// yyyy-MM-dd
progress
:
number
;
}
function
SubmitButton
({
isEdit
}:
{
isEdit
:
boolean
})
{
const
{
pending
}
=
useFormStatus
();
return
(
<
Button
type
=
"submit"
disabled
=
{
pending
}
className
=
"w-full"
>
{
pending
?
"
Salvando...
"
:
isEdit
?
"
Salvar alterações
"
:
"
Criar tarefa
"
}
</
Button
>
);
}
const
initialState
:
ActionState
=
{};
export
function
TaskDialog
({
workspaceId
,
members
,
task
,
subtasks
,
defaultAssigneeId
,
trigger
,
}:
{
workspaceId
:
number
;
members
:
WorkspaceMember
[];
task
?:
TaskFormValues
;
subtasks
?:
Subtask
[];
defaultAssigneeId
?:
number
;
trigger
:
ReactNode
;
})
{
const
[
open
,
setOpen
]
=
useState
(
false
);
const
action
=
task
?
updateTaskAction
.
bind
(
null
,
task
.
id
)
:
createTaskAction
;
const
[
state
,
formAction
]
=
useFormState
(
action
,
initialState
);
useEffect
(()
=>
{
if
(
open
&&
state
.
success
)
{
toast
.
success
(
task
?
"
Tarefa atualizada.
"
:
"
Tarefa criada.
"
);
setOpen
(
false
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
state
]);
return
(
<
Dialog
open
=
{
open
}
onOpenChange
=
{
setOpen
}
>
<
DialogTrigger
asChild
>
{
trigger
}
</
DialogTrigger
>
<
DialogContent
className
=
"max-h-[90vh] overflow-y-auto sm:max-w-lg"
>
<
DialogHeader
>
<
DialogTitle
>
{
task
?
"
Editar tarefa
"
:
"
Nova tarefa
"
}
</
DialogTitle
>
<
DialogDescription
>
{
task
?
"
Atualize as informações da tarefa.
"
:
"
Descreva a tarefa, defina prazos e o responsável.
"
}
</
DialogDescription
>
</
DialogHeader
>
<
form
action
=
{
formAction
}
className
=
"space-y-4"
>
{
state
.
error
&&
(
<
Alert
variant
=
"destructive"
>
<
AlertDescription
>
{
state
.
error
}
</
AlertDescription
>
</
Alert
>
)
}
<
input
type
=
"hidden"
name
=
"workspaceId"
value
=
{
workspaceId
}
/>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-title"
>
Título
</
Label
>
<
Input
id
=
"task-title"
name
=
"title"
defaultValue
=
{
task
?.
title
}
placeholder
=
"O que precisa ser feito?"
required
/>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-description"
>
Descrição (opcional)
</
Label
>
<
Textarea
id
=
"task-description"
name
=
"description"
rows
=
{
3
}
defaultValue
=
{
task
?.
description
??
""
}
/>
</
div
>
<
div
className
=
"grid grid-cols-2 gap-4"
>
<
div
className
=
"space-y-2"
>
<
Label
>
Status
</
Label
>
<
Select
name
=
"status"
defaultValue
=
{
task
?.
status
??
"
todo
"
}
>
<
SelectTrigger
>
<
SelectValue
/>
</
SelectTrigger
>
<
SelectContent
>
{
TASK_STATUSES
.
map
((
s
)
=>
(
<
SelectItem
key
=
{
s
.
value
}
value
=
{
s
.
value
}
>
{
s
.
label
}
</
SelectItem
>
))
}
</
SelectContent
>
</
Select
>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
>
Prioridade
</
Label
>
<
Select
name
=
"priority"
defaultValue
=
{
task
?.
priority
??
"
medium
"
}
>
<
SelectTrigger
>
<
SelectValue
/>
</
SelectTrigger
>
<
SelectContent
>
{
TASK_PRIORITIES
.
map
((
p
)
=>
(
<
SelectItem
key
=
{
p
.
value
}
value
=
{
p
.
value
}
>
{
p
.
label
}
</
SelectItem
>
))
}
</
SelectContent
>
</
Select
>
</
div
>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
>
Responsável
</
Label
>
<
Select
name
=
"assigneeId"
defaultValue
=
{
task
?.
assigneeId
?
String
(
task
.
assigneeId
)
:
defaultAssigneeId
?
String
(
defaultAssigneeId
)
:
undefined
}
>
<
SelectTrigger
>
<
SelectValue
placeholder
=
"Sem responsável"
/>
</
SelectTrigger
>
<
SelectContent
>
{
members
.
map
((
m
)
=>
(
<
SelectItem
key
=
{
m
.
UserId
}
value
=
{
String
(
m
.
UserId
)
}
>
{
m
.
Name
}
</
SelectItem
>
))
}
</
SelectContent
>
</
Select
>
</
div
>
<
div
className
=
"grid grid-cols-2 gap-4"
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-start"
>
Início
</
Label
>
<
Input
id
=
"task-start"
name
=
"startDate"
type
=
"date"
defaultValue
=
{
task
?.
startDate
??
""
}
/>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-due"
>
Vencimento
</
Label
>
<
Input
id
=
"task-due"
name
=
"dueDate"
type
=
"date"
defaultValue
=
{
task
?.
dueDate
??
""
}
/>
</
div
>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-progress"
>
Progresso (%)
</
Label
>
<
Input
id
=
"task-progress"
name
=
"progress"
type
=
"number"
min
=
{
0
}
max
=
{
100
}
defaultValue
=
{
task
?.
progress
??
0
}
disabled
=
{
(
subtasks
?.
length
??
0
)
>
0
}
/>
<
p
className
=
"text-xs text-muted-foreground"
>
{
(
subtasks
?.
length
??
0
)
>
0
?
"
Calculado automaticamente pelas subtarefas.
"
:
"
Digite quanto da tarefa já foi concluído (0 a 100).
"
}
</
p
>
</
div
>
<
SubmitButton
isEdit
=
{
Boolean
(
task
)
}
/>
</
form
>
{
task
&&
(
<>
<
Separator
/>
<
SubtaskList
taskId
=
{
task
.
id
}
subtasks
=
{
subtasks
??
[]
}
/>
</>
)
}
</
DialogContent
>
</
Dialog
>
);
}
src/components/tasks/task-progress-editor.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
useEffect
,
useState
,
useTransition
}
from
"
react
"
;
import
{
ListChecks
}
from
"
lucide-react
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
updateTaskProgressAction
}
from
"
@/lib/actions
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
taskPercent
}
from
"
./task-progress
"
;
/**
* Barra de progresso com edição manual do %.
* Quando a tarefa tem subtarefas, o % vem do checklist e a digitação fica
* desabilitada; sem subtarefas, o membro digita o % diretamente.
*/
export
function
TaskProgressEditor
({
taskId
,
status
,
subtaskCount
,
subtaskDone
,
manualProgress
,
}:
{
taskId
:
number
;
status
:
string
;
subtaskCount
:
number
;
subtaskDone
:
number
;
manualProgress
:
number
;
})
{
const
percent
=
taskPercent
(
status
,
subtaskCount
,
subtaskDone
,
manualProgress
);
const
[
value
,
setValue
]
=
useState
(
String
(
percent
));
const
[
pending
,
startTransition
]
=
useTransition
();
// Mantém o campo em dia quando o % muda por outra via (subtarefas, status).
useEffect
(()
=>
{
setValue
(
String
(
percent
));
},
[
percent
]);
const
manual
=
subtaskCount
===
0
&&
status
!==
"
done
"
;
function
commit
()
{
const
parsed
=
Number
(
value
);
if
(
!
Number
.
isInteger
(
parsed
)
||
parsed
<
0
||
parsed
>
100
)
{
setValue
(
String
(
percent
));
toast
.
error
(
"
Informe um número entre 0 e 100.
"
);
return
;
}
if
(
parsed
===
manualProgress
)
return
;
startTransition
(
async
()
=>
{
const
result
=
await
updateTaskProgressAction
(
taskId
,
parsed
);
if
(
result
.
error
)
{
setValue
(
String
(
percent
));
toast
.
error
(
result
.
error
);
}
});
}
return
(
<
div
className
=
"flex min-w-[130px] items-center gap-2"
>
<
Progress
value
=
{
percent
}
className
=
"h-2 w-16"
/>
{
manual
?
(
<
span
className
=
"flex items-center text-xs text-muted-foreground"
>
<
Input
type
=
"number"
min
=
{
0
}
max
=
{
100
}
value
=
{
value
}
disabled
=
{
pending
}
onChange
=
{
(
e
)
=>
setValue
(
e
.
target
.
value
)
}
onBlur
=
{
commit
}
onKeyDown
=
{
(
e
)
=>
{
if
(
e
.
key
===
"
Enter
"
)
{
e
.
preventDefault
();
e
.
currentTarget
.
blur
();
}
}
}
className
=
"h-7 w-14 px-1.5 text-center text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
title
=
"Digite o % concluído"
/>
<
span
className
=
"ml-0.5"
>
%
</
span
>
</
span
>
)
:
(
<
span
className
=
"whitespace-nowrap text-xs text-muted-foreground"
>
{
percent
}
%
{
subtaskCount
>
0
&&
(
<
span
className
=
"ml-1 inline-flex items-center gap-0.5"
>
<
ListChecks
className
=
"h-3 w-3"
/>
{
subtaskDone
}
/
{
subtaskCount
}
</
span
>
)
}
</
span
>
)
}
</
div
>
);
}
src/components/tasks/task-progress.tsx
0 → 100644
View file @
f5fa1ac0
import
{
ListChecks
}
from
"
lucide-react
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
/**
* % de conclusão da tarefa:
* - tarefa com status "Concluída" => 100%;
* - com subtarefas => proporção do checklist;
* - sem subtarefas => progresso digitado manualmente.
*/
export
function
taskPercent
(
status
:
string
,
subtaskCount
:
number
,
subtaskDone
:
number
,
manualProgress
:
number
):
number
{
if
(
status
===
"
done
"
)
return
100
;
if
(
subtaskCount
>
0
)
return
Math
.
round
((
subtaskDone
/
subtaskCount
)
*
100
);
return
manualProgress
;
}
export
function
TaskProgress
({
status
,
subtaskCount
,
subtaskDone
,
manualProgress
,
}:
{
status
:
string
;
subtaskCount
:
number
;
subtaskDone
:
number
;
manualProgress
:
number
;
})
{
const
percent
=
taskPercent
(
status
,
subtaskCount
,
subtaskDone
,
manualProgress
);
return
(
<
div
className
=
"flex min-w-[120px] items-center gap-2"
>
<
Progress
value
=
{
percent
}
className
=
"h-2 w-16"
/>
<
span
className
=
"whitespace-nowrap text-xs text-muted-foreground"
>
{
percent
}
%
{
subtaskCount
>
0
&&
(
<
span
className
=
"ml-1 inline-flex items-center gap-0.5"
>
<
ListChecks
className
=
"h-3 w-3"
/>
{
subtaskDone
}
/
{
subtaskCount
}
</
span
>
)
}
</
span
>
</
div
>
);
}
src/components/tasks/task-status-select.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
useTransition
}
from
"
react
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
updateTaskStatusAction
}
from
"
@/lib/actions
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
{
Select
,
SelectContent
,
SelectItem
,
SelectTrigger
,
SelectValue
,
}
from
"
@/components/ui/select
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
const
STATUS_STYLES
:
Record
<
string
,
string
>
=
{
todo
:
"
border-slate-400/50 text-slate-600 dark:text-slate-300
"
,
in_progress
:
"
border-blue-500/50 text-blue-600 dark:text-blue-400
"
,
review
:
"
border-purple-500/50 text-purple-600 dark:text-purple-400
"
,
done
:
"
border-green-500/50 text-green-600 dark:text-green-400
"
,
};
const
STATUS_DOTS
:
Record
<
string
,
string
>
=
{
todo
:
"
bg-slate-400
"
,
in_progress
:
"
bg-blue-500
"
,
review
:
"
bg-purple-500
"
,
done
:
"
bg-green-500
"
,
};
export
function
TaskStatusSelect
({
taskId
,
status
,
}:
{
taskId
:
number
;
status
:
string
;
})
{
const
[
pending
,
startTransition
]
=
useTransition
();
function
handleChange
(
value
:
string
)
{
startTransition
(
async
()
=>
{
const
result
=
await
updateTaskStatusAction
(
taskId
,
value
);
if
(
result
.
error
)
toast
.
error
(
result
.
error
);
});
}
return
(
<
Select
value
=
{
status
}
onValueChange
=
{
handleChange
}
disabled
=
{
pending
}
>
<
SelectTrigger
className
=
{
cn
(
"
h-8 w-[160px] text-xs font-medium
"
,
STATUS_STYLES
[
status
])
}
>
<
SelectValue
/>
</
SelectTrigger
>
<
SelectContent
>
{
TASK_STATUSES
.
map
((
s
)
=>
(
<
SelectItem
key
=
{
s
.
value
}
value
=
{
s
.
value
}
>
<
span
className
=
"flex items-center gap-2"
>
<
span
className
=
{
cn
(
"
h-2 w-2 rounded-full
"
,
STATUS_DOTS
[
s
.
value
])
}
/>
{
s
.
label
}
</
span
>
</
SelectItem
>
))
}
</
SelectContent
>
</
Select
>
);
}
src/components/tasks/task-table.tsx
0 → 100644
View file @
f5fa1ac0
import
{
Pencil
}
from
"
lucide-react
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
formatDate
,
toISODate
}
from
"
@/lib/dates
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"
@/components/ui/table
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDialog
,
type
TaskFormValues
}
from
"
./task-dialog
"
;
import
{
TaskProgress
}
from
"
./task-progress
"
;
import
{
TaskProgressEditor
}
from
"
./task-progress-editor
"
;
import
{
TaskStatusSelect
}
from
"
./task-status-select
"
;
export
function
toFormValues
(
task
:
Task
):
TaskFormValues
{
return
{
id
:
task
.
Id
,
title
:
task
.
Title
,
description
:
task
.
Description
,
status
:
task
.
Status
,
priority
:
task
.
Priority
,
assigneeId
:
task
.
AssigneeId
,
startDate
:
toISODate
(
task
.
StartDate
),
dueDate
:
toISODate
(
task
.
DueDate
),
progress
:
task
.
Progress
,
};
}
export
function
TaskTable
({
tasks
,
members
,
workspaceId
,
subtasksByTask
,
currentUserId
,
isAdmin
,
}:
{
tasks
:
Task
[];
members
:
WorkspaceMember
[];
workspaceId
:
number
;
subtasksByTask
:
Record
<
number
,
Subtask
[]
>
;
currentUserId
:
number
;
isAdmin
:
boolean
;
})
{
if
(
tasks
.
length
===
0
)
{
return
(
<
div
className
=
"rounded-lg border border-dashed p-12 text-center text-sm text-muted-foreground"
>
Nenhuma tarefa aqui ainda.
</
div
>
);
}
return
(
<
div
className
=
"rounded-lg border"
>
<
Table
>
<
TableHeader
>
<
TableRow
>
<
TableHead
>
Tarefa
</
TableHead
>
<
TableHead
>
Status
</
TableHead
>
<
TableHead
>
Progresso
</
TableHead
>
<
TableHead
>
Prioridade
</
TableHead
>
<
TableHead
>
Responsável
</
TableHead
>
<
TableHead
>
Início
</
TableHead
>
<
TableHead
>
Vencimento
</
TableHead
>
<
TableHead
className
=
"w-[90px]"
/>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
tasks
.
map
((
task
)
=>
{
const
editable
=
canEditTask
(
task
,
currentUserId
,
isAdmin
);
return
(
<
TableRow
key
=
{
task
.
Id
}
>
<
TableCell
className
=
"max-w-[280px]"
>
<
p
className
=
"truncate font-medium"
>
{
task
.
Title
}
</
p
>
{
task
.
Description
&&
(
<
p
className
=
"truncate text-xs text-muted-foreground"
>
{
task
.
Description
}
</
p
>
)
}
</
TableCell
>
<
TableCell
>
{
editable
?
(
<
TaskStatusSelect
taskId
=
{
task
.
Id
}
status
=
{
task
.
Status
}
/>
)
:
(
<
StatusBadge
status
=
{
task
.
Status
}
/>
)
}
</
TableCell
>
<
TableCell
>
{
editable
?
(
<
TaskProgressEditor
taskId
=
{
task
.
Id
}
status
=
{
task
.
Status
}
subtaskCount
=
{
task
.
SubtaskCount
}
subtaskDone
=
{
task
.
SubtaskDone
}
manualProgress
=
{
task
.
Progress
}
/>
)
:
(
<
TaskProgress
status
=
{
task
.
Status
}
subtaskCount
=
{
task
.
SubtaskCount
}
subtaskDone
=
{
task
.
SubtaskDone
}
manualProgress
=
{
task
.
Progress
}
/>
)
}
</
TableCell
>
<
TableCell
>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
</
TableCell
>
<
TableCell
className
=
"text-sm"
>
{
task
.
AssigneeName
??
(
<
span
className
=
"text-muted-foreground"
>
—
</
span
>
)
}
</
TableCell
>
<
TableCell
className
=
"text-sm"
>
{
formatDate
(
task
.
StartDate
)
}
</
TableCell
>
<
TableCell
>
<
div
className
=
"flex flex-col gap-1"
>
<
span
className
=
"text-sm"
>
{
formatDate
(
task
.
DueDate
)
}
</
span
>
<
DueBadge
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
</
div
>
</
TableCell
>
<
TableCell
>
{
editable
&&
(
<
div
className
=
"flex items-center justify-end"
>
<
TaskDialog
workspaceId
=
{
workspaceId
}
members
=
{
members
}
task
=
{
toFormValues
(
task
)
}
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
trigger
=
{
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-8 w-8 text-muted-foreground"
title
=
"Editar tarefa"
>
<
Pencil
className
=
"h-4 w-4"
/>
</
Button
>
}
/>
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
/>
</
div
>
)
}
</
TableCell
>
</
TableRow
>
);
})
}
</
TableBody
>
</
Table
>
</
div
>
);
}
src/components/theme-provider.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
ThemeProvider
as
NextThemesProvider
}
from
"
next-themes
"
;
import
type
{
ComponentProps
}
from
"
react
"
;
export
function
ThemeProvider
(
props
:
ComponentProps
<
typeof
NextThemesProvider
>
)
{
return
<
NextThemesProvider
{
...
props
}
/>;
}
src/components/theme-toggle.tsx
0 → 100644
View file @
f5fa1ac0
"
use client
"
;
import
{
Moon
,
Sun
}
from
"
lucide-react
"
;
import
{
useTheme
}
from
"
next-themes
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
export
function
ThemeToggle
()
{
const
{
resolvedTheme
,
setTheme
}
=
useTheme
();
return
(
<
Button
variant
=
"ghost"
size
=
"icon"
title
=
"Alternar tema"
onClick
=
{
()
=>
setTheme
(
resolvedTheme
===
"
dark
"
?
"
light
"
:
"
dark
"
)
}
>
<
Sun
className
=
"h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<
Moon
className
=
"absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<
span
className
=
"sr-only"
>
Alternar tema
</
span
>
</
Button
>
);
}
src/components/ui/alert.tsx
0 → 100644
View file @
f5fa1ac0
import
*
as
React
from
"
react
"
import
{
cva
,
type
VariantProps
}
from
"
class-variance-authority
"
import
{
cn
}
from
"
@/lib/utils
"
const
alertVariants
=
cva
(
"
relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7
"
,
{
variants
:
{
variant
:
{
default
:
"
bg-background text-foreground
"
,
destructive
:
"
border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive
"
,
},
},
defaultVariants
:
{
variant
:
"
default
"
,
},
}
)
const
Alert
=
React
.
forwardRef
<
HTMLDivElement
,
React
.
HTMLAttributes
<
HTMLDivElement
>
&
VariantProps
<
typeof
alertVariants
>
>
(({
className
,
variant
,
...
props
},
ref
)
=>
(
<
div
ref
=
{
ref
}
role
=
"alert"
className
=
{
cn
(
alertVariants
({
variant
}),
className
)
}
{
...
props
}
/>
))
Alert
.
displayName
=
"
Alert
"
const
AlertTitle
=
React
.
forwardRef
<
HTMLParagraphElement
,
React
.
HTMLAttributes
<
HTMLHeadingElement
>
>
(({
className
,
...
props
},
ref
)
=>
(
<
h5
ref
=
{
ref
}
className
=
{
cn
(
"
mb-1 font-medium leading-none tracking-tight
"
,
className
)
}
{
...
props
}
/>
))
AlertTitle
.
displayName
=
"
AlertTitle
"
const
AlertDescription
=
React
.
forwardRef
<
HTMLParagraphElement
,
React
.
HTMLAttributes
<
HTMLParagraphElement
>
>
(({
className
,
...
props
},
ref
)
=>
(
<
div
ref
=
{
ref
}
className
=
{
cn
(
"
text-sm [&_p]:leading-relaxed
"
,
className
)
}
{
...
props
}
/>
))
AlertDescription
.
displayName
=
"
AlertDescription
"
export
{
Alert
,
AlertTitle
,
AlertDescription
}
Prev
1
2
3
4
Next
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