Commit e0ea8b9c authored by Paulo Avila's avatar Paulo Avila
Browse files

criação de mecanica para troca de senha, e melhoria na exibição de subtasks

parent f85352f0
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:{" "}
......
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>
);
}
"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>
);
}
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" />
......
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-500 dark:text-slate-400", text: "text-slate-600 dark:text-slate-300",
bar: "bg-slate-400", iconBg: "bg-slate-500/10 text-slate-500",
}, },
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>
); );
} }
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-500 dark:text-slate-400" }, { value: "todo", label: "A Fazer", icon: Circle, className: "text-slate-600 dark:text-slate-300", chipBg: "bg-slate-500/10" },
{ 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 na pasta abre a página da pessoa * Pastas por membro em grade: clicar no 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-lg border bg-card pr-2 transition hover:border-primary/60 hover:shadow-sm" className="group relative rounded-xl border bg-card transition hover:border-primary/50 hover:shadow-md"
> >
<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 py-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>
); );
......
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-lg border border-dashed p-12 text-center text-sm text-muted-foreground"> <div className="rounded-xl 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-lg border"> <div className="overflow-hidden rounded-xl 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>
......
...@@ -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({
......
...@@ -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(
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment