refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,198 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { anotacoes } from "@/db/schema";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
import type { ActionResult } from "@/shared/lib/types/actions";
const taskSchema = z.object({
id: z.string(),
text: z.string().min(1, "O texto da tarefa não pode estar vazio."),
completed: z.boolean(),
});
const noteBaseSchema = z
.object({
title: z
.string({ message: "Informe o título da anotação." })
.trim()
.min(1, "Informe o título da anotação.")
.max(30, "O título deve ter no máximo 30 caracteres."),
description: z
.string({ message: "Informe o conteúdo da anotação." })
.trim()
.max(350, "O conteúdo deve ter no máximo 350 caracteres.")
.optional()
.default(""),
type: z.enum(["nota", "tarefa"], {
message: "O tipo deve ser 'nota' ou 'tarefa'.",
}),
tasks: z.array(taskSchema).optional().default([]),
})
.refine(
(data) => {
// Se for nota, a descrição é obrigatória
if (data.type === "nota") {
return data.description.trim().length > 0;
}
// Se for tarefa, deve ter pelo menos uma tarefa
if (data.type === "tarefa") {
return data.tasks && data.tasks.length > 0;
}
return true;
},
{
message:
"Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.",
},
);
const createNoteSchema = noteBaseSchema;
const updateNoteSchema = noteBaseSchema.and(
z.object({
id: uuidSchema("Anotação"),
}),
);
const deleteNoteSchema = z.object({
id: uuidSchema("Anotação"),
});
type NoteCreateInput = z.infer<typeof createNoteSchema>;
type NoteUpdateInput = z.infer<typeof updateNoteSchema>;
type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
export async function createNoteAction(
input: NoteCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createNoteSchema.parse(input);
await db.insert(anotacoes).values({
title: data.title,
description: data.description,
type: data.type,
tasks:
data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
userId: user.id,
});
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateNoteAction(
input: NoteUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateNoteSchema.parse(input);
const [updated] = await db
.update(anotacoes)
.set({
title: data.title,
description: data.description,
type: data.type,
tasks:
data.tasks && data.tasks.length > 0
? JSON.stringify(data.tasks)
: null,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteNoteAction(
input: NoteDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteNoteSchema.parse(input);
const [deleted] = await db
.delete(anotacoes)
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!deleted) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
const arquivarNoteSchema = z.object({
id: uuidSchema("Anotação"),
arquivada: z.boolean(),
});
type NoteArquivarInput = z.infer<typeof arquivarNoteSchema>;
export async function arquivarAnotacaoAction(
input: NoteArquivarInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = arquivarNoteSchema.parse(input);
const [updated] = await db
.update(anotacoes)
.set({
arquivada: data.arquivada,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
return {
success: true,
message: data.arquivada
? "Anotação arquivada com sucesso."
: "Anotação desarquivada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,147 @@
"use client";
import {
RiArchiveLine,
RiCheckLine,
RiDeleteBin5Line,
RiFileList2Line,
RiInboxUnarchiveLine,
RiPencilLine,
} from "@remixicon/react";
import { buildNoteDisplayTitle } from "@/features/notes/lib/formatters";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/shared/components/ui/card";
import { type Note, sortTasksByStatus } from "./types";
interface NoteCardProps {
note: Note;
onEdit?: (note: Note) => void;
onDetails?: (note: Note) => void;
onRemove?: (note: Note) => void;
onArquivar?: (note: Note) => void;
isArquivadas?: boolean;
}
export function NoteCard({
note,
onEdit,
onDetails,
onRemove,
onArquivar,
isArquivadas = false,
}: NoteCardProps) {
const displayTitle = buildNoteDisplayTitle(note.title);
const isTask = note.type === "tarefa";
const tasks = note.tasks || [];
const sortedTasks = sortTasksByStatus(tasks);
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
variant: "default" as const,
},
{
label: "detalhes",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onDetails,
variant: "default" as const,
},
{
label: isArquivadas ? "desarquivar" : "arquivar",
icon: isArquivadas ? (
<RiInboxUnarchiveLine className="size-4" aria-hidden />
) : (
<RiArchiveLine className="size-4" aria-hidden />
),
onClick: onArquivar,
variant: "default" as const,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
variant: "destructive" as const,
},
].filter((action) => typeof action.onClick === "function");
return (
<Card className="flex h-[300px] w-full flex-col gap-0">
<CardContent className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex shrink-0 items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
{displayTitle}
</h3>
</div>
{isTask && (
<Badge variant="outline" className="text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
</div>
{isTask ? (
<div className="min-h-0 flex-1 space-y-2 overflow-hidden">
{sortedTasks.slice(0, 5).map((task) => (
<div key={task.id} className="flex items-start gap-2 text-sm">
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
task.completed
? "bg-success border-success"
: "border-input"
}`}
>
{task.completed && (
<RiCheckLine className="h-3 w-3 text-background" />
)}
</div>
<span
className={`leading-relaxed ${
task.completed
? "text-muted-foreground line-through"
: "text-foreground"
}`}
>
{task.text}
</span>
</div>
))}
{tasks.length > 5 && (
<p className="text-xs text-muted-foreground pl-5 py-1">
+{tasks.length - 5}
{tasks.length - 5 === 1 ? "tarefa" : "tarefas"}...
</p>
)}
</div>
) : (
<p className="min-h-0 flex-1 overflow-hidden whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed">
{note.description}
</p>
)}
</CardContent>
{actions.length > 0 ? (
<CardFooter className="flex shrink-0 flex-wrap gap-3 px-6 pt-3 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={() => onClick?.(note)}
className={`flex items-center gap-1 font-medium transition-opacity hover:opacity-80 ${
variant === "destructive" ? "text-destructive" : "text-primary"
}`}
aria-label={`${label} anotação`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { RiCheckLine } from "@remixicon/react";
import {
buildNoteDisplayTitle,
formatNoteCreatedAtLong,
} from "@/features/notes/lib/formatters";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { type Note, sortTasksByStatus } from "./types";
interface NoteDetailsDialogProps {
note: Note | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NoteDetailsDialog({
note,
open,
onOpenChange,
}: NoteDetailsDialogProps) {
if (!note) {
return null;
}
const formattedDate = formatNoteCreatedAtLong(note.createdAt) ?? "";
const displayTitle = buildNoteDisplayTitle(note.title);
const tasks = note.tasks || [];
const sortedTasks = sortTasksByStatus(tasks);
const isTask = note.type === "tarefa";
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{displayTitle}
{isTask && (
<Badge variant="secondary" className="text-xs">
{completedCount}/{totalCount}
</Badge>
)}
</DialogTitle>
<DialogDescription>{formattedDate}</DialogDescription>
</DialogHeader>
{isTask ? (
<Card className="max-h-[320px] overflow-auto gap-2 p-2">
{sortedTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 px-3 py-1.5 space-y-1 rounded-md hover:bg-muted/50"
>
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
task.completed
? "bg-success border-success"
: "border-input"
}`}
>
{task.completed && (
<RiCheckLine className="h-4 w-4 text-primary-foreground" />
)}
</div>
<span
className={`text-sm ${
task.completed
? "text-muted-foreground line-through"
: "text-foreground"
}`}
>
{task.text}
</span>
</div>
))}
</Card>
) : (
<div className="max-h-[320px] overflow-auto whitespace-pre-line wrap-break-word text-sm text-foreground">
{note.description}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,413 @@
"use client";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { createNoteAction, updateNoteAction } from "@/features/notes/actions";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
import { Textarea } from "@/shared/components/ui/textarea";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { useFormState } from "@/shared/hooks/use-form-state";
import {
type Note,
type NoteFormValues,
sortTasksByStatus,
type Task,
} from "./types";
type NoteDialogMode = "create" | "update";
interface NoteDialogProps {
mode: NoteDialogMode;
trigger?: ReactNode;
note?: Note;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const MAX_TITLE = 30;
const MAX_DESC = 350;
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
const buildInitialValues = (note?: Note): NoteFormValues => ({
title: note?.title ?? "",
description: note?.description ?? "",
type: note?.type ?? "nota",
tasks: note?.tasks ?? [],
});
const generateTaskId = () => {
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
export function NoteDialog({
mode,
trigger,
note,
open,
onOpenChange,
}: NoteDialogProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [newTaskText, setNewTaskText] = useState("");
const titleRef = useRef<HTMLInputElement>(null);
const descRef = useRef<HTMLTextAreaElement>(null);
const newTaskRef = useRef<HTMLInputElement>(null);
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const initialState = buildInitialValues(note);
const { formState, resetForm, updateField } =
useFormState<NoteFormValues>(initialState);
useEffect(() => {
if (dialogOpen) {
resetForm(buildInitialValues(note));
setErrorMessage(null);
setNewTaskText("");
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, resetForm]);
const dialogTitle = mode === "create" ? "Nova anotação" : "Editar anotação";
const description =
mode === "create"
? "Crie uma nota simples ou uma lista de tarefas."
: "Altere o conteúdo desta anotação.";
const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
const titleCount = formState.title.length;
const descCount = formState.description.length;
const isNote = formState.type === "nota";
const sortedTasks = useMemo(
() => sortTasksByStatus(formState.tasks || []),
[formState.tasks],
);
const onlySpaces =
normalize(formState.title).length === 0 ||
(isNote && formState.description.trim().length === 0) ||
(!isNote && (!formState.tasks || formState.tasks.length === 0));
const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC;
const unchanged =
mode === "update" &&
normalize(formState.title) === normalize(note?.title ?? "") &&
formState.description.trim() === (note?.description ?? "").trim() &&
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const handleOpenChange = (v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
};
const handleAddTask = () => {
const text = normalize(newTaskText);
if (!text) return;
const newTask: Task = {
id: generateTaskId(),
text,
completed: false,
};
updateField("tasks", [...(formState.tasks || []), newTask]);
setNewTaskText("");
requestAnimationFrame(() => newTaskRef.current?.focus());
};
const handleRemoveTask = (taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
};
const handleToggleTask = (taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t,
),
);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const payload = {
title: normalize(formState.title),
description: formState.description.trim(),
type: formState.type,
tasks: formState.tasks,
};
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
startTransition(async () => {
let result: { success: boolean; message?: string; error?: string };
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
result = await updateNoteAction({ id: note.id, ...payload });
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
};
return (
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
className="space-y-3 -mx-6 max-h-[80vh] overflow-y-auto px-6 pb-1"
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
noValidate
>
{mode === "create" && (
<div className="space-y-1">
<Label>Tipo de anotação</Label>
<RadioGroup
value={formState.type}
onValueChange={(value) =>
updateField("type", value as "nota" | "tarefa")
}
disabled={isPending}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="nota" id="tipo-nota" />
<Label
htmlFor="tipo-nota"
className="font-normal cursor-pointer"
>
Nota
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="tarefa" id="tipo-tarefa" />
<Label
htmlFor="tipo-tarefa"
className="font-normal cursor-pointer"
>
Tarefas
</Label>
</div>
</RadioGroup>
</div>
)}
<div className="space-y-1">
<Label htmlFor="note-title">Título</Label>
<Input
id="note-title"
ref={titleRef}
value={formState.title}
onChange={(e) => updateField("title", e.target.value)}
placeholder={
isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana"
}
maxLength={MAX_TITLE}
disabled={isPending}
required
/>
</div>
{isNote && (
<div className="space-y-1">
<Label htmlFor="note-description">Conteúdo</Label>
<Textarea
id="note-description"
className="field-sizing-fixed"
ref={descRef}
value={formState.description}
onChange={(e) => updateField("description", e.target.value)}
placeholder="Detalhe sua anotação..."
rows={5}
maxLength={MAX_DESC}
disabled={isPending}
required
/>
</div>
)}
{!isNote && (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="new-task-input">Adicionar tarefa</Label>
<div className="flex gap-2">
<Input
id="new-task-input"
ref={newTaskRef}
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
placeholder="Nova tarefa..."
disabled={isPending}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTask();
}
}}
/>
<Button
type="button"
variant="outline"
onClick={handleAddTask}
disabled={isPending || !normalize(newTaskText)}
className="shrink-0"
>
<RiAddLine className="h-4 w-4" />
</Button>
</div>
</div>
{sortedTasks.length > 0 && (
<div className="space-y-1 max-h-[300px] overflow-y-auto pr-1 mt-4 rounded-md p-2 bg-card ">
{sortedTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 px-3 py-1.5 rounded-md hover:bg-muted/50"
>
<Checkbox
className="data-[state=checked]:bg-success data-[state=checked]:border-success"
checked={task.completed}
onCheckedChange={() => handleToggleTask(task.id)}
disabled={isPending}
aria-label={`Marcar "${task.text}" como ${
task.completed ? "não concluída" : "concluída"
}`}
/>
<span
className={`flex-1 text-sm wrap-break-word ${
task.completed
? "text-muted-foreground line-through"
: "text-foreground"
}`}
>
{task.text}
</span>
<button
type="button"
onClick={() => handleRemoveTask(task.id)}
disabled={isPending}
className="shrink-0 text-muted-foreground/50 hover:text-destructive transition-colors"
aria-label={`Remover "${task.text}"`}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
{errorMessage ? (
<p className="text-sm text-destructive" role="alert">
{errorMessage}
</p>
) : null}
</form>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={disableSubmit}
onClick={(e) => {
const form = (
e.currentTarget.closest("[role=dialog]") as HTMLElement
)?.querySelector("form");
if (form) {
e.preventDefault();
form.requestSubmit();
}
}}
>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { RiAddCircleLine, RiTodoLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
arquivarAnotacaoAction,
deleteNoteAction,
} from "@/features/notes/actions";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { EmptyState } from "@/shared/components/empty-state";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { NoteCard } from "./note-card";
import { NoteDetailsDialog } from "./note-details-dialog";
import { NoteDialog } from "./note-dialog";
import type { Note } from "./types";
interface NotesPageProps {
notes: Note[];
archivedNotes: Note[];
}
export function NotesPage({ notes, archivedNotes }: NotesPageProps) {
const [activeTab, setActiveTab] = useState("ativas");
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [noteToRemove, setNoteToRemove] = useState<Note | null>(null);
const [arquivarOpen, setArquivarOpen] = useState(false);
const [noteToArquivar, setNoteToArquivar] = useState<Note | null>(null);
const sortedNotes = useMemo(
() =>
[...notes].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
[notes],
);
const sortedArchivedNotes = useMemo(
() =>
[...archivedNotes].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
[archivedNotes],
);
const isArquivadas = activeTab === "arquivadas";
const handleCreateOpenChange = (open: boolean) => {
setCreateOpen(open);
};
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
};
const handleDetailsOpenChange = (open: boolean) => {
setDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
};
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setNoteToRemove(null);
}
};
const handleArquivarOpenChange = (open: boolean) => {
setArquivarOpen(open);
if (!open) {
setNoteToArquivar(null);
}
};
const handleEditRequest = (note: Note) => {
setNoteToEdit(note);
setEditOpen(true);
};
const handleDetailsRequest = (note: Note) => {
setNoteDetails(note);
setDetailsOpen(true);
};
const handleRemoveRequest = (note: Note) => {
setNoteToRemove(note);
setRemoveOpen(true);
};
const handleArquivarRequest = (note: Note) => {
setNoteToArquivar(note);
setArquivarOpen(true);
};
const handleArquivarConfirm = async () => {
if (!noteToArquivar) {
return;
}
const result = await arquivarAnotacaoAction({
id: noteToArquivar.id,
arquivada: !isArquivadas,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleRemoveConfirm = async () => {
if (!noteToRemove) {
return;
}
const result = await deleteNoteAction({ id: noteToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const removeTitle = noteToRemove
? noteToRemove.title.trim().length
? `Remover anotação "${noteToRemove.title}"?`
: "Remover anotação?"
: "Remover anotação?";
const arquivarTitle = noteToArquivar
? noteToArquivar.title.trim().length
? isArquivadas
? `Desarquivar anotação "${noteToArquivar.title}"?`
: `Arquivar anotação "${noteToArquivar.title}"?`
: isArquivadas
? "Desarquivar anotação?"
: "Arquivar anotação?"
: isArquivadas
? "Desarquivar anotação?"
: "Arquivar anotação?";
const renderNoteList = (list: Note[], isArchived: boolean) => {
if (list.length === 0) {
return (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiTodoLine className="size-6 text-primary" />}
title={
isArchived
? "Nenhuma anotação arquivada"
: "Nenhuma anotação registrada"
}
description={
isArchived
? "As anotações arquivadas aparecerão aqui."
: "Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
}
/>
</Card>
);
}
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{list.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={handleEditRequest}
onDetails={handleDetailsRequest}
onRemove={handleRemoveRequest}
onArquivar={handleArquivarRequest}
isArquivadas={isArchived}
/>
))}
</div>
);
};
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex">
<NoteDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateOpenChange}
trigger={
<Button className="w-full sm:w-auto">
<RiAddCircleLine className="size-4" />
Nova anotação
</Button>
}
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList>
<TabsTrigger value="ativas">Ativas</TabsTrigger>
<TabsTrigger value="arquivadas">Arquivadas</TabsTrigger>
</TabsList>
<TabsContent value="ativas" className="mt-4">
{renderNoteList(sortedNotes, false)}
</TabsContent>
<TabsContent value="arquivadas" className="mt-4">
{renderNoteList(sortedArchivedNotes, true)}
</TabsContent>
</Tabs>
</div>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={editOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
<ConfirmActionDialog
open={arquivarOpen}
onOpenChange={handleArquivarOpenChange}
title={arquivarTitle}
description={
isArquivadas
? "A anotação será movida de volta para a lista principal."
: "A anotação será movida para arquivadas."
}
confirmLabel={isArquivadas ? "Desarquivar" : "Arquivar"}
confirmVariant="default"
pendingLabel={isArquivadas ? "Desarquivando..." : "Arquivando..."}
onConfirm={handleArquivarConfirm}
/>
<ConfirmActionDialog
open={removeOpen}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Essa ação não pode ser desfeita."
confirmLabel="Remover"
confirmVariant="destructive"
pendingLabel="Removendo..."
onConfirm={handleRemoveConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,29 @@
export type NoteType = "nota" | "tarefa";
export interface Task {
id: string;
text: string;
completed: boolean;
}
export interface Note {
id: string;
title: string;
description: string;
type: NoteType;
tasks?: Task[];
arquivada: boolean;
createdAt: string;
}
export interface NoteFormValues {
title: string;
description: string;
type: NoteType;
tasks?: Task[];
}
/** Ordena tarefas: pendentes primeiro, concluídas por último. */
export function sortTasksByStatus(tasks: Task[]): Task[] {
return [...tasks].sort((a, b) => Number(a.completed) - Number(b.completed));
}

View File

@@ -0,0 +1,51 @@
type NoteTasksSummaryInput = {
type: string;
tasks?: Array<{ completed: boolean }> | null;
};
const NOTE_CREATED_AT_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
});
const NOTE_CREATED_AT_LONG_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
});
const parseNoteDate = (value: string | Date | null | undefined) => {
if (!value) {
return null;
}
const parsed = value instanceof Date ? value : new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const buildNoteDisplayTitle = (value: string | null | undefined) => {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : "Anotação sem título";
};
export const getNoteTasksSummary = (note: NoteTasksSummaryInput) => {
if (note.type !== "tarefa") {
return "Nota";
}
const tasks = note.tasks ?? [];
const completed = tasks.filter((task) => task.completed).length;
return `${completed}/${tasks.length} concluídas`;
};
export const formatNoteCreatedAt = (
value: string | Date | null | undefined,
) => {
const parsed = parseNoteDate(value);
return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : null;
};
export const formatNoteCreatedAtLong = (
value: string | Date | null | undefined,
) => {
const parsed = parseNoteDate(value);
return parsed ? NOTE_CREATED_AT_LONG_FORMATTER.format(parsed) : null;
};

View File

@@ -0,0 +1,100 @@
import { and, eq } from "drizzle-orm";
import { type Anotacao, anotacoes } from "@/db/schema";
import { db } from "@/shared/lib/db";
export type Task = {
id: string;
text: string;
completed: boolean;
};
export type NoteData = {
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: Task[];
arquivada: boolean;
createdAt: string;
};
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}
export async function fetchAllNotesForUser(
userId: string,
): Promise<{ activeNotes: NoteData[]; archivedNotes: NoteData[] }> {
const [activeNotes, archivedNotes] = await Promise.all([
fetchNotesForUser(userId),
fetchArquivadasForUser(userId),
]);
return { activeNotes, archivedNotes };
}
export async function fetchArquivadasForUser(
userId: string,
): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}