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>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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