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

Versão 1.0 Petruz Task

parent 95bad46e
"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>
);
}
"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>
);
}
"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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
"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>
);
}
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>
);
}
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>
);
}
"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>
);
}
"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>
);
}
"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>
);
}
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>
);
}
"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>
);
}
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>
);
}
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
export function ThemeProvider(props: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props} />;
}
"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>
);
}
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 }
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