Commit 7264ecf5 authored by Paulo Avila's avatar Paulo Avila
Browse files

alteração na exibição das pastas

parent 47104fa0
......@@ -13,7 +13,7 @@ import {
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { DueBadge } from "@/components/tasks/due-badge";
import { STATUS_CHIPS } from "@/components/tasks/task-by-person";
import { STATUS_CHIPS } from "@/components/tasks/task-utils";
import { formatDate } from "@/lib/dates";
import { cn } from "@/lib/utils";
......
......@@ -40,7 +40,7 @@ export function Sidebar({
return (
<aside
className={cn(
"hidden shrink-0 flex-col bg-sidebar text-sidebar-foreground transition-[width] duration-200 md:flex",
"sticky top-0 hidden h-screen shrink-0 flex-col bg-sidebar text-sidebar-foreground transition-[width] duration-200 md:flex",
collapsed ? "w-16" : "w-64"
)}
>
......
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
CheckCircle2,
Circle,
Eye,
LoaderCircle,
Plus,
UserRound,
} from "lucide-react";
import { ChevronRight, Plus, Search, 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 { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { TaskDialog } from "./task-dialog";
import { taskPercent } from "./task-progress";
import { initials, STATUS_CHIPS } from "./task-utils";
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-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", chipBg: "bg-blue-500/10" },
{ 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", chipBg: "bg-green-500/10" },
] as const;
export { STATUS_CHIPS };
interface PersonGroup {
key: string;
......@@ -39,8 +23,9 @@ interface PersonGroup {
}
/**
* Pastas por membro em grade: clicar no card abre a página da pessoa
* com apenas as tarefas dela.
* Lista de pastas por membro: busca por nome no topo e linhas compactas
* (mais pessoas visíveis de uma vez). Clicar na linha abre a página da
* pessoa com apenas as tarefas dela.
*/
export function TaskByPerson({
tasks,
......@@ -56,145 +41,174 @@ export function TaskByPerson({
isAdmin: boolean;
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,
});
}
const [search, setSearch] = useState("");
const groups: PersonGroup[] = useMemo(() => {
const list: 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) {
list.push({
key: "unassigned",
name: null,
userId: null,
tasks: unassigned,
});
}
return list;
}, [members, tasks]);
const term = search.trim().toLowerCase();
const visibleGroups = term
? groups.filter((g) => (g.name ?? "sem responsável").toLowerCase().includes(term))
: groups;
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-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 isOwn = group.userId === currentUserId;
const canQuickCreate =
group.userId !== null && (isAdmin || isOwn);
<div className="space-y-3">
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar pessoa..."
className="pl-9"
/>
</div>
<div className="overflow-hidden rounded-xl border bg-card">
{visibleGroups.length === 0 ? (
<p className="p-8 text-center text-sm text-muted-foreground">
Nenhuma pessoa encontrada para “{search}”.
</p>
) : (
<ul className="divide-y">
{visibleGroups.map((group) => {
const total = group.tasks.length;
const done = group.tasks.filter((t) => t.Status === "done").length;
// Média do % das tarefas: reflete progresso parcial.
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 isOwn = group.userId === currentUserId;
const canQuickCreate =
group.userId !== null && (isAdmin || isOwn);
return (
<div
key={group.key}
className="group relative rounded-xl border bg-card transition hover:border-primary/50 hover:shadow-md"
>
<Link
href={`/workspaces/${workspaceId}/membro/${group.userId ?? 0}`}
className="block space-y-4 p-5"
>
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 ring-2 ring-primary/15">
<AvatarFallback
className={
group.name
? "bg-primary text-primary-foreground text-sm font-semibold"
: "bg-muted text-muted-foreground"
}
return (
<li key={group.key} className="group relative">
<Link
href={`/workspaces/${workspaceId}/membro/${group.userId ?? 0}`}
className="flex items-center gap-3 py-3 pl-4 pr-24 transition-colors hover:bg-muted/40"
>
{group.name ? (
initials(group.name)
) : (
<UserRound className="h-5 w-5" />
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-semibold leading-tight">
{group.name ?? "Sem responsável"}
{isOwn && (
<span className="ml-1.5 text-xs font-normal text-primary">
(você)
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{total === 0
? "Nenhuma tarefa"
: `${total} tarefa${total > 1 ? "s" : ""}`}
</p>
</div>
</div>
<Avatar className="h-9 w-9 shrink-0">
<AvatarFallback
className={
group.name
? "bg-primary text-primary-foreground text-xs font-semibold"
: "bg-muted text-muted-foreground"
}
>
{group.name ? (
initials(group.name)
) : (
<UserRound className="h-4 w-4" />
)}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-1.5">
{STATUS_CHIPS.map((chip) => {
const count = group.tasks.filter(
(t) => t.Status === chip.value
).length;
if (count === 0) return null;
const Icon = chip.icon;
return (
<span
key={chip.value}
title={chip.label}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
chip.chipBg,
chip.className
)}
>
<Icon className="h-3 w-3" />
{count}
</span>
);
})}
{total === 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
Pasta vazia
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{group.name ?? "Sem responsável"}
{isOwn && (
<span className="ml-1.5 text-xs font-normal text-primary">
(você)
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{total === 0
? "Nenhuma tarefa"
: `${done}/${total} concluída${total > 1 ? "s" : ""}`}
</p>
</div>
<div className="flex items-center gap-2">
<Progress value={percent} className="h-1.5" />
<span className="text-xs font-semibold text-muted-foreground">
{percent}%
</span>
</div>
</Link>
{/* Contadores por status — escondem em telas estreitas */}
<div className="hidden items-center gap-3 lg: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}
title={chip.label}
className={cn(
"flex w-9 items-center justify-center gap-1 text-xs tabular-nums",
count > 0 ? chip.className : "text-muted-foreground/30"
)}
>
<Icon className="h-3.5 w-3.5" />
{count}
</span>
);
})}
</div>
{canQuickCreate && (
<div className="absolute right-3 top-3">
<TaskDialog
workspaceId={workspaceId}
members={members}
defaultAssigneeId={group.userId ?? undefined}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-opacity md:opacity-0 md:group-hover:opacity-100"
title={`Nova tarefa para ${group.name}`}
>
<Plus className="h-4 w-4" />
</Button>
}
/>
</div>
)}
</div>
);
})}
{/* Progresso */}
<div className="hidden w-40 items-center gap-2 sm:flex">
<Progress value={percent} className="h-1.5" />
<span className="w-9 shrink-0 text-right text-xs font-semibold tabular-nums text-muted-foreground">
{percent}%
</span>
</div>
</Link>
{/* Ações à direita, fora do Link para HTML válido */}
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
{canQuickCreate && (
<TaskDialog
workspaceId={workspaceId}
members={members}
defaultAssigneeId={group.userId ?? undefined}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-opacity md:opacity-0 md:group-hover:opacity-100"
title={`Nova tarefa para ${group.name ?? "ninguém"}`}
>
<Plus className="h-4 w-4" />
</Button>
}
/>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
......@@ -3,6 +3,7 @@
import { Fragment, useState } from "react";
import { CheckCircle2, ChevronRight, Circle, Pencil } from "lucide-react";
import type { Subtask, Task, WorkspaceMember } from "@/lib/data";
import { TASK_STATUSES } from "@/lib/constants";
import { formatDate } from "@/lib/dates";
import { canEditTask } from "@/lib/permissions";
import { initials, toFormValues } from "./task-utils";
......@@ -45,6 +46,7 @@ export function TaskTable({
isAdmin: boolean;
}) {
const [expanded, setExpanded] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all");
function toggleExpanded(taskId: number) {
setExpanded((prev) =>
......@@ -62,8 +64,51 @@ export function TaskTable({
);
}
const filteredTasks =
statusFilter === "all"
? tasks
: tasks.filter((t) => t.Status === statusFilter);
const filterOptions = [
{ value: "all", label: "Todas", count: tasks.length },
...TASK_STATUSES.map((s) => ({
value: s.value,
label: s.label,
count: tasks.filter((t) => t.Status === s.value).length,
})),
];
return (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{filterOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setStatusFilter(option.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
statusFilter === option.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted"
)}
>
{option.label}
<span
className={cn(
"rounded-full px-1.5 text-[10px]",
statusFilter === option.value
? "bg-primary-foreground/20"
: "bg-muted"
)}
>
{option.count}
</span>
</button>
))}
</div>
<div className="overflow-hidden rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/40 hover:bg-muted/40">
......@@ -86,7 +131,7 @@ export function TaskTable({
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => {
{filteredTasks.map((task) => {
const editable = canEditTask(task, currentUserId, isAdmin);
const subtasks = subtasksByTask[task.Id] ?? [];
const hasSubtasks = subtasks.length > 0;
......@@ -99,8 +144,8 @@ export function TaskTable({
isOpen ? "border-0 bg-muted/20" : "last:border-0"
)}
>
<TableCell className="max-w-[300px] py-3">
<div className="flex items-center gap-1.5">
<TableCell className="min-w-[260px] py-3 align-top">
<div className="flex items-start gap-1.5">
{hasSubtasks ? (
<button
type="button"
......@@ -110,7 +155,7 @@ export function TaskTable({
? "Recolher subtarefas"
: "Expandir subtarefas"
}
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"
className="mt-0.5 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"
>
<ChevronRight
className={cn(
......@@ -124,14 +169,20 @@ export function TaskTable({
) : (
<span className="w-5 shrink-0" />
)}
<p className="truncate font-medium">{task.Title}</p>
<PriorityBadge priority={task.Priority} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium leading-snug">
{task.Title}
</span>
<PriorityBadge priority={task.Priority} />
</div>
{task.Description && (
<p className="mt-0.5 text-xs text-muted-foreground">
{task.Description}
</p>
)}
</div>
</div>
{task.Description && (
<p className="ml-6 mt-0.5 truncate text-xs text-muted-foreground">
{task.Description}
</p>
)}
</TableCell>
<TableCell className="py-3">
{editable ? (
......@@ -257,6 +308,12 @@ export function TaskTable({
})}
</TableBody>
</Table>
{filteredTasks.length === 0 && (
<p className="p-8 text-center text-sm text-muted-foreground">
Nenhuma tarefa com este status.
</p>
)}
</div>
</div>
);
}
// Utilitários compartilhados entre componentes de servidor e de cliente —
// este módulo não pode ter "use client" nem importar módulos cliente.
import { CheckCircle2, Circle, Eye, LoaderCircle } from "lucide-react";
import type { Task } from "@/lib/data";
import { toISODate } from "@/lib/dates";
import type { TaskFormValues } from "./task-dialog";
export const STATUS_CHIPS = [
{ 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", chipBg: "bg-blue-500/10" },
{ 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", chipBg: "bg-green-500/10" },
] as const;
export function toFormValues(task: Task): TaskFormValues {
return {
id: task.Id,
......
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