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
e0ea8b9c
Commit
e0ea8b9c
authored
Jun 10, 2026
by
Paulo Avila
Browse files
criação de mecanica para troca de senha, e melhoria na exibição de subtasks
parent
f85352f0
Changes
9
Hide whitespace changes
Inline
Side-by-side
src/app/(app)/admin/page.tsx
View file @
e0ea8b9c
import
{
ShieldCheck
}
from
"
lucide-react
"
;
import
{
Search
,
ShieldCheck
,
X
}
from
"
lucide-react
"
;
import
Link
from
"
next/link
"
;
import
{
requireSuperAdmin
,
SUPER_ADMIN_ROLE
}
from
"
@/lib/authz
"
;
import
{
requireSuperAdmin
,
SUPER_ADMIN_ROLE
}
from
"
@/lib/authz
"
;
import
{
listAllUsers
}
from
"
@/lib/data
"
;
import
{
listAllUsers
}
from
"
@/lib/data
"
;
import
{
formatDate
}
from
"
@/lib/dates
"
;
import
{
formatDate
}
from
"
@/lib/dates
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
import
{
Table
,
Table
,
TableBody
,
TableBody
,
...
@@ -15,9 +18,14 @@ import { ResetPasswordDialog } from "@/components/admin/reset-password-dialog";
...
@@ -15,9 +18,14 @@ import { ResetPasswordDialog } from "@/components/admin/reset-password-dialog";
export
const
metadata
=
{
title
:
"
Administração — Petruz Tasks
"
};
export
const
metadata
=
{
title
:
"
Administração — Petruz Tasks
"
};
export
default
async
function
AdminPage
()
{
export
default
async
function
AdminPage
({
searchParams
,
}:
{
searchParams
:
{
q
?:
string
};
})
{
const
admin
=
await
requireSuperAdmin
();
const
admin
=
await
requireSuperAdmin
();
const
users
=
await
listAllUsers
();
const
query
=
searchParams
.
q
?.
trim
()
??
""
;
const
users
=
await
listAllUsers
(
query
);
return
(
return
(
<
div
className
=
"space-y-6"
>
<
div
className
=
"space-y-6"
>
...
@@ -32,6 +40,33 @@ export default async function AdminPage() {
...
@@ -32,6 +40,33 @@ export default async function AdminPage() {
</
p
>
</
p
>
</
div
>
</
div
>
<
form
method
=
"GET"
className
=
"flex max-w-md items-center gap-2"
>
<
div
className
=
"relative flex-1"
>
<
Search
className
=
"absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<
Input
name
=
"q"
defaultValue
=
{
query
}
placeholder
=
"Buscar por nome ou e-mail..."
className
=
"pl-9"
/>
</
div
>
<
Button
type
=
"submit"
variant
=
"secondary"
>
Buscar
</
Button
>
{
query
&&
(
<
Button
variant
=
"ghost"
size
=
"icon"
asChild
title
=
"Limpar busca"
>
<
Link
href
=
"/admin"
>
<
X
className
=
"h-4 w-4"
/>
</
Link
>
</
Button
>
)
}
</
form
>
{
users
.
length
===
0
?
(
<
div
className
=
"rounded-lg border border-dashed p-12 text-center text-sm text-muted-foreground"
>
Nenhum usuário encontrado para “
{
query
}
”.
</
div
>
)
:
(
<
div
className
=
"rounded-lg border"
>
<
div
className
=
"rounded-lg border"
>
<
Table
>
<
Table
>
<
TableHeader
>
<
TableHeader
>
...
@@ -77,6 +112,7 @@ export default async function AdminPage() {
...
@@ -77,6 +112,7 @@ export default async function AdminPage() {
</
TableBody
>
</
TableBody
>
</
Table
>
</
Table
>
</
div
>
</
div
>
)
}
<
p
className
=
"text-xs text-muted-foreground"
>
<
p
className
=
"text-xs text-muted-foreground"
>
Para promover alguém a super admin, execute no banco de dados:
{
"
"
}
Para promover alguém a super admin, execute no banco de dados:
{
"
"
}
...
...
src/app/(app)/conta/page.tsx
0 → 100644
View file @
e0ea8b9c
import
{
KeyRound
}
from
"
lucide-react
"
;
import
{
requireUser
}
from
"
@/lib/authz
"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
,
}
from
"
@/components/ui/card
"
;
import
{
ChangePasswordForm
}
from
"
@/components/account/change-password-form
"
;
export
const
metadata
=
{
title
:
"
Minha conta — Petruz Tasks
"
};
export
default
async
function
AccountPage
()
{
const
user
=
await
requireUser
();
return
(
<
div
className
=
"mx-auto max-w-md space-y-6"
>
<
div
>
<
h1
className
=
"text-2xl font-bold"
>
Minha conta
</
h1
>
<
p
className
=
"text-sm text-muted-foreground"
>
{
user
.
name
}
·
{
user
.
email
}
</
p
>
</
div
>
<
Card
>
<
CardHeader
>
<
CardTitle
className
=
"flex items-center gap-2 text-lg"
>
<
KeyRound
className
=
"h-5 w-5 text-primary"
/>
Trocar minha senha
</
CardTitle
>
<
CardDescription
>
Confirme sua senha atual e escolha uma nova. A troca vale apenas
para a sua conta.
</
CardDescription
>
</
CardHeader
>
<
CardContent
>
<
ChangePasswordForm
/>
</
CardContent
>
</
Card
>
</
div
>
);
}
src/components/account/change-password-form.tsx
0 → 100644
View file @
e0ea8b9c
"
use client
"
;
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
useFormState
,
useFormStatus
}
from
"
react-dom
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
changeOwnPasswordAction
,
type
ActionState
}
from
"
@/lib/actions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
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"
disabled
=
{
pending
}
className
=
"w-full"
>
{
pending
?
"
Salvando...
"
:
"
Trocar senha
"
}
</
Button
>
);
}
const
initialState
:
ActionState
=
{};
export
function
ChangePasswordForm
()
{
const
[
state
,
formAction
]
=
useFormState
(
changeOwnPasswordAction
,
initialState
);
const
formRef
=
useRef
<
HTMLFormElement
>
(
null
);
useEffect
(()
=>
{
if
(
state
.
success
)
{
toast
.
success
(
"
Senha alterada com sucesso.
"
);
formRef
.
current
?.
reset
();
}
},
[
state
]);
return
(
<
form
ref
=
{
formRef
}
action
=
{
formAction
}
className
=
"space-y-4"
>
{
state
.
error
&&
(
<
Alert
variant
=
"destructive"
>
<
AlertDescription
>
{
state
.
error
}
</
AlertDescription
>
</
Alert
>
)
}
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"current-password"
>
Senha atual
</
Label
>
<
Input
id
=
"current-password"
name
=
"currentPassword"
type
=
"password"
autoComplete
=
"current-password"
required
/>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"new-password"
>
Nova senha
</
Label
>
<
Input
id
=
"new-password"
name
=
"newPassword"
type
=
"password"
autoComplete
=
"new-password"
minLength
=
{
8
}
required
/>
<
p
className
=
"text-xs text-muted-foreground"
>
Mínimo de 8 caracteres.
</
p
>
</
div
>
<
SubmitButton
/>
</
form
>
);
}
src/components/layout/topbar.tsx
View file @
e0ea8b9c
import
{
LogOut
}
from
"
lucide-react
"
;
import
Link
from
"
next/link
"
;
import
{
KeyRound
,
LogOut
}
from
"
lucide-react
"
;
import
{
logoutAction
}
from
"
@/lib/actions
"
;
import
{
logoutAction
}
from
"
@/lib/actions
"
;
import
type
{
SessionUser
}
from
"
@/lib/auth
"
;
import
type
{
SessionUser
}
from
"
@/lib/auth
"
;
import
{
ThemeToggle
}
from
"
@/components/theme-toggle
"
;
import
{
ThemeToggle
}
from
"
@/components/theme-toggle
"
;
...
@@ -33,6 +34,12 @@ export function Topbar({ user }: { user: SessionUser }) {
...
@@ -33,6 +34,12 @@ export function Topbar({ user }: { user: SessionUser }) {
<
p
className
=
"text-xs text-muted-foreground"
>
{
user
.
email
}
</
p
>
<
p
className
=
"text-xs text-muted-foreground"
>
{
user
.
email
}
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
<
Button
variant
=
"ghost"
size
=
"icon"
asChild
title
=
"Minha conta"
>
<
Link
href
=
"/conta"
>
<
KeyRound
className
=
"h-4 w-4"
/>
<
span
className
=
"sr-only"
>
Minha conta
</
span
>
</
Link
>
</
Button
>
<
form
action
=
{
logoutAction
}
>
<
form
action
=
{
logoutAction
}
>
<
Button
variant
=
"ghost"
size
=
"icon"
title
=
"Sair"
type
=
"submit"
>
<
Button
variant
=
"ghost"
size
=
"icon"
title
=
"Sair"
type
=
"submit"
>
<
LogOut
className
=
"h-4 w-4"
/>
<
LogOut
className
=
"h-4 w-4"
/>
...
...
src/components/tasks/status-summary.tsx
View file @
e0ea8b9c
import
{
import
{
CheckCircle2
,
Circle
,
Eye
,
LoaderCircle
}
from
"
lucide-react
"
;
CheckCircle2
,
Circle
,
Eye
,
LoaderCircle
,
}
from
"
lucide-react
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
type
{
Task
}
from
"
@/lib/data
"
;
import
type
{
Task
}
from
"
@/lib/data
"
;
import
{
Card
,
CardContent
}
from
"
@/components/ui/card
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
const
STATUS_VISUALS
:
Record
<
const
STATUS_VISUALS
:
Record
<
string
,
string
,
{
icon
:
typeof
Circle
;
text
:
string
;
bar
:
string
}
{
icon
:
typeof
Circle
;
text
:
string
;
iconBg
:
string
}
>
=
{
>
=
{
todo
:
{
todo
:
{
icon
:
Circle
,
icon
:
Circle
,
text
:
"
text-slate-
5
00 dark:text-slate-
4
00
"
,
text
:
"
text-slate-
6
00 dark:text-slate-
3
00
"
,
bar
:
"
bg-slate-
4
00
"
,
iconBg
:
"
bg-slate-
500/10 text-slate-5
00
"
,
},
},
in_progress
:
{
in_progress
:
{
icon
:
LoaderCircle
,
icon
:
LoaderCircle
,
text
:
"
text-blue-600 dark:text-blue-400
"
,
text
:
"
text-blue-600 dark:text-blue-400
"
,
bar
:
"
bg
-blue-500
"
,
iconBg
:
"
bg-blue-500/10 text
-blue-500
"
,
},
},
review
:
{
review
:
{
icon
:
Eye
,
icon
:
Eye
,
text
:
"
text-purple-600 dark:text-purple-400
"
,
text
:
"
text-purple-600 dark:text-purple-400
"
,
bar
:
"
bg
-purple-500
"
,
iconBg
:
"
bg-purple-500/10 text
-purple-500
"
,
},
},
done
:
{
done
:
{
icon
:
CheckCircle2
,
icon
:
CheckCircle2
,
text
:
"
text-green-600 dark:text-green-400
"
,
text
:
"
text-green-600 dark:text-green-400
"
,
bar
:
"
bg
-green-500
"
,
iconBg
:
"
bg-green-500/10 text
-green-500
"
,
},
},
};
};
/** Cartões de acompanhamento por status
+ barra geral
do espaço. */
/** Cartões de acompanhamento por status do espaço. */
export
function
StatusSummary
({
tasks
}:
{
tasks
:
Task
[]
})
{
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
(
return
(
<
div
className
=
"space-y-3"
>
<
div
className
=
"grid grid-cols-2 gap-3 lg:grid-cols-4"
>
<
div
className
=
"grid grid-cols-2 gap-3 lg:grid-cols-4"
>
{
TASK_STATUSES
.
map
((
status
)
=>
{
{
TASK_STATUSES
.
map
((
status
)
=>
{
const
visual
=
STATUS_VISUALS
[
status
.
value
];
const
visual
=
STATUS_VISUALS
[
status
.
value
];
const
count
=
tasks
.
filter
((
t
)
=>
t
.
Status
===
status
.
value
).
length
;
const
count
=
tasks
.
filter
((
t
)
=>
t
.
Status
===
status
.
value
).
length
;
const
Icon
=
visual
.
icon
;
const
percent
=
total
>
0
?
Math
.
round
((
count
/
total
)
*
100
)
:
0
;
return
(
const
Icon
=
visual
.
icon
;
<
div
return
(
key
=
{
status
.
value
}
<
Card
key
=
{
status
.
value
}
>
className
=
"flex items-center gap-3 rounded-xl border bg-card p-4"
<
CardContent
className
=
"space-y-2 p-4"
>
>
<
div
className
=
"flex items-center justify-between"
>
<
span
<
span
className
=
{
cn
(
className
=
{
cn
(
"
flex h-10 w-10 shrink-0 items-center justify-center rounded-lg
"
,
"
flex items-center gap-1.5 text-sm font-medium
"
,
visual
.
iconBg
visual
.
text
)
}
)
}
>
>
<
Icon
className
=
"h-5 w-5"
/>
<
Icon
className
=
"h-4 w-4"
/>
</
span
>
{
status
.
label
}
<
div
className
=
"min-w-0"
>
</
span
>
<
p
className
=
"text-2xl font-bold leading-none"
>
{
count
}
</
p
>
<
span
className
=
"text-2xl font-bold"
>
{
count
}
</
span
>
<
p
</
div
>
className
=
{
cn
(
<
div
className
=
"h-1.5 overflow-hidden rounded-full bg-muted"
>
"
mt-1 truncate text-xs font-medium
"
,
<
div
visual
.
text
className
=
{
cn
(
"
h-full rounded-full transition-all
"
,
visual
.
bar
)
}
)
}
style
=
{
{
width
:
`
${
percent
}
%`
}
}
>
/>
{
status
.
label
}
</
div
>
</
p
>
<
p
className
=
"text-xs text-muted-foreground"
>
</
div
>
{
percent
}
% das tarefas
</
div
>
</
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
>
</
div
>
);
);
}
}
src/components/tasks/task-by-person.tsx
View file @
e0ea8b9c
import
Link
from
"
next/link
"
;
import
Link
from
"
next/link
"
;
import
{
import
{
CheckCircle2
,
CheckCircle2
,
ChevronRight
,
Circle
,
Circle
,
Eye
,
Eye
,
Folder
,
LoaderCircle
,
LoaderCircle
,
Plus
,
Plus
,
UserRound
,
UserRound
,
...
@@ -27,10 +25,10 @@ function initials(name: string): string {
...
@@ -27,10 +25,10 @@ function initials(name: string): string {
}
}
export
const
STATUS_CHIPS
=
[
export
const
STATUS_CHIPS
=
[
{
value
:
"
todo
"
,
label
:
"
A Fazer
"
,
icon
:
Circle
,
className
:
"
text-slate-
5
00 dark:text-slate-
40
0
"
},
{
value
:
"
todo
"
,
label
:
"
A Fazer
"
,
icon
:
Circle
,
className
:
"
text-slate-
6
00 dark:text-slate-
300
"
,
chipBg
:
"
bg-slate-500/1
0
"
},
{
value
:
"
in_progress
"
,
label
:
"
Em Andamento
"
,
icon
:
LoaderCircle
,
className
:
"
text-blue-600 dark:text-blue-400
"
},
{
value
:
"
in_progress
"
,
label
:
"
Em Andamento
"
,
icon
:
LoaderCircle
,
className
:
"
text-blue-600 dark:text-blue-400
"
,
chipBg
:
"
bg-blue-500/10
"
},
{
value
:
"
review
"
,
label
:
"
Em Revisão
"
,
icon
:
Eye
,
className
:
"
text-purple-600 dark:text-purple-400
"
},
{
value
:
"
review
"
,
label
:
"
Em Revisão
"
,
icon
:
Eye
,
className
:
"
text-purple-600 dark:text-purple-400
"
,
chipBg
:
"
bg-purple-500/10
"
},
{
value
:
"
done
"
,
label
:
"
Concluídas
"
,
icon
:
CheckCircle2
,
className
:
"
text-green-600 dark:text-green-400
"
},
{
value
:
"
done
"
,
label
:
"
Concluídas
"
,
icon
:
CheckCircle2
,
className
:
"
text-green-600 dark:text-green-400
"
,
chipBg
:
"
bg-green-500/10
"
},
]
as
const
;
]
as
const
;
interface
PersonGroup
{
interface
PersonGroup
{
...
@@ -41,7 +39,7 @@ interface PersonGroup {
...
@@ -41,7 +39,7 @@ interface PersonGroup {
}
}
/**
/**
* Pastas por membro: clicar n
a pasta
abre a página da pessoa
* Pastas por membro
em grade
: clicar n
o card
abre a página da pessoa
* com apenas as tarefas dela.
* com apenas as tarefas dela.
*/
*/
export
function
TaskByPerson
({
export
function
TaskByPerson
({
...
@@ -75,7 +73,7 @@ export function TaskByPerson({
...
@@ -75,7 +73,7 @@ export function TaskByPerson({
}
}
return
(
return
(
<
div
className
=
"
space-y
-3"
>
<
div
className
=
"
grid gap-4 sm:grid-cols-2 xl:grid-cols
-3"
>
{
groups
.
map
((
group
)
=>
{
{
groups
.
map
((
group
)
=>
{
const
total
=
group
.
tasks
.
length
;
const
total
=
group
.
tasks
.
length
;
// Média do % das tarefas: reflete progresso parcial, não só concluídas.
// Média do % das tarefas: reflete progresso parcial, não só concluídas.
...
@@ -90,94 +88,109 @@ export function TaskByPerson({
...
@@ -90,94 +88,109 @@ export function TaskByPerson({
)
/
total
)
/
total
)
)
:
0
;
:
0
;
const
href
=
`/workspaces/
${
workspaceId
}
/membro/
${
group
.
userId
??
0
}
`
;
const
isOwn
=
group
.
userId
===
currentUserId
;
const
canQuickCreate
=
group
.
userId
!==
null
&&
(
isAdmin
||
isOwn
);
return
(
return
(
<
div
<
div
key
=
{
group
.
key
}
key
=
{
group
.
key
}
className
=
"
flex items-center gap-2
rounded-l
g
border bg-card
pr-2
transition hover:border-primary/
6
0 hover:shadow-
s
m"
className
=
"
group relative
rounded-
x
l border bg-card transition hover:border-primary/
5
0 hover:shadow-m
d
"
>
>
<
Link
<
Link
href
=
{
href
}
href
=
{
`/workspaces/
${
workspaceId
}
/membro/
${
group
.
userId
??
0
}
`
}
className
=
"
flex min-w-0 flex-1 flex-wrap items-center gap-3 px
-4 p
y-3
"
className
=
"
block space-y
-4 p
-5
"
>
>
<
Folder
className
=
"h-5 w-5 shrink-0 text-primary"
/>
<
div
className
=
"flex items-center gap-3"
>
<
Avatar
className
=
"h-8 w-8"
>
<
Avatar
className
=
"h-12 w-12 ring-2 ring-primary/15"
>
<
AvatarFallback
<
AvatarFallback
className
=
{
className
=
{
group
.
name
group
.
name
?
"
bg-primary text-primary-foreground text-xs
"
?
"
bg-primary text-primary-foreground text-sm font-semibold
"
:
"
bg-muted text-muted-foreground
"
:
"
bg-muted text-muted-foreground
"
}
}
>
>
{
group
.
name
?
(
{
group
.
name
?
(
initials
(
group
.
name
)
initials
(
group
.
name
)
)
:
(
)
:
(
<
UserRound
className
=
"h-4 w-4"
/>
<
UserRound
className
=
"h-5 w-5"
/>
)
}
)
}
</
AvatarFallback
>
</
AvatarFallback
>
</
Avatar
>
</
Avatar
>
<
div
className
=
"min-w-0 flex-1"
>
<
div
className
=
"min-w-0"
>
<
p
className
=
"truncate font-medium leading-tight"
>
<
p
className
=
"truncate font-semibold leading-tight"
>
{
group
.
name
??
"
Sem responsável
"
}
{
group
.
name
??
"
Sem responsável
"
}
</
p
>
{
isOwn
&&
(
<
p
className
=
"text-xs text-muted-foreground"
>
<
span
className
=
"ml-1.5 text-xs font-normal text-primary"
>
{
total
===
0
(você)
?
"
Nenhuma tarefa atribuída
"
</
span
>
:
`
${
total
}
tarefa
${
total
>
1
?
"
s
"
:
""
}
— clique para abrir a pasta`
}
)
}
</
p
>
</
p
>
<
p
className
=
"text-xs text-muted-foreground"
>
{
total
===
0
?
"
Nenhuma tarefa
"
:
`
${
total
}
tarefa
${
total
>
1
?
"
s
"
:
""
}
`
}
</
p
>
</
div
>
</
div
>
</
div
>
<
div
className
=
"
hidden items-center gap-3 md:flex
"
>
<
div
className
=
"
flex flex-wrap gap-1.5
"
>
{
STATUS_CHIPS
.
map
((
chip
)
=>
{
{
STATUS_CHIPS
.
map
((
chip
)
=>
{
const
count
=
group
.
tasks
.
filter
(
const
count
=
group
.
tasks
.
filter
(
(
t
)
=>
t
.
Status
===
chip
.
value
(
t
)
=>
t
.
Status
===
chip
.
value
).
length
;
).
length
;
if
(
count
===
0
)
return
null
;
const
Icon
=
chip
.
icon
;
const
Icon
=
chip
.
icon
;
return
(
return
(
<
span
<
span
key
=
{
chip
.
value
}
key
=
{
chip
.
value
}
title
=
{
chip
.
label
}
className
=
{
cn
(
className
=
{
cn
(
"
flex items-center gap-1 text-xs
"
,
"
inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium
"
,
count
>
0
?
chip
.
className
:
"
text-muted-foreground/40
"
chip
.
chipBg
,
chip
.
className
)
}
)
}
title
=
{
chip
.
label
}
>
>
<
Icon
className
=
"h-3
.5
w-3
.5
"
/>
<
Icon
className
=
"h-3 w-3"
/>
{
count
}
{
count
}
</
span
>
</
span
>
);
);
})
}
})
}
{
total
===
0
&&
(
<
span
className
=
"rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
Pasta vazia
</
span
>
)
}
</
div
>
</
div
>
<
div
className
=
"flex
w-36
items-center gap-2"
>
<
div
className
=
"flex items-center gap-2"
>
<
Progress
value
=
{
percent
}
className
=
"h-
2
"
/>
<
Progress
value
=
{
percent
}
className
=
"h-
1.5
"
/>
<
span
className
=
"text-xs font-semibold text-muted-foreground"
>
<
span
className
=
"text-xs font-semibold text-muted-foreground"
>
{
percent
}
%
{
percent
}
%
</
span
>
</
span
>
</
div
>
</
div
>
<
ChevronRight
className
=
"h-4 w-4 shrink-0 text-muted-foreground"
/>
</
Link
>
</
Link
>
{
group
.
userId
!==
null
&&
{
canQuickCreate
&&
(
(
isAdmin
||
group
.
userId
===
currentUserId
)
&&
(
<
div
className
=
"absolute right-3 top-3"
>
<
TaskDialog
<
TaskDialog
workspaceId
=
{
workspaceId
}
workspaceId
=
{
workspaceId
}
members
=
{
members
}
members
=
{
members
}
defaultAssigneeId
=
{
group
.
userId
}
defaultAssigneeId
=
{
group
.
userId
??
undefined
}
currentUserId
=
{
currentUserId
}
currentUserId
=
{
currentUserId
}
isAdmin
=
{
isAdmin
}
isAdmin
=
{
isAdmin
}
trigger
=
{
trigger
=
{
<
Button
<
Button
variant
=
"ghost"
variant
=
"ghost"
size
=
"icon"
size
=
"icon"
className
=
"h-8 w-8 shrink-0 text-muted-foreground"
className
=
"h-8 w-8 text-muted-foreground transition-opacity md:opacity-0 md:group-hover:opacity-100"
title
=
{
`Nova tarefa para
${
group
.
name
}
`
}
title
=
{
`Nova tarefa para
${
group
.
name
}
`
}
>
>
<
Plus
className
=
"h-4 w-4"
/>
<
Plus
className
=
"h-4 w-4"
/>
</
Button
>
</
Button
>
}
}
/>
/>
</
div
>
)
}
)
}
</
div
>
</
div
>
);
);
...
...
src/components/tasks/task-table.tsx
View file @
e0ea8b9c
import
{
Pencil
}
from
"
lucide-react
"
;
"
use client
"
;
import
{
Fragment
,
useState
}
from
"
react
"
;
import
{
CheckCircle2
,
ChevronRight
,
Circle
,
Pencil
}
from
"
lucide-react
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
type
{
Subtask
,
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
formatDate
,
toISODate
}
from
"
@/lib/dates
"
;
import
{
formatDate
,
toISODate
}
from
"
@/lib/dates
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
import
{
Table
,
Table
,
...
@@ -11,9 +15,11 @@ import {
...
@@ -11,9 +15,11 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"
@/components/ui/table
"
;
}
from
"
@/components/ui/table
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
SubtaskList
}
from
"
./subtask-list
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDialog
,
type
TaskFormValues
}
from
"
./task-dialog
"
;
import
{
TaskDialog
,
type
TaskFormValues
}
from
"
./task-dialog
"
;
import
{
TaskProgress
}
from
"
./task-progress
"
;
import
{
TaskProgress
}
from
"
./task-progress
"
;
...
@@ -34,6 +40,17 @@ export function toFormValues(task: Task): TaskFormValues {
...
@@ -34,6 +40,17 @@ export function toFormValues(task: Task): TaskFormValues {
};
};
}
}
function
initials
(
name
:
string
):
string
{
return
name
.
split
(
"
"
)
.
filter
(
Boolean
)
.
slice
(
0
,
2
)
.
map
((
part
)
=>
part
[
0
]?.
toUpperCase
())
.
join
(
""
);
}
const
COLUMN_COUNT
=
6
;
export
function
TaskTable
({
export
function
TaskTable
({
tasks
,
tasks
,
members
,
members
,
...
@@ -49,110 +66,215 @@ export function TaskTable({
...
@@ -49,110 +66,215 @@ export function TaskTable({
currentUserId
:
number
;
currentUserId
:
number
;
isAdmin
:
boolean
;
isAdmin
:
boolean
;
})
{
})
{
const
[
expanded
,
setExpanded
]
=
useState
<
number
[]
>
([]);
function
toggleExpanded
(
taskId
:
number
)
{
setExpanded
((
prev
)
=>
prev
.
includes
(
taskId
)
?
prev
.
filter
((
id
)
=>
id
!==
taskId
)
:
[...
prev
,
taskId
]
);
}
if
(
tasks
.
length
===
0
)
{
if
(
tasks
.
length
===
0
)
{
return
(
return
(
<
div
className
=
"rounded-l
g
border border-dashed p-12 text-center text-sm text-muted-foreground"
>
<
div
className
=
"rounded-
x
l border border-dashed p-12 text-center text-sm text-muted-foreground"
>
Nenhuma tarefa aqui ainda.
Nenhuma tarefa aqui ainda.
</
div
>
</
div
>
);
);
}
}
return
(
return
(
<
div
className
=
"rounded-l
g
border"
>
<
div
className
=
"
overflow-hidden
rounded-
x
l border
bg-card
"
>
<
Table
>
<
Table
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
>
<
TableRow
className
=
"border-b bg-muted/40 hover:bg-muted/40"
>
<
TableHead
>
Tarefa
</
TableHead
>
<
TableHead
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
<
TableHead
>
Status
</
TableHead
>
Tarefa
<
TableHead
>
Progresso
</
TableHead
>
</
TableHead
>
<
TableHead
>
Prioridade
</
TableHead
>
<
TableHead
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
<
TableHead
>
Responsável
</
TableHead
>
Status
<
TableHead
>
Início
</
TableHead
>
</
TableHead
>
<
TableHead
>
Vencimento
</
TableHead
>
<
TableHead
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Progresso
</
TableHead
>
<
TableHead
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Responsável
</
TableHead
>
<
TableHead
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Vencimento
</
TableHead
>
<
TableHead
className
=
"w-[90px]"
/>
<
TableHead
className
=
"w-[90px]"
/>
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
tasks
.
map
((
task
)
=>
{
{
tasks
.
map
((
task
)
=>
{
const
editable
=
canEditTask
(
task
,
currentUserId
,
isAdmin
);
const
editable
=
canEditTask
(
task
,
currentUserId
,
isAdmin
);
const
subtasks
=
subtasksByTask
[
task
.
Id
]
??
[];
const
hasSubtasks
=
subtasks
.
length
>
0
;
const
isOpen
=
expanded
.
includes
(
task
.
Id
);
return
(
return
(
<
TableRow
key
=
{
task
.
Id
}
>
<
Fragment
key
=
{
task
.
Id
}
>
<
TableCell
className
=
"max-w-[280px]"
>
<
TableRow
<
p
className
=
"truncate font-medium"
>
{
task
.
Title
}
</
p
>
className
=
{
cn
(
{
task
.
Description
&&
(
"
group border-b transition-colors hover:bg-muted/30
"
,
<
p
className
=
"truncate text-xs text-muted-foreground"
>
isOpen
?
"
border-0 bg-muted/20
"
:
"
last:border-0
"
{
task
.
Description
}
</
p
>
)
}
)
}
</
TableCell
>
>
<
TableCell
>
<
TableCell
className
=
"max-w-[300px] py-3"
>
{
editable
?
(
<
div
className
=
"flex items-center gap-1.5"
>
<
TaskStatusSelect
taskId
=
{
task
.
Id
}
status
=
{
task
.
Status
}
/>
{
hasSubtasks
?
(
)
:
(
<
button
<
StatusBadge
status
=
{
task
.
Status
}
/>
type
=
"button"
)
}
onClick
=
{
()
=>
toggleExpanded
(
task
.
Id
)
}
</
TableCell
>
title
=
{
<
TableCell
>
isOpen
{
editable
?
(
?
"
Recolher subtarefas
"
<
TaskProgressEditor
:
"
Expandir subtarefas
"
taskId
=
{
task
.
Id
}
}
status
=
{
task
.
Status
}
className
=
"flex shrink-0 items-center gap-1 rounded-md px-1 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
subtaskCount
=
{
task
.
SubtaskCount
}
>
subtaskDone
=
{
task
.
SubtaskDone
}
<
ChevronRight
manualProgress
=
{
task
.
Progress
}
className
=
{
cn
(
/>
"
h-4 w-4 transition-transform
"
,
)
:
(
isOpen
&&
"
rotate-90
"
<
TaskProgress
)
}
status
=
{
task
.
Status
}
/>
subtaskCount
=
{
task
.
SubtaskCount
}
{
subtasks
.
filter
((
s
)
=>
s
.
IsDone
).
length
}
/
subtaskDone
=
{
task
.
SubtaskDone
}
{
subtasks
.
length
}
manualProgress
=
{
task
.
Progress
}
</
button
>
/>
)
:
(
)
}
<
span
className
=
"w-5 shrink-0"
/>
</
TableCell
>
)
}
<
TableCell
>
<
p
className
=
"truncate font-medium"
>
{
task
.
Title
}
</
p
>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
</
TableCell
>
</
div
>
<
TableCell
className
=
"text-sm"
>
{
task
.
Description
&&
(
{
task
.
AssigneeName
??
(
<
p
className
=
"ml-6 mt-0.5 truncate text-xs text-muted-foreground"
>
<
span
className
=
"text-muted-foreground"
>
—
</
span
>
{
task
.
Description
}
)
}
</
p
>
</
TableCell
>
)
}
<
TableCell
className
=
"text-sm"
>
</
TableCell
>
{
formatDate
(
task
.
StartDate
)
}
<
TableCell
className
=
"py-3"
>
</
TableCell
>
{
editable
?
(
<
TableCell
>
<
TaskStatusSelect
taskId
=
{
task
.
Id
}
status
=
{
task
.
Status
}
/>
<
div
className
=
"flex flex-col gap-1"
>
)
:
(
<
span
className
=
"text-sm"
>
{
formatDate
(
task
.
DueDate
)
}
</
span
>
<
StatusBadge
status
=
{
task
.
Status
}
/>
<
DueBadge
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
)
}
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className
=
"py-3"
>
<
TableCell
>
{
editable
?
(
{
editable
&&
(
<
TaskProgressEditor
<
div
className
=
"flex items-center justify-end"
>
taskId
=
{
task
.
Id
}
<
TaskDialog
status
=
{
task
.
Status
}
workspaceId
=
{
workspaceId
}
subtaskCount
=
{
task
.
SubtaskCount
}
members
=
{
members
}
subtaskDone
=
{
task
.
SubtaskDone
}
task
=
{
toFormValues
(
task
)
}
manualProgress
=
{
task
.
Progress
}
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
/>
currentUserId
=
{
currentUserId
}
)
:
(
isAdmin
=
{
isAdmin
}
<
TaskProgress
trigger
=
{
status
=
{
task
.
Status
}
<
Button
subtaskCount
=
{
task
.
SubtaskCount
}
variant
=
"ghost"
subtaskDone
=
{
task
.
SubtaskDone
}
size
=
"icon"
manualProgress
=
{
task
.
Progress
}
className
=
"h-8 w-8 text-muted-foreground"
title
=
"Editar tarefa"
>
<
Pencil
className
=
"h-4 w-4"
/>
</
Button
>
}
/>
/>
<
TaskDeleteButton
taskId
=
{
task
.
Id
}
/>
)
}
</
TableCell
>
<
TableCell
className
=
"py-3"
>
{
task
.
AssigneeName
?
(
<
div
className
=
"flex items-center gap-2"
>
<
Avatar
className
=
"h-7 w-7"
>
<
AvatarFallback
className
=
"bg-primary/15 text-[10px] font-semibold text-primary"
>
{
initials
(
task
.
AssigneeName
)
}
</
AvatarFallback
>
</
Avatar
>
<
span
className
=
"hidden text-sm lg:inline"
>
{
task
.
AssigneeName
}
</
span
>
</
div
>
)
:
(
<
span
className
=
"text-sm text-muted-foreground"
>
—
</
span
>
)
}
</
TableCell
>
<
TableCell
className
=
"py-3"
>
<
div
className
=
"flex items-center gap-2"
>
<
span
className
=
"text-sm text-muted-foreground"
title
=
{
task
.
StartDate
?
`Início:
${
formatDate
(
task
.
StartDate
)}
`
:
undefined
}
>
{
formatDate
(
task
.
DueDate
)
}
</
span
>
<
DueBadge
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
</
div
>
</
div
>
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className
=
"py-3"
>
</
TableRow
>
{
editable
&&
(
<
div
className
=
"flex items-center justify-end transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<
TaskDialog
workspaceId
=
{
workspaceId
}
members
=
{
members
}
task
=
{
toFormValues
(
task
)
}
subtasks
=
{
subtasks
}
currentUserId
=
{
currentUserId
}
isAdmin
=
{
isAdmin
}
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
}
taskTitle
=
{
task
.
Title
}
/>
</
div
>
)
}
</
TableCell
>
</
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"
/>
)
}
<
span
className
=
{
cn
(
subtask
.
IsDone
&&
"
text-muted-foreground line-through
"
)
}
>
{
subtask
.
Title
}
</
span
>
</
li
>
))
}
</
ul
>
)
}
</
div
>
</
TableCell
>
</
TableRow
>
)
}
</
Fragment
>
);
);
})
}
})
}
</
TableBody
>
</
TableBody
>
...
...
src/lib/actions.ts
View file @
e0ea8b9c
...
@@ -104,6 +104,44 @@ export async function logoutAction(): Promise<void> {
...
@@ -104,6 +104,44 @@ export async function logoutAction(): Promise<void> {
redirect
(
"
/login
"
);
redirect
(
"
/login
"
);
}
}
// ---------- Conta (troca da própria senha) ----------
const
changePasswordSchema
=
z
.
object
({
currentPassword
:
z
.
string
().
min
(
1
,
"
Informe sua senha atual.
"
),
newPassword
:
z
.
string
()
.
min
(
8
,
"
A nova senha deve ter no mínimo 8 caracteres.
"
),
});
export
async
function
changeOwnPasswordAction
(
_prev
:
ActionState
,
formData
:
FormData
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
const
parsed
=
changePasswordSchema
.
safeParse
({
currentPassword
:
formData
.
get
(
"
currentPassword
"
),
newPassword
:
formData
.
get
(
"
newPassword
"
),
});
if
(
!
parsed
.
success
)
return
{
error
:
firstError
(
parsed
.
error
)
};
try
{
const
record
=
await
data
.
getUserById
(
user
.
id
);
if
(
!
record
||
!
(
await
verifyPassword
(
parsed
.
data
.
currentPassword
,
record
.
PasswordHash
))
)
{
return
{
error
:
"
Senha atual incorreta.
"
};
}
const
hash
=
await
hashPassword
(
parsed
.
data
.
newPassword
);
await
data
.
updateUserPassword
(
user
.
id
,
hash
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao trocar a senha.
"
,
};
}
return
{
success
:
true
};
}
// ---------- Administração (super admin) ----------
// ---------- Administração (super admin) ----------
const
resetPasswordSchema
=
z
.
object
({
const
resetPasswordSchema
=
z
.
object
({
...
...
src/lib/data.ts
View file @
e0ea8b9c
...
@@ -103,14 +103,27 @@ export async function getUserRole(userId: number): Promise<string | null> {
...
@@ -103,14 +103,27 @@ export async function getUserRole(userId: number): Promise<string | null> {
return
result
.
recordset
[
0
]?.
Role
??
null
;
return
result
.
recordset
[
0
]?.
Role
??
null
;
}
}
export
async
function
listAllUsers
():
Promise
<
UserSummary
[]
>
{
export
async
function
listAllUsers
(
query
?:
string
):
Promise
<
UserSummary
[]
>
{
const
pool
=
await
getPool
();
const
request
=
pool
.
request
();
let
where
=
""
;
if
(
query
?.
trim
())
{
request
.
input
(
"
q
"
,
sql
.
NVarChar
(
255
),
`%
${
query
.
trim
()}
%`
);
where
=
"
WHERE Name LIKE @q OR Email LIKE @q
"
;
}
const
result
=
await
request
.
query
(
`SELECT Id, Name, Email, Role, CreatedAt FROM dbo.Users
${
where
}
ORDER BY Name`
);
return
result
.
recordset
;
}
export
async
function
getUserById
(
id
:
number
):
Promise
<
UserRecord
|
null
>
{
const
pool
=
await
getPool
();
const
pool
=
await
getPool
();
const
result
=
await
pool
const
result
=
await
pool
.
request
()
.
request
()
.
query
(
.
input
(
"
id
"
,
sql
.
Int
,
id
)
"
SELECT Id, Name, Email, Role, CreatedAt FROM dbo.Users ORDER BY Name
"
.
query
(
"
SELECT * FROM dbo.Users WHERE Id = @id
"
);
);
return
result
.
recordset
[
0
]
??
null
;
return
result
.
recordset
;
}
}
export
async
function
updateUserPassword
(
export
async
function
updateUserPassword
(
...
...
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