feat: adicionar página de anotações arquivadas e componente de notificação
- Implementa a página de anotações arquivadas, que busca as notas arquivadas do usuário e as exibe utilizando o componente NotesPage. - Cria o componente NotificationBell para gerenciar e exibir notificações de pagamentos, incluindo a formatação de datas e valores monetários. O componente também apresenta um sistema de tooltip e dropdown para interação do usuário.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { anotacoes } from "@/db/schema";
|
||||
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
|
||||
import { revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import type { ActionResult } from "@/lib/actions/types";
|
||||
import { uuidSchema } from "@/lib/schemas/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
@@ -142,3 +142,45 @@ export async function deleteNoteAction(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
14
app/(dashboard)/anotacoes/arquivadas/page.tsx
Normal file
14
app/(dashboard)/anotacoes/arquivadas/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NotesPage } from "@/components/anotacoes/notes-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchArquivadasForUser } from "../data";
|
||||
|
||||
export default async function ArquivadasPage() {
|
||||
const userId = await getUserId();
|
||||
const notes = await fetchArquivadasForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<NotesPage notes={notes} isArquivadas={true} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { anotacoes, type Anotacao } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
@@ -14,12 +14,13 @@ export type NoteData = {
|
||||
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: eq(anotacoes.userId, userId),
|
||||
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
|
||||
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
|
||||
});
|
||||
|
||||
@@ -42,6 +43,38 @@ export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
|
||||
description: (note.description ?? "").trim(),
|
||||
type: (note.type ?? "nota") as "nota" | "tarefa",
|
||||
tasks,
|
||||
arquivada: note.arquivada,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import {
|
||||
RiArchiveLine,
|
||||
RiCheckLine,
|
||||
RiDeleteBin5Line,
|
||||
RiEyeLine,
|
||||
RiInboxUnarchiveLine,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
@@ -20,9 +22,18 @@ interface NoteCardProps {
|
||||
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 }: NoteCardProps) {
|
||||
export function NoteCard({
|
||||
note,
|
||||
onEdit,
|
||||
onDetails,
|
||||
onRemove,
|
||||
onArquivar,
|
||||
isArquivadas = false,
|
||||
}: NoteCardProps) {
|
||||
const { formattedDate, displayTitle } = useMemo(() => {
|
||||
const resolvedTitle = note.title.trim().length
|
||||
? note.title
|
||||
@@ -52,6 +63,16 @@ export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
|
||||
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 />,
|
||||
@@ -68,20 +89,17 @@ export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
|
||||
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
|
||||
{displayTitle}
|
||||
</h3>
|
||||
{isTask && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedCount}/{totalCount} concluídas
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
{isTask && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedCount}/{totalCount} concluídas
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTask ? (
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{tasks.slice(0, 4).map((task) => (
|
||||
<div className="flex-1 overflow-auto space-y-2 mt-2">
|
||||
{tasks.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 ${
|
||||
@@ -96,24 +114,22 @@ export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
|
||||
</div>
|
||||
<span
|
||||
className={`leading-relaxed ${
|
||||
task.completed
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
task.completed ? "text-muted-foreground" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{tasks.length > 4 && (
|
||||
{tasks.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground pl-5 py-1">
|
||||
+{tasks.length - 4}{" "}
|
||||
{tasks.length - 4 === 1 ? "tarefa" : "tarefas"}...
|
||||
+{tasks.length - 5}
|
||||
{tasks.length - 5 === 1 ? "tarefa" : "tarefas"}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex-1 overflow-auto whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed">
|
||||
<p className="flex-1 overflow-auto whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed mt-2">
|
||||
{note.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { RiCheckLine } from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "../ui/card";
|
||||
import type { Note } from "./types";
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
||||
@@ -71,9 +72,9 @@ export function NoteDetailsDialog({
|
||||
{isTask ? (
|
||||
<div className="max-h-[320px] overflow-auto space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
<Card
|
||||
key={task.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border bg-card"
|
||||
className="flex gap-3 p-3 flex-row items-center"
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
|
||||
@@ -88,14 +89,12 @@ export function NoteDetailsDialog({
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
task.completed
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
task.completed ? "text-muted-foreground" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "../ui/card";
|
||||
import type { Note, NoteFormValues, Task } from "./types";
|
||||
|
||||
type NoteDialogMode = "create" | "update";
|
||||
@@ -404,11 +405,12 @@ export function NoteDialog({
|
||||
</label>
|
||||
<div className="space-y-2 max-h-[240px] overflow-y-auto pr-1">
|
||||
{formState.tasks.map((task) => (
|
||||
<div
|
||||
<Card
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
className="flex items-center gap-3 px-3 py-2 flex-row mt-1"
|
||||
>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-green-600 data-[state=checked]:border-green-600"
|
||||
checked={task.completed}
|
||||
onCheckedChange={() => handleToggleTask(task.id)}
|
||||
disabled={isPending}
|
||||
@@ -419,7 +421,7 @@ export function NoteDialog({
|
||||
<span
|
||||
className={`flex-1 text-sm wrap-break-word ${
|
||||
task.completed
|
||||
? "line-through text-muted-foreground"
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
@@ -436,7 +438,7 @@ export function NoteDialog({
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
|
||||
import { arquivarAnotacaoAction, deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,9 +15,10 @@ import type { Note } from "./types";
|
||||
|
||||
interface NotesPageProps {
|
||||
notes: Note[];
|
||||
isArquivadas?: boolean;
|
||||
}
|
||||
|
||||
export function NotesPage({ notes }: NotesPageProps) {
|
||||
export function NotesPage({ notes, isArquivadas = false }: NotesPageProps) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
@@ -25,6 +26,8 @@ export function NotesPage({ notes }: NotesPageProps) {
|
||||
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(
|
||||
() =>
|
||||
@@ -60,6 +63,13 @@ export function NotesPage({ notes }: NotesPageProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleArquivarOpenChange = useCallback((open: boolean) => {
|
||||
setArquivarOpen(open);
|
||||
if (!open) {
|
||||
setNoteToArquivar(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditRequest = useCallback((note: Note) => {
|
||||
setNoteToEdit(note);
|
||||
setEditOpen(true);
|
||||
@@ -75,6 +85,30 @@ export function NotesPage({ notes }: NotesPageProps) {
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleArquivarRequest = useCallback((note: Note) => {
|
||||
setNoteToArquivar(note);
|
||||
setArquivarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleArquivarConfirm = useCallback(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);
|
||||
}, [noteToArquivar, isArquivadas]);
|
||||
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!noteToRemove) {
|
||||
return;
|
||||
@@ -97,29 +131,51 @@ export function NotesPage({ notes }: NotesPageProps) {
|
||||
: "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?";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-start">
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova anotação
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!isArquivadas && (
|
||||
<div className="flex justify-start">
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova anotação
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedNotes.length === 0 ? (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiTodoLine className="size-6 text-primary" />}
|
||||
title="Nenhuma anotação registrada"
|
||||
description="Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
|
||||
title={
|
||||
isArquivadas
|
||||
? "Nenhuma anotação arquivada"
|
||||
: "Nenhuma anotação registrada"
|
||||
}
|
||||
description={
|
||||
isArquivadas
|
||||
? "As anotações arquivadas aparecerão aqui."
|
||||
: "Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -131,6 +187,8 @@ export function NotesPage({ notes }: NotesPageProps) {
|
||||
onEdit={handleEditRequest}
|
||||
onDetails={handleDetailsRequest}
|
||||
onRemove={handleRemoveRequest}
|
||||
onArquivar={handleArquivarRequest}
|
||||
isArquivadas={isArquivadas}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,6 +208,21 @@ export function NotesPage({ notes }: NotesPageProps) {
|
||||
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}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Note {
|
||||
description: string;
|
||||
type: NoteType;
|
||||
tasks?: Task[];
|
||||
arquivada: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,22 +5,25 @@ import type { CalendarEvent } from "@/components/calendario/types";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
const LEGEND_ITEMS: Array<{
|
||||
type: CalendarEvent["type"];
|
||||
type?: CalendarEvent["type"];
|
||||
label: string;
|
||||
dotColor?: string;
|
||||
}> = [
|
||||
{ type: "lancamento", label: "Lançamento financeiro" },
|
||||
{ type: "lancamento", label: "Lançamentos" },
|
||||
{ type: "boleto", label: "Boleto com vencimento" },
|
||||
{ type: "cartao", label: "Vencimento de cartão" },
|
||||
{ label: "Pagamento fatura", dotColor: "bg-green-600" },
|
||||
];
|
||||
|
||||
export function CalendarLegend() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 rounded-sm border border-border/60 bg-muted/20 p-2 text-xs font-medium text-muted-foreground">
|
||||
{LEGEND_ITEMS.map((item) => {
|
||||
const style = EVENT_TYPE_STYLES[item.type];
|
||||
{LEGEND_ITEMS.map((item, index) => {
|
||||
const dotColor =
|
||||
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
|
||||
return (
|
||||
<span key={item.type} className="flex items-center gap-2">
|
||||
<span className={cn("size-3 rounded-full", style.dot)} />
|
||||
<span key={item.type || index} className="flex items-center gap-2">
|
||||
<span className={cn("size-3 rounded-full", dotColor)} />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -18,17 +18,18 @@ export const EVENT_TYPE_STYLES: Record<
|
||||
> = {
|
||||
lancamento: {
|
||||
wrapper:
|
||||
"bg-orange-100 text-orange-600 dark:bg-orange-800 dark:text-orange-50",
|
||||
"bg-orange-100 text-orange-600 dark:bg-orange-900/10 dark:text-orange-50 border-l-4 border-orange-500",
|
||||
dot: "bg-orange-600",
|
||||
},
|
||||
boleto: {
|
||||
wrapper:
|
||||
"bg-emerald-100 text-emerald-600 dark:bg-emerald-800 dark:text-emerald-50",
|
||||
dot: "bg-emerald-600",
|
||||
"bg-blue-100 text-blue-600 dark:bg-blue-900/10 dark:text-blue-50 border-l-4 border-blue-500",
|
||||
dot: "bg-blue-600",
|
||||
},
|
||||
cartao: {
|
||||
wrapper: "bg-blue-100 text-blue-600 dark:bg-blue-800 dark:text-blue-50",
|
||||
dot: "bg-blue-600",
|
||||
wrapper:
|
||||
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
|
||||
dot: "bg-violet-600",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,10 +76,28 @@ const buildEventComplement = (event: CalendarEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isPagamentoFatura = (event: CalendarEvent) => {
|
||||
return (
|
||||
event.type === "lancamento" &&
|
||||
event.lancamento.name.startsWith("Pagamento fatura -")
|
||||
);
|
||||
};
|
||||
|
||||
const getEventStyle = (event: CalendarEvent) => {
|
||||
if (isPagamentoFatura(event)) {
|
||||
return {
|
||||
wrapper:
|
||||
"bg-green-100 text-green-600 dark:bg-green-900/10 dark:text-green-50 border-l-4 border-green-500",
|
||||
dot: "bg-green-600",
|
||||
};
|
||||
}
|
||||
return eventStyles[event.type];
|
||||
};
|
||||
|
||||
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
const complement = buildEventComplement(event);
|
||||
const label = buildEventLabel(event);
|
||||
const style = eventStyles[event.type];
|
||||
const style = getEventStyle(event);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -88,7 +107,6 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className={cn("size-1.5 rounded-full", style.dot)} />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{complement ? (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
|
||||
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,12 +11,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { friendlyDate, parseLocalDateString } from "@/lib/utils/date";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
|
||||
import { parseDateKey } from "@/components/calendario/utils";
|
||||
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
|
||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||
import type { ReactNode } from "react";
|
||||
import MoneyValues from "../money-values";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
type EventModalProps = {
|
||||
open: boolean;
|
||||
@@ -23,36 +25,26 @@ type EventModalProps = {
|
||||
onCreate: (date: string) => void;
|
||||
};
|
||||
|
||||
const fullDateFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const capitalize = (value: string) =>
|
||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||
|
||||
const formatCurrency = (value: number, isReceita: boolean) => {
|
||||
const formatted = currencyFormatter.format(value ?? 0);
|
||||
return isReceita ? `+${formatted}` : formatted;
|
||||
};
|
||||
|
||||
const EventCard = ({
|
||||
children,
|
||||
type,
|
||||
isPagamentoFatura = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
type: CalendarEvent["type"];
|
||||
isPagamentoFatura?: boolean;
|
||||
}) => {
|
||||
const style = EVENT_TYPE_STYLES[type];
|
||||
const style = isPagamentoFatura
|
||||
? { dot: "bg-green-600" }
|
||||
: EVENT_TYPE_STYLES[type];
|
||||
return (
|
||||
<div className="flex gap-3 rounded-xl border border-border/60 bg-card/85 p-4">
|
||||
<Card className="flex flex-row gap-2 p-3 mb-1">
|
||||
<span
|
||||
className={cn("mt-1 size-2.5 shrink-0 rounded-full", style.dot)}
|
||||
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2">{children}</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,41 +52,38 @@ const renderLancamento = (
|
||||
event: Extract<CalendarEvent, { type: "lancamento" }>
|
||||
) => {
|
||||
const isReceita = event.lancamento.transactionType === "Receita";
|
||||
const subtitleParts = [
|
||||
event.lancamento.categoriaName,
|
||||
event.lancamento.paymentMethod,
|
||||
event.lancamento.pagadorName,
|
||||
].filter(Boolean);
|
||||
const isPagamentoFatura =
|
||||
event.lancamento.name.startsWith("Pagamento fatura -");
|
||||
|
||||
return (
|
||||
<EventCard type="lancamento">
|
||||
<EventCard type="lancamento" isPagamentoFatura={isPagamentoFatura}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">{event.lancamento.name}</span>
|
||||
{subtitleParts.length ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{subtitleParts.join(" • ")}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-sm font-semibold leading-tight ${
|
||||
isPagamentoFatura && "text-green-600 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
{event.lancamento.name}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Badge variant={"outline"}>{event.lancamento.condition}</Badge>
|
||||
<Badge variant={"outline"}>{event.lancamento.paymentMethod}</Badge>
|
||||
<Badge variant={"outline"}>{event.lancamento.categoriaName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
isReceita ? "text-emerald-600" : "text-foreground"
|
||||
"text-sm font-semibold whitespace-nowrap",
|
||||
isReceita ? "text-green-600 dark:text-green-400" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(event.lancamento.amount, isReceita)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5">
|
||||
{capitalize(event.lancamento.transactionType)}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5">
|
||||
{event.lancamento.condition}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5">
|
||||
{event.lancamento.paymentMethod}
|
||||
<MoneyValues
|
||||
showPositiveSign
|
||||
className="text-base"
|
||||
amount={event.lancamento.amount}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</EventCard>
|
||||
@@ -111,26 +100,25 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
return (
|
||||
<EventCard type="boleto">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">{event.lancamento.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Boleto{formattedDueDate ? ` - Vence em ${formattedDueDate}` : ""}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
{event.lancamento.name}
|
||||
</span>
|
||||
|
||||
{formattedDueDate && (
|
||||
<span className="text-xs text-muted-foreground leading-tight">
|
||||
Vence em {formattedDueDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{currencyFormatter.format(event.lancamento.amount ?? 0)}
|
||||
<span className="font-semibold">
|
||||
<MoneyValues amount={event.lancamento.amount} />
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
|
||||
isPaid
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
|
||||
)}
|
||||
>
|
||||
{isPaid ? "Pago" : "Pendente"}
|
||||
</span>
|
||||
</EventCard>
|
||||
);
|
||||
};
|
||||
@@ -138,25 +126,18 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
const renderCard = (event: Extract<CalendarEvent, { type: "cartao" }>) => (
|
||||
<EventCard type="cartao">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">Cartão {event.card.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Vencimento dia {event.card.dueDay}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
Vencimento Fatura - {event.card.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge variant={"outline"}>{event.card.status ?? "Fatura"}</Badge>
|
||||
</div>
|
||||
{event.card.totalDue !== null ? (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{currencyFormatter.format(event.card.totalDue)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5">
|
||||
Status: {event.card.status ?? "Indefinido"}
|
||||
</span>
|
||||
{event.card.closingDay ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5">
|
||||
Fechamento dia {event.card.closingDay}
|
||||
<span className="font-semibold">
|
||||
<MoneyValues amount={event.card.totalDue} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -177,11 +158,9 @@ const renderEvent = (event: CalendarEvent) => {
|
||||
};
|
||||
|
||||
export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!day) return "";
|
||||
const parsed = parseDateKey(day.date);
|
||||
return capitalize(fullDateFormatter.format(parsed));
|
||||
}, [day]);
|
||||
const formattedDate = !day
|
||||
? ""
|
||||
: friendlyDate(parseLocalDateString(day.date));
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!day) return;
|
||||
@@ -201,7 +180,7 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
|
||||
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
|
||||
{day?.events.length ? (
|
||||
day.events.map((event) => (
|
||||
<div key={event.id}>{renderEvent(event)}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ type DotIconProps = {
|
||||
export default function DotIcon({ bg_dot }: DotIconProps) {
|
||||
return (
|
||||
<span>
|
||||
<span className={`${bg_dot} flex h-2 w-2 rounded-full`}></span>
|
||||
<span className={`${bg_dot} flex size-2 rounded-full`}></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangelogNotification } from "@/components/changelog/changelog-notification";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
import { NotificationBell } from "@/components/notificacoes/notification-bell";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { getUnreadUpdates } from "@/lib/changelog/data";
|
||||
|
||||
@@ -135,8 +135,8 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Privacy Warning */}
|
||||
<Alert className="border-none">
|
||||
<RiAlertLine className="size-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<RiAlertLine className="size-4" color="red" />
|
||||
<AlertDescription className="text-sm text-card-foreground">
|
||||
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
|
||||
financeiros serão enviados para o provedor de IA selecionado
|
||||
(Anthropic, OpenAI, Google ou OpenRouter) para processamento.
|
||||
|
||||
@@ -309,6 +309,7 @@ const buildColumns = ({
|
||||
return (
|
||||
<MoneyValues
|
||||
amount={row.original.amount}
|
||||
showPositiveSign={isReceita}
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
isReceita
|
||||
|
||||
@@ -7,9 +7,10 @@ import { usePrivacyMode } from "./privacy-provider";
|
||||
type Props = {
|
||||
amount: number;
|
||||
className?: string;
|
||||
showPositiveSign?: boolean;
|
||||
};
|
||||
|
||||
function MoneyValues({ amount, className }: Props) {
|
||||
function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
|
||||
const { privacyMode } = usePrivacyMode();
|
||||
|
||||
const formattedValue = amount.toLocaleString("pt-BR", {
|
||||
@@ -18,6 +19,10 @@ function MoneyValues({ amount, className }: Props) {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const displayValue = showPositiveSign && amount > 0
|
||||
? `+${formattedValue}`
|
||||
: formattedValue;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -27,13 +32,13 @@ function MoneyValues({ amount, className }: Props) {
|
||||
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
|
||||
className
|
||||
)}
|
||||
aria-label={privacyMode ? "Valor oculto" : formattedValue}
|
||||
aria-label={privacyMode ? "Valor oculto" : displayValue}
|
||||
data-privacy={privacyMode ? "hidden" : undefined}
|
||||
title={
|
||||
privacyMode ? "Valor oculto - passe o mouse para revelar" : undefined
|
||||
}
|
||||
>
|
||||
{formattedValue}
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
RiArchiveLine,
|
||||
RiArrowLeftRightLine,
|
||||
RiBankCardLine,
|
||||
RiBankLine,
|
||||
@@ -142,6 +143,14 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
||||
title: "Anotações",
|
||||
url: "/anotacoes",
|
||||
icon: RiTodoLine,
|
||||
items: [
|
||||
{
|
||||
title: "Arquivadas",
|
||||
url: "/anotacoes/arquivadas",
|
||||
key: "anotacoes-arquivadas",
|
||||
icon: RiArchiveLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Insights",
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 border-transparent border drop-shadow-xs py-6 rounded-md hover:border-primary/50 transition-colors",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 border-transparent border drop-shadow-xs py-6 rounded-md hover:border-primary/50 transition-all ease-in-out duration-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -347,6 +347,7 @@ export const anotacoes = pgTable("anotacoes", {
|
||||
description: text("descricao"),
|
||||
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
|
||||
tasks: text("tasks"), // JSON stringificado com array de tarefas
|
||||
arquivada: boolean("arquivada").notNull().default(false),
|
||||
createdAt: timestamp("created_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
|
||||
@@ -30,7 +30,7 @@ export const revalidateConfig = {
|
||||
categorias: ["/categorias"],
|
||||
orcamentos: ["/orcamentos"],
|
||||
pagadores: ["/pagadores"],
|
||||
anotacoes: ["/anotacoes"],
|
||||
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
||||
lancamentos: ["/lancamentos", "/contas"],
|
||||
} as const;
|
||||
|
||||
|
||||
24
package.json
24
package.json
@@ -28,10 +28,10 @@
|
||||
"docker:rebuild": "docker compose up --build --force-recreate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.56",
|
||||
"@ai-sdk/google": "^2.0.46",
|
||||
"@ai-sdk/openai": "^2.0.86",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.3",
|
||||
"@ai-sdk/anthropic": "^3.0.1",
|
||||
"@ai-sdk/google": "^3.0.1",
|
||||
"@ai-sdk/openai": "^3.0.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
@@ -56,41 +56,41 @@
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^5.0.113",
|
||||
"ai": "^6.0.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"better-auth": "1.4.7",
|
||||
"better-auth": "1.4.9",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"motion": "^12.23.26",
|
||||
"next": "16.0.10",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "0.4.6",
|
||||
"pg": "8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "3.6.0",
|
||||
"resend": "^6.6.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.2.0"
|
||||
"zod": "4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@types/d3-array": "^3.2.2",
|
||||
"@types/node": "25.0.2",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"baseline-browser-mapping": "^2.9.7",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"depcheck": "^1.4.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
1333
pnpm-lock.yaml
generated
1333
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user