mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(dashboard): persist notification center state
This commit is contained in:
2
drizzle/0023_next_blue_blade.sql
Normal file
2
drizzle/0023_next_blue_blade.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||||
14
drizzle/0024_glorious_mindworm.sql
Normal file
14
drizzle/0024_glorious_mindworm.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "dashboard_notification_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"notification_key" text NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");
|
||||||
@@ -3,33 +3,14 @@ import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
|||||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||||
import { getUserSession } from "@/shared/lib/auth/server";
|
import { getUserSession } from "@/shared/lib/auth/server";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
searchParams,
|
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}>) {
|
}>) {
|
||||||
const session = await getUserSession();
|
const session = await getUserSession();
|
||||||
|
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||||
// Buscar notificações para o período atual
|
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
|
||||||
const periodoParam = resolvedSearchParams?.periodo;
|
|
||||||
const singlePeriodoParam =
|
|
||||||
typeof periodoParam === "string"
|
|
||||||
? periodoParam
|
|
||||||
: Array.isArray(periodoParam)
|
|
||||||
? periodoParam[0]
|
|
||||||
: null;
|
|
||||||
const { period: currentPeriod } = parsePeriodParam(
|
|
||||||
singlePeriodoParam ?? null,
|
|
||||||
);
|
|
||||||
const navbarData = await fetchDashboardNavbarData(
|
|
||||||
session.user.id,
|
|
||||||
currentPeriod,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
@@ -40,7 +21,7 @@ export default async function DashboardLayout({
|
|||||||
notificationsSnapshot={navbarData.notificationsSnapshot}
|
notificationsSnapshot={navbarData.notificationsSnapshot}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-1 flex-col pt-16">
|
<div className="relative flex flex-1 flex-col pt-16">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
|
||||||
<DotPattern
|
<DotPattern
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
|||||||
statementNoteAsColumn: boolean("extrato_note_as_column")
|
statementNoteAsColumn: boolean("extrato_note_as_column")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
systemFont: text("system_font").notNull().default("ai-sans"),
|
|
||||||
moneyFont: text("money_font").notNull().default("ai-sans"),
|
|
||||||
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
||||||
string[] | null
|
string[] | null
|
||||||
>(),
|
>(),
|
||||||
@@ -527,6 +525,40 @@ export const inboxItems = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const dashboardNotificationStates = pgTable(
|
||||||
|
"dashboard_notification_states",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
notificationKey: text("notification_key").notNull(),
|
||||||
|
fingerprint: text("fingerprint").notNull(),
|
||||||
|
readAt: timestamp("read_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
archivedAt: timestamp("archived_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdNotificationKeyUnique: uniqueIndex(
|
||||||
|
"dashboard_notification_states_user_id_key_unique",
|
||||||
|
).on(table.userId, table.notificationKey),
|
||||||
|
userIdArchivedAtIdx: index(
|
||||||
|
"dashboard_notification_states_user_id_archived_idx",
|
||||||
|
).on(table.userId, table.archivedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const installmentAnticipations = pgTable(
|
export const installmentAnticipations = pgTable(
|
||||||
"antecipacoes_parcelas",
|
"antecipacoes_parcelas",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { payers } from "@/db/schema";
|
|||||||
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
import { getBusinessDateString } from "@/shared/utils/date";
|
||||||
import {
|
import {
|
||||||
type DashboardNotificationsSnapshot,
|
type DashboardNotificationsSnapshot,
|
||||||
fetchDashboardNotifications,
|
fetchDashboardNotifications,
|
||||||
@@ -36,8 +37,8 @@ async function fetchAdminPayerAvatarUrl(
|
|||||||
|
|
||||||
async function fetchDashboardNavbarDataInternal(
|
async function fetchDashboardNavbarDataInternal(
|
||||||
userId: string,
|
userId: string,
|
||||||
currentPeriod: string,
|
|
||||||
): Promise<DashboardNavbarData> {
|
): Promise<DashboardNavbarData> {
|
||||||
|
const currentPeriod = getBusinessDateString().slice(0, 7);
|
||||||
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
|
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchAdminPayerAvatarUrl(userId),
|
fetchAdminPayerAvatarUrl(userId),
|
||||||
@@ -52,12 +53,11 @@ async function fetchDashboardNavbarDataInternal(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDashboardNavbarData(
|
export function fetchDashboardNavbarData(userId: string) {
|
||||||
userId: string,
|
const currentPeriod = getBusinessDateString().slice(0, 7);
|
||||||
currentPeriod: string,
|
|
||||||
) {
|
|
||||||
return unstable_cache(
|
return unstable_cache(
|
||||||
() => fetchDashboardNavbarDataInternal(userId, currentPeriod),
|
() => fetchDashboardNavbarDataInternal(userId),
|
||||||
[`dashboard-navbar-${userId}-${currentPeriod}`],
|
[`dashboard-navbar-${userId}-${currentPeriod}`],
|
||||||
{
|
{
|
||||||
tags: [`dashboard-${userId}`],
|
tags: [`dashboard-${userId}`],
|
||||||
|
|||||||
252
src/features/dashboard/notifications-actions.ts
Normal file
252
src/features/dashboard/notifications-actions.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { dashboardNotificationStates } 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 { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||||
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
|
|
||||||
|
const notificationStateSchema = z.object({
|
||||||
|
notificationKey: z
|
||||||
|
.string({ message: "Chave da notificação inválida." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Chave da notificação inválida."),
|
||||||
|
fingerprint: z
|
||||||
|
.string({ message: "Fingerprint da notificação inválido." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Fingerprint da notificação inválido."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DashboardNotificationStateInput = z.infer<typeof notificationStateSchema>;
|
||||||
|
|
||||||
|
function revalidateNotifications(userId: string) {
|
||||||
|
revalidateForEntity("notifications", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExistingNotificationState(
|
||||||
|
userId: string,
|
||||||
|
notificationKey: string,
|
||||||
|
) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({
|
||||||
|
id: dashboardNotificationStates.id,
|
||||||
|
archivedAt: dashboardNotificationStates.archivedAt,
|
||||||
|
})
|
||||||
|
.from(dashboardNotificationStates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, userId),
|
||||||
|
eq(dashboardNotificationStates.notificationKey, notificationKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return existing ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDashboardNotificationAsReadAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await getExistingNotificationState(
|
||||||
|
user.id,
|
||||||
|
data.notificationKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(dashboardNotificationStates)
|
||||||
|
.set({
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: existing.archivedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, user.id),
|
||||||
|
eq(
|
||||||
|
dashboardNotificationStates.notificationKey,
|
||||||
|
data.notificationKey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.insert(dashboardNotificationStates).values({
|
||||||
|
userId: user.id,
|
||||||
|
notificationKey: data.notificationKey,
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: null,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação marcada como lida." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDashboardNotificationAsUnreadAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await getExistingNotificationState(
|
||||||
|
user.id,
|
||||||
|
data.notificationKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: true, message: "Notificação marcada como não lida." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(dashboardNotificationStates)
|
||||||
|
.set({
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: existing.archivedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, user.id),
|
||||||
|
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação marcada como não lida." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveDashboardNotificationAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(dashboardNotificationStates)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
notificationKey: data.notificationKey,
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
dashboardNotificationStates.userId,
|
||||||
|
dashboardNotificationStates.notificationKey,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação arquivada." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unarchiveDashboardNotificationAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await getExistingNotificationState(
|
||||||
|
user.id,
|
||||||
|
data.notificationKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Notificação não encontrada para restaurar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(dashboardNotificationStates)
|
||||||
|
.set({
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
archivedAt: null,
|
||||||
|
readAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, user.id),
|
||||||
|
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação restaurada." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq, lt, ne, sql } from "drizzle-orm";
|
import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
budgets,
|
budgets,
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
|
dashboardNotificationStates,
|
||||||
invoices,
|
invoices,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
|
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
import type {
|
||||||
|
BudgetNotification,
|
||||||
|
DashboardNotification,
|
||||||
|
DashboardNotificationsSnapshot,
|
||||||
|
} from "@/shared/lib/types/notifications";
|
||||||
import {
|
import {
|
||||||
buildDateOnlyStringFromPeriodDay,
|
buildDateOnlyStringFromPeriodDay,
|
||||||
getBusinessDateString,
|
getBusinessDateString,
|
||||||
@@ -19,41 +27,65 @@ import {
|
|||||||
toDateOnlyString,
|
toDateOnlyString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
|
|
||||||
export type NotificationType = "overdue" | "due_soon";
|
export type {
|
||||||
|
BudgetNotification,
|
||||||
export type DashboardNotification = {
|
BudgetStatus,
|
||||||
id: string;
|
DashboardNotification,
|
||||||
type: "invoice" | "boleto";
|
DashboardNotificationsSnapshot,
|
||||||
name: string;
|
NotificationType,
|
||||||
dueDate: string;
|
} from "@/shared/lib/types/notifications";
|
||||||
status: NotificationType;
|
|
||||||
amount: number;
|
|
||||||
period?: string;
|
|
||||||
showAmount: boolean;
|
|
||||||
cardLogo?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BudgetStatus = "exceeded" | "critical";
|
|
||||||
|
|
||||||
export type BudgetNotification = {
|
|
||||||
id: string;
|
|
||||||
categoryName: string;
|
|
||||||
budgetAmount: number;
|
|
||||||
spentAmount: number;
|
|
||||||
usedPercentage: number;
|
|
||||||
status: BudgetStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DashboardNotificationsSnapshot = {
|
|
||||||
notifications: DashboardNotification[];
|
|
||||||
totalCount: number;
|
|
||||||
budgetNotifications: BudgetNotification[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||||
|
|
||||||
|
type PersistedNotificationState = {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
readAt: Date | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInvoiceNotificationKey = (cardId: string, period: string) =>
|
||||||
|
`invoice-${cardId}-${period}`;
|
||||||
|
|
||||||
|
const buildBoletoNotificationKey = (transactionId: string) =>
|
||||||
|
`boleto-${transactionId}`;
|
||||||
|
|
||||||
|
const buildBudgetNotificationKey = (
|
||||||
|
categoryId: string | null,
|
||||||
|
budgetId: string,
|
||||||
|
period: string,
|
||||||
|
) => (categoryId ? `budget-${categoryId}-${period}` : `budget-${budgetId}`);
|
||||||
|
|
||||||
|
function mergeNotificationState<
|
||||||
|
T extends {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
isRead: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
readAt: Date | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
},
|
||||||
|
>(items: T[], stateByKey: Map<string, PersistedNotificationState>): T[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
const persisted = stateByKey.get(item.notificationKey);
|
||||||
|
|
||||||
|
if (!persisted || persisted.fingerprint !== item.fingerprint) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
isRead: persisted.readAt !== null,
|
||||||
|
isArchived: persisted.archivedAt !== null,
|
||||||
|
readAt: persisted.readAt,
|
||||||
|
archivedAt: persisted.archivedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca todas as notificações do dashboard:
|
* Busca todas as notificações do dashboard:
|
||||||
* - Faturas de cartão atrasadas ou com vencimento próximo
|
* - Faturas de cartão atrasadas ou com vencimento próximo
|
||||||
@@ -188,7 +220,9 @@ export async function fetchDashboardNotifications(
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
orcamentoId: budgets.id,
|
orcamentoId: budgets.id,
|
||||||
|
categoryId: budgets.categoryId,
|
||||||
budgetAmount: budgets.amount,
|
budgetAmount: budgets.amount,
|
||||||
|
period: budgets.period,
|
||||||
categoriaName: categories.name,
|
categoriaName: categories.name,
|
||||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
||||||
})
|
})
|
||||||
@@ -216,12 +250,12 @@ export async function fetchDashboardNotifications(
|
|||||||
);
|
);
|
||||||
if (!dueDate) continue;
|
if (!dueDate) continue;
|
||||||
const amount = toNumber(invoice.totalAmount);
|
const amount = toNumber(invoice.totalAmount);
|
||||||
const notificationId = invoice.invoiceId
|
const notificationKey = buildInvoiceNotificationKey(
|
||||||
? `invoice-${invoice.invoiceId}`
|
invoice.cardId,
|
||||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
invoice.period,
|
||||||
|
);
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: notificationId,
|
|
||||||
type: "invoice",
|
type: "invoice",
|
||||||
name: invoice.cardName,
|
name: invoice.cardName,
|
||||||
dueDate,
|
dueDate,
|
||||||
@@ -230,6 +264,13 @@ export async function fetchDashboardNotifications(
|
|||||||
period: invoice.period,
|
period: invoice.period,
|
||||||
showAmount: true,
|
showAmount: true,
|
||||||
cardLogo: invoice.cardLogo,
|
cardLogo: invoice.cardLogo,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: "overdue",
|
||||||
|
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,20 +302,28 @@ export async function fetchDashboardNotifications(
|
|||||||
);
|
);
|
||||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||||
|
|
||||||
const notificationId = invoice.invoiceId
|
const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon";
|
||||||
? `invoice-${invoice.invoiceId}`
|
const notificationKey = buildInvoiceNotificationKey(
|
||||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
invoice.cardId,
|
||||||
|
invoice.period,
|
||||||
|
);
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: notificationId,
|
|
||||||
type: "invoice",
|
type: "invoice",
|
||||||
name: invoice.cardName,
|
name: invoice.cardName,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: invoiceIsOverdue ? "overdue" : "due_soon",
|
status: notificationStatus,
|
||||||
amount: Math.abs(amount),
|
amount: Math.abs(amount),
|
||||||
period: invoice.period,
|
period: invoice.period,
|
||||||
showAmount: invoiceIsOverdue,
|
showAmount: invoiceIsOverdue,
|
||||||
cardLogo: invoice.cardLogo,
|
cardLogo: invoice.cardLogo,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: notificationStatus,
|
||||||
|
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,10 +341,11 @@ export async function fetchDashboardNotifications(
|
|||||||
const isOldPeriod = boleto.period < currentPeriod;
|
const isOldPeriod = boleto.period < currentPeriod;
|
||||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||||
const amount = toNumber(boleto.amount);
|
const amount = toNumber(boleto.amount);
|
||||||
|
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
|
||||||
|
const notificationKey = buildBoletoNotificationKey(boleto.id);
|
||||||
|
|
||||||
if (isOldPeriod) {
|
if (isOldPeriod) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: `boleto-${boleto.id}`,
|
|
||||||
type: "boleto",
|
type: "boleto",
|
||||||
name: boleto.name,
|
name: boleto.name,
|
||||||
dueDate,
|
dueDate,
|
||||||
@@ -303,17 +353,32 @@ export async function fetchDashboardNotifications(
|
|||||||
amount: Math.abs(amount),
|
amount: Math.abs(amount),
|
||||||
period: boleto.period,
|
period: boleto.period,
|
||||||
showAmount: true,
|
showAmount: true,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: "overdue",
|
||||||
|
href,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||||
|
const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon";
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: `boleto-${boleto.id}`,
|
|
||||||
type: "boleto",
|
type: "boleto",
|
||||||
name: boleto.name,
|
name: boleto.name,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: boletoIsOverdue ? "overdue" : "due_soon",
|
status: notificationStatus,
|
||||||
amount: Math.abs(amount),
|
amount: Math.abs(amount),
|
||||||
period: boleto.period,
|
period: boleto.period,
|
||||||
showAmount: boletoIsOverdue,
|
showAmount: boletoIsOverdue,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: notificationStatus,
|
||||||
|
href,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,14 +400,26 @@ export async function fetchDashboardNotifications(
|
|||||||
|
|
||||||
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
||||||
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
||||||
|
const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical";
|
||||||
|
const notificationKey = buildBudgetNotificationKey(
|
||||||
|
row.categoryId,
|
||||||
|
row.orcamentoId,
|
||||||
|
row.period,
|
||||||
|
);
|
||||||
|
|
||||||
budgetNotifications.push({
|
budgetNotifications.push({
|
||||||
id: `budget-${row.orcamentoId}`,
|
|
||||||
categoryName: row.categoriaName,
|
categoryName: row.categoriaName,
|
||||||
budgetAmount,
|
budgetAmount,
|
||||||
spentAmount,
|
spentAmount,
|
||||||
usedPercentage,
|
usedPercentage,
|
||||||
status: usedPercentage >= 100 ? "exceeded" : "critical",
|
status: notificationStatus,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: notificationStatus,
|
||||||
|
href: `/budgets?periodo=${formatPeriodForUrl(row.period)}`,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,9 +430,68 @@ export async function fetchDashboardNotifications(
|
|||||||
return b.usedPercentage - a.usedPercentage;
|
return b.usedPercentage - a.usedPercentage;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const notificationKeys = [
|
||||||
notifications,
|
...notifications.map((notification) => notification.notificationKey),
|
||||||
totalCount: notifications.length,
|
...budgetNotifications.map((notification) => notification.notificationKey),
|
||||||
|
];
|
||||||
|
|
||||||
|
let persistedStates: PersistedNotificationState[] = [];
|
||||||
|
|
||||||
|
if (notificationKeys.length > 0) {
|
||||||
|
try {
|
||||||
|
persistedStates = await db
|
||||||
|
.select({
|
||||||
|
notificationKey: dashboardNotificationStates.notificationKey,
|
||||||
|
fingerprint: dashboardNotificationStates.fingerprint,
|
||||||
|
readAt: dashboardNotificationStates.readAt,
|
||||||
|
archivedAt: dashboardNotificationStates.archivedAt,
|
||||||
|
})
|
||||||
|
.from(dashboardNotificationStates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, userId),
|
||||||
|
inArray(
|
||||||
|
dashboardNotificationStates.notificationKey,
|
||||||
|
notificationKeys,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
console.warn(
|
||||||
|
"[DashboardNotifications] Tabela dashboard_notification_states ainda não existe. Voltando ao modo sem persistência.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateByKey = new Map(
|
||||||
|
persistedStates.map((state) => [state.notificationKey, state]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedNotifications = mergeNotificationState(notifications, stateByKey);
|
||||||
|
const mergedBudgetNotifications = mergeNotificationState(
|
||||||
budgetNotifications,
|
budgetNotifications,
|
||||||
|
stateByKey,
|
||||||
|
);
|
||||||
|
const visibleNotifications = mergedNotifications.filter(
|
||||||
|
(notification) => !notification.isArchived,
|
||||||
|
);
|
||||||
|
const visibleBudgetNotifications = mergedBudgetNotifications.filter(
|
||||||
|
(notification) => !notification.isArchived,
|
||||||
|
);
|
||||||
|
const unreadCount = [
|
||||||
|
...visibleNotifications,
|
||||||
|
...visibleBudgetNotifications,
|
||||||
|
].filter((notification) => !notification.isRead).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: mergedNotifications,
|
||||||
|
budgetNotifications: mergedBudgetNotifications,
|
||||||
|
unreadCount,
|
||||||
|
visibleCount:
|
||||||
|
visibleNotifications.length + visibleBudgetNotifications.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { DashboardNotificationsSnapshot } from "@/features/dashboard/notifications-queries";
|
|
||||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||||
import { Logo } from "@/shared/components/logo";
|
import { Logo } from "@/shared/components/logo";
|
||||||
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
||||||
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
||||||
|
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
|
||||||
import { NavMenu } from "./nav-menu";
|
import { NavMenu } from "./nav-menu";
|
||||||
import { NavbarUser } from "./navbar-user";
|
import { NavbarUser } from "./navbar-user";
|
||||||
|
|
||||||
@@ -40,7 +40,8 @@ export function AppNavbar({
|
|||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
notifications={notificationsSnapshot.notifications}
|
notifications={notificationsSnapshot.notifications}
|
||||||
totalCount={notificationsSnapshot.totalCount}
|
unreadCount={notificationsSnapshot.unreadCount}
|
||||||
|
visibleCount={notificationsSnapshot.visibleCount}
|
||||||
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
||||||
preLancamentosCount={preLancamentosCount}
|
preLancamentosCount={preLancamentosCount}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full shadow-none transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
|
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
|
||||||
aria-label="Menu do usuário"
|
aria-label="Menu do usuário"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -1,347 +1,81 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
RiAlertFill,
|
|
||||||
RiArrowRightLine,
|
|
||||||
RiAtLine,
|
|
||||||
RiBankCardLine,
|
|
||||||
RiBarChart2Line,
|
|
||||||
RiCheckboxCircleFill,
|
|
||||||
RiErrorWarningLine,
|
|
||||||
RiFileListLine,
|
|
||||||
RiNotification2Line,
|
|
||||||
RiTimeLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type {
|
|
||||||
BudgetNotification,
|
|
||||||
DashboardNotification,
|
|
||||||
} from "@/features/dashboard/notifications-queries";
|
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
|
||||||
import { buttonVariants } from "@/shared/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
import {
|
import { NotificationBellContent } from "./notification-bell/notification-bell-content";
|
||||||
Empty,
|
import { NotificationBellEmptyState } from "./notification-bell/notification-bell-empty-state";
|
||||||
EmptyDescription,
|
import { NotificationBellHeader } from "./notification-bell/notification-bell-header";
|
||||||
EmptyMedia,
|
import { NotificationBellTrigger } from "./notification-bell/notification-bell-trigger";
|
||||||
EmptyTitle,
|
import type { NotificationBellProps } from "./notification-bell/types";
|
||||||
} from "@/shared/components/ui/empty";
|
import { useNotificationBell } from "./notification-bell/use-notification-bell";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/shared/components/ui/tooltip";
|
|
||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
|
||||||
import { formatCurrency } from "@/shared/utils/currency";
|
|
||||||
import { formatDateOnly } from "@/shared/utils/date";
|
|
||||||
import { formatPercentage } from "@/shared/utils/percentage";
|
|
||||||
import { cn } from "@/shared/utils/ui";
|
|
||||||
|
|
||||||
type NotificationBellProps = {
|
export function NotificationBell(props: NotificationBellProps) {
|
||||||
notifications: DashboardNotification[];
|
const {
|
||||||
totalCount: number;
|
open,
|
||||||
budgetNotifications: BudgetNotification[];
|
setOpen,
|
||||||
preLancamentosCount?: number;
|
viewMode,
|
||||||
};
|
setViewMode,
|
||||||
|
displayCount,
|
||||||
function formatDate(dateString: string): string {
|
hasUnreadNotifications,
|
||||||
return (
|
hasAnySourceItems,
|
||||||
formatDateOnly(dateString, {
|
headerCountLabel,
|
||||||
day: "2-digit",
|
hasDashboardNotificationItems,
|
||||||
month: "short",
|
hasArchivedItems,
|
||||||
}) ?? dateString
|
archivedDashboardCount,
|
||||||
);
|
hasVisibleItems,
|
||||||
}
|
displayedPreLancamentosCount,
|
||||||
|
displayedBudgetNotifications,
|
||||||
function SectionLabel({
|
invoiceNotifications,
|
||||||
icon,
|
boletoNotifications,
|
||||||
title,
|
handleInboxNavigate,
|
||||||
}: {
|
handleNotificationNavigate,
|
||||||
icon: React.ReactNode;
|
handleToggleRead,
|
||||||
title: string;
|
handleToggleArchive,
|
||||||
}) {
|
showArchived,
|
||||||
return (
|
} = useNotificationBell(props);
|
||||||
<div className="flex items-center gap-1.5 px-3 pb-1 pt-3">
|
|
||||||
<span className="text-muted-foreground">{icon}</span>
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotificationItemProps = {
|
|
||||||
href: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
isOverdue: boolean;
|
|
||||||
title: string;
|
|
||||||
detail: string;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function NotificationItem({
|
|
||||||
href,
|
|
||||||
icon,
|
|
||||||
isOverdue,
|
|
||||||
title,
|
|
||||||
detail,
|
|
||||||
onClose,
|
|
||||||
}: NotificationItemProps) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
onClick={onClose}
|
|
||||||
className={cn(
|
|
||||||
"group mx-1 mb-0.5 flex items-start gap-2.5 rounded-md px-2 py-2 transition-colors hover:bg-accent/60",
|
|
||||||
isOverdue && "bg-destructive/5 hover:bg-destructive/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
|
||||||
<span className="flex flex-1 flex-col gap-0.5 min-w-0">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[12px] font-medium leading-snug",
|
|
||||||
isOverdue ? "text-destructive" : "text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
|
||||||
{detail}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<RiArrowRightLine className="mt-0.5 size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationBell({
|
|
||||||
notifications,
|
|
||||||
totalCount,
|
|
||||||
budgetNotifications,
|
|
||||||
preLancamentosCount = 0,
|
|
||||||
}: NotificationBellProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const effectiveTotalCount =
|
|
||||||
totalCount + preLancamentosCount + budgetNotifications.length;
|
|
||||||
const displayCount =
|
|
||||||
effectiveTotalCount > 99 ? "99+" : effectiveTotalCount.toString();
|
|
||||||
const hasNotifications = effectiveTotalCount > 0;
|
|
||||||
|
|
||||||
const invoiceNotifications = notifications.filter(
|
|
||||||
(n) => n.type === "invoice",
|
|
||||||
);
|
|
||||||
const boletoNotifications = notifications.filter((n) => n.type === "boleto");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<Tooltip>
|
<NotificationBellTrigger
|
||||||
<TooltipTrigger asChild>
|
open={open}
|
||||||
<DropdownMenuTrigger asChild>
|
hasAnySourceItems={hasAnySourceItems}
|
||||||
<button
|
hasUnreadNotifications={hasUnreadNotifications}
|
||||||
type="button"
|
displayCount={displayCount}
|
||||||
aria-label="Notificações"
|
/>
|
||||||
aria-expanded={open}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
|
||||||
"group relative shadow-none transition-all duration-200",
|
|
||||||
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
|
|
||||||
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
|
|
||||||
hasNotifications ? "text-black" : "text-black/75",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RiNotification2Line
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-transform duration-200",
|
|
||||||
open ? "scale-90" : "scale-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{hasNotifications && (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[12px] font-semibold text-destructive-foreground"
|
|
||||||
>
|
|
||||||
{displayCount}
|
|
||||||
</span>
|
|
||||||
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/40" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8}>
|
|
||||||
Notificações
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={12}
|
sideOffset={12}
|
||||||
className="w-80 overflow-hidden rounded-lg border border-border/60 bg-popover p-0 shadow-none"
|
className="w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg p-0 shadow-lg"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<NotificationBellHeader
|
||||||
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-[12px] font-semibold">
|
hasAnySourceItems={hasAnySourceItems}
|
||||||
<span>Notificações</span>
|
headerCountLabel={headerCountLabel}
|
||||||
{hasNotifications && (
|
hasDashboardNotificationItems={hasDashboardNotificationItems}
|
||||||
<Badge variant="outline" className="text-[10px] font-semibold">
|
viewMode={viewMode}
|
||||||
{effectiveTotalCount}{" "}
|
hasArchivedItems={hasArchivedItems}
|
||||||
{effectiveTotalCount === 1 ? "item" : "itens"}
|
archivedDashboardCount={archivedDashboardCount}
|
||||||
</Badge>
|
onViewModeChange={setViewMode}
|
||||||
)}
|
/>
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
{!hasNotifications ? (
|
{hasVisibleItems ? (
|
||||||
<div className="px-4 py-8">
|
<NotificationBellContent
|
||||||
<Empty>
|
displayedPreLancamentosCount={displayedPreLancamentosCount}
|
||||||
<EmptyMedia>
|
displayedBudgetNotifications={displayedBudgetNotifications}
|
||||||
<RiCheckboxCircleFill color="green" />
|
invoiceNotifications={invoiceNotifications}
|
||||||
</EmptyMedia>
|
boletoNotifications={boletoNotifications}
|
||||||
<EmptyTitle>Nenhuma notificação</EmptyTitle>
|
onInboxNavigate={handleInboxNavigate}
|
||||||
<EmptyDescription>
|
onNotificationNavigate={handleNotificationNavigate}
|
||||||
Você está em dia com seus pagamentos!
|
onToggleRead={handleToggleRead}
|
||||||
</EmptyDescription>
|
onToggleArchive={handleToggleArchive}
|
||||||
</Empty>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[460px] overflow-y-auto pb-2">
|
<NotificationBellEmptyState
|
||||||
{/* Pré-lançamentos */}
|
showArchived={showArchived}
|
||||||
{preLancamentosCount > 0 && (
|
hasArchivedItems={hasArchivedItems}
|
||||||
<div>
|
/>
|
||||||
<SectionLabel
|
|
||||||
icon={<RiAtLine className="size-3" />}
|
|
||||||
title="Pré-lançamentos"
|
|
||||||
/>
|
|
||||||
<NotificationItem
|
|
||||||
href="/inbox"
|
|
||||||
isOverdue={false}
|
|
||||||
icon={<RiAtLine className="size-5 text-primary" />}
|
|
||||||
title={
|
|
||||||
preLancamentosCount === 1
|
|
||||||
? "1 pré-lançamento pendente"
|
|
||||||
: `${preLancamentosCount} pré-lançamentos pendentes`
|
|
||||||
}
|
|
||||||
detail="Aguardando revisão"
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Orçamentos */}
|
|
||||||
{budgetNotifications.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<SectionLabel
|
|
||||||
icon={<RiBarChart2Line className="size-3" />}
|
|
||||||
title="Orçamentos"
|
|
||||||
/>
|
|
||||||
{budgetNotifications.map((n) => (
|
|
||||||
<NotificationItem
|
|
||||||
key={n.id}
|
|
||||||
href="/budgets"
|
|
||||||
isOverdue={n.status === "exceeded"}
|
|
||||||
icon={
|
|
||||||
n.status === "exceeded" ? (
|
|
||||||
<RiAlertFill className="size-5 text-destructive" />
|
|
||||||
) : (
|
|
||||||
<RiErrorWarningLine className="size-5 text-amber-500" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={n.categoryName}
|
|
||||||
detail={
|
|
||||||
n.status === "exceeded"
|
|
||||||
? `Excedido — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)} (${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })})`
|
|
||||||
: `${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })} utilizado — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)}`
|
|
||||||
}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cartão de Crédito */}
|
|
||||||
{invoiceNotifications.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<SectionLabel
|
|
||||||
icon={<RiBankCardLine className="size-3" />}
|
|
||||||
title="Cartão de Crédito"
|
|
||||||
/>
|
|
||||||
{invoiceNotifications.map((n) => {
|
|
||||||
const logo = resolveLogoSrc(n.cardLogo);
|
|
||||||
return (
|
|
||||||
<NotificationItem
|
|
||||||
key={n.id}
|
|
||||||
href="/cards"
|
|
||||||
isOverdue={n.status === "overdue"}
|
|
||||||
icon={
|
|
||||||
logo ? (
|
|
||||||
<Image
|
|
||||||
src={logo}
|
|
||||||
alt=""
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="size-5 rounded-full object-contain"
|
|
||||||
/>
|
|
||||||
) : n.status === "overdue" ? (
|
|
||||||
<RiAlertFill className="size-5 text-destructive" />
|
|
||||||
) : (
|
|
||||||
<RiTimeLine className="size-5 text-amber-500" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={n.name}
|
|
||||||
detail={
|
|
||||||
n.status === "overdue"
|
|
||||||
? `Venceu em ${formatDate(n.dueDate)}${n.showAmount && n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
|
||||||
: `Vence em ${formatDate(n.dueDate)}${n.showAmount && n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
|
||||||
}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Boletos */}
|
|
||||||
{boletoNotifications.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<SectionLabel
|
|
||||||
icon={<RiFileListLine className="size-3" />}
|
|
||||||
title="Boletos"
|
|
||||||
/>
|
|
||||||
{boletoNotifications.map((n) => (
|
|
||||||
<NotificationItem
|
|
||||||
key={n.id}
|
|
||||||
href="/transactions"
|
|
||||||
isOverdue={n.status === "overdue"}
|
|
||||||
icon={
|
|
||||||
<RiAlertFill
|
|
||||||
className={cn(
|
|
||||||
"size-5",
|
|
||||||
n.status === "overdue"
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-amber-500",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={n.name}
|
|
||||||
detail={
|
|
||||||
n.status === "overdue"
|
|
||||||
? `Venceu em ${formatDate(n.dueDate)}${n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
|
||||||
: `Vence em ${formatDate(n.dueDate)}${n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
|
||||||
}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiAlertFill,
|
||||||
|
RiArchiveLine,
|
||||||
|
RiArrowGoBackLine,
|
||||||
|
RiAtLine,
|
||||||
|
RiBankCardLine,
|
||||||
|
RiBarChart2Line,
|
||||||
|
RiCheckLine,
|
||||||
|
RiErrorWarningLine,
|
||||||
|
RiFileListLine,
|
||||||
|
RiInboxUnarchiveLine,
|
||||||
|
RiTimeLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import StatusDot from "@/shared/components/status-dot";
|
||||||
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
import { formatDateOnly } from "@/shared/utils/date";
|
||||||
|
import { formatPercentage } from "@/shared/utils/percentage";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import type {
|
||||||
|
ResolvedBudgetNotification,
|
||||||
|
ResolvedDashboardNotification,
|
||||||
|
StatefulNotification,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type NotificationBellContentProps = {
|
||||||
|
displayedPreLancamentosCount: number;
|
||||||
|
displayedBudgetNotifications: ResolvedBudgetNotification[];
|
||||||
|
invoiceNotifications: ResolvedDashboardNotification[];
|
||||||
|
boletoNotifications: ResolvedDashboardNotification[];
|
||||||
|
onInboxNavigate: () => void;
|
||||||
|
onNotificationNavigate: (notification: StatefulNotification) => Promise<void>;
|
||||||
|
onToggleRead: (notification: StatefulNotification) => Promise<void>;
|
||||||
|
onToggleArchive: (notification: StatefulNotification) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return (
|
||||||
|
formatDateOnly(dateString, { day: "2-digit", month: "short" }) ?? dateString
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadAction(notification: StatefulNotification) {
|
||||||
|
return {
|
||||||
|
label: notification.isRead ? "Marcar como não lida" : "Marcar como lida",
|
||||||
|
icon: notification.isRead ? (
|
||||||
|
<RiArrowGoBackLine className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RiCheckLine className="size-4" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchiveAction(notification: StatefulNotification) {
|
||||||
|
return {
|
||||||
|
label: notification.isArchived
|
||||||
|
? "Desarquivar notificação"
|
||||||
|
: "Arquivar notificação",
|
||||||
|
icon: notification.isArchived ? (
|
||||||
|
<RiInboxUnarchiveLine className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RiArchiveLine className="size-4" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SectionLabel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SectionLabel({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 p-2 first:pt-1">
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NotificationActionButton({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick?: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onClick?.();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||||
|
"size-7 text-muted-foreground opacity-0 transition-all group-hover/item:opacity-100 hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" sideOffset={8}>
|
||||||
|
{label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NotificationItem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type NotificationItemProps = {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isOverdue: boolean;
|
||||||
|
isRead?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
isBusy?: boolean;
|
||||||
|
showUnreadIndicator?: boolean;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
onNavigate: () => void | Promise<void>;
|
||||||
|
onToggleRead?: () => void | Promise<void>;
|
||||||
|
onToggleArchive?: () => void | Promise<void>;
|
||||||
|
notification?: StatefulNotification;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotificationItem({
|
||||||
|
icon,
|
||||||
|
isOverdue,
|
||||||
|
isRead = false,
|
||||||
|
isArchived = false,
|
||||||
|
isBusy = false,
|
||||||
|
showUnreadIndicator = false,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
onNavigate,
|
||||||
|
onToggleRead,
|
||||||
|
onToggleArchive,
|
||||||
|
notification,
|
||||||
|
}: NotificationItemProps) {
|
||||||
|
const readAction = notification ? getReadAction(notification) : null;
|
||||||
|
const archiveAction = notification ? getArchiveAction(notification) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/item mx-1 mb-0.5 flex items-center gap-2.5 rounded-md px-2.5 py-2 transition-colors",
|
||||||
|
isArchived
|
||||||
|
? "opacity-60"
|
||||||
|
: isOverdue && !isRead
|
||||||
|
? "bg-destructive/5"
|
||||||
|
: "hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onNavigate();
|
||||||
|
}}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex min-w-0 flex-1 items-start gap-2.5 text-left disabled:cursor-wait disabled:opacity-80"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 truncate text-xs font-medium leading-snug",
|
||||||
|
isOverdue && !isRead
|
||||||
|
? "text-destructive"
|
||||||
|
: isRead
|
||||||
|
? "text-muted-foreground"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{showUnreadIndicator && !isRead && (
|
||||||
|
<StatusDot color="bg-destructive/80" className="size-1.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs leading-snug",
|
||||||
|
isRead ? "text-muted-foreground/70" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(readAction || archiveAction) && (
|
||||||
|
<div className="flex w-16 shrink-0 items-center justify-end gap-0.5">
|
||||||
|
{readAction && onToggleRead && (
|
||||||
|
<NotificationActionButton
|
||||||
|
label={readAction.label}
|
||||||
|
icon={readAction.icon}
|
||||||
|
onClick={onToggleRead}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{archiveAction && onToggleArchive && (
|
||||||
|
<NotificationActionButton
|
||||||
|
label={archiveAction.label}
|
||||||
|
icon={archiveAction.icon}
|
||||||
|
onClick={onToggleArchive}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NotificationSection — generic wrapper to eliminate per-type repetition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type NotificationSectionProps<
|
||||||
|
T extends StatefulNotification & { isBusy: boolean },
|
||||||
|
> = {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
items: T[];
|
||||||
|
renderIcon: (item: T) => React.ReactNode;
|
||||||
|
renderTitle: (item: T) => string;
|
||||||
|
renderDetail: (item: T) => string;
|
||||||
|
isOverdue: (item: T) => boolean;
|
||||||
|
showUnreadIndicator?: boolean;
|
||||||
|
onNavigate: (item: T) => void | Promise<void>;
|
||||||
|
onToggleRead: (item: T) => void | Promise<void>;
|
||||||
|
onToggleArchive: (item: T) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotificationSection<
|
||||||
|
T extends StatefulNotification & { isBusy: boolean },
|
||||||
|
>({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
renderIcon,
|
||||||
|
renderTitle,
|
||||||
|
renderDetail,
|
||||||
|
isOverdue,
|
||||||
|
showUnreadIndicator = false,
|
||||||
|
onNavigate,
|
||||||
|
onToggleRead,
|
||||||
|
onToggleArchive,
|
||||||
|
}: NotificationSectionProps<T>) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionLabel icon={icon} title={title} />
|
||||||
|
{items.map((item) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={item.notificationKey}
|
||||||
|
isOverdue={isOverdue(item)}
|
||||||
|
isRead={item.isRead}
|
||||||
|
isArchived={item.isArchived}
|
||||||
|
isBusy={item.isBusy}
|
||||||
|
showUnreadIndicator={showUnreadIndicator}
|
||||||
|
icon={renderIcon(item)}
|
||||||
|
title={renderTitle(item)}
|
||||||
|
detail={renderDetail(item)}
|
||||||
|
onNavigate={() => onNavigate(item)}
|
||||||
|
onToggleRead={() => onToggleRead(item)}
|
||||||
|
onToggleArchive={() => onToggleArchive(item)}
|
||||||
|
notification={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Icon helpers (consistent between invoice / boleto)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DueDateIcon({ isOverdue }: { isOverdue: boolean }) {
|
||||||
|
return isOverdue ? (
|
||||||
|
<RiAlertFill className="size-5 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<RiTimeLine className="size-5 text-amber-500" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceIcon({
|
||||||
|
cardLogo,
|
||||||
|
isOverdue,
|
||||||
|
}: {
|
||||||
|
cardLogo?: string | null;
|
||||||
|
isOverdue: boolean;
|
||||||
|
}) {
|
||||||
|
const logo = resolveLogoSrc(cardLogo);
|
||||||
|
|
||||||
|
if (logo) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="size-5 rounded-full object-contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DueDateIcon isOverdue={isOverdue} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDateDetail(
|
||||||
|
status: string,
|
||||||
|
dueDate: string,
|
||||||
|
amount: number,
|
||||||
|
showAmount: boolean,
|
||||||
|
) {
|
||||||
|
const verb = status === "overdue" ? "Venceu em" : "Vence em";
|
||||||
|
const amountStr =
|
||||||
|
showAmount && amount > 0 ? ` — ${formatCurrency(amount)}` : "";
|
||||||
|
return `${verb} ${formatDate(dueDate)}${amountStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function NotificationBellContent({
|
||||||
|
displayedPreLancamentosCount,
|
||||||
|
displayedBudgetNotifications,
|
||||||
|
invoiceNotifications,
|
||||||
|
boletoNotifications,
|
||||||
|
onInboxNavigate,
|
||||||
|
onNotificationNavigate,
|
||||||
|
onToggleRead,
|
||||||
|
onToggleArchive,
|
||||||
|
}: NotificationBellContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="max-h-[460px] overflow-y-auto p-2">
|
||||||
|
{displayedPreLancamentosCount > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel
|
||||||
|
icon={<RiAtLine className="size-3" />}
|
||||||
|
title="Pré-lançamentos"
|
||||||
|
/>
|
||||||
|
<NotificationItem
|
||||||
|
icon={<RiAtLine className="size-5 text-primary" />}
|
||||||
|
isOverdue={false}
|
||||||
|
title={
|
||||||
|
displayedPreLancamentosCount === 1
|
||||||
|
? "1 pré-lançamento pendente"
|
||||||
|
: `${displayedPreLancamentosCount} pré-lançamentos pendentes`
|
||||||
|
}
|
||||||
|
detail="Aguardando revisão"
|
||||||
|
onNavigate={onInboxNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NotificationSection
|
||||||
|
icon={<RiBarChart2Line className="size-3" />}
|
||||||
|
title="Orçamentos"
|
||||||
|
items={displayedBudgetNotifications}
|
||||||
|
isOverdue={(n) => n.status === "exceeded"}
|
||||||
|
showUnreadIndicator
|
||||||
|
renderIcon={(n) =>
|
||||||
|
n.status === "exceeded" ? (
|
||||||
|
<RiAlertFill className="size-5 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<RiErrorWarningLine className="size-5 text-amber-500" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderTitle={(n) => n.categoryName}
|
||||||
|
renderDetail={(n) =>
|
||||||
|
n.status === "exceeded"
|
||||||
|
? `Excedido — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)} (${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })})`
|
||||||
|
: `${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })} utilizado — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)}`
|
||||||
|
}
|
||||||
|
onNavigate={(n) => onNotificationNavigate(n)}
|
||||||
|
onToggleRead={(n) => onToggleRead(n)}
|
||||||
|
onToggleArchive={(n) => onToggleArchive(n)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationSection
|
||||||
|
icon={<RiBankCardLine className="size-3" />}
|
||||||
|
title="Cartão de Crédito"
|
||||||
|
items={invoiceNotifications}
|
||||||
|
isOverdue={(n) => n.status === "overdue"}
|
||||||
|
showUnreadIndicator
|
||||||
|
renderIcon={(n) => (
|
||||||
|
<InvoiceIcon
|
||||||
|
cardLogo={n.cardLogo}
|
||||||
|
isOverdue={n.status === "overdue"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderTitle={(n) => n.name}
|
||||||
|
renderDetail={(n) =>
|
||||||
|
formatDueDateDetail(n.status, n.dueDate, n.amount, n.showAmount)
|
||||||
|
}
|
||||||
|
onNavigate={(n) => onNotificationNavigate(n)}
|
||||||
|
onToggleRead={(n) => onToggleRead(n)}
|
||||||
|
onToggleArchive={(n) => onToggleArchive(n)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationSection
|
||||||
|
icon={<RiFileListLine className="size-3" />}
|
||||||
|
title="Boletos"
|
||||||
|
items={boletoNotifications}
|
||||||
|
isOverdue={(n) => n.status === "overdue"}
|
||||||
|
showUnreadIndicator
|
||||||
|
renderIcon={(n) => <DueDateIcon isOverdue={n.status === "overdue"} />}
|
||||||
|
renderTitle={(n) => n.name}
|
||||||
|
renderDetail={(n) =>
|
||||||
|
formatDueDateDetail(n.status, n.dueDate, n.amount, true)
|
||||||
|
}
|
||||||
|
onNavigate={(n) => onNotificationNavigate(n)}
|
||||||
|
onToggleRead={(n) => onToggleRead(n)}
|
||||||
|
onToggleArchive={(n) => onToggleArchive(n)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiArchiveLine, RiCheckboxCircleFill } from "@remixicon/react";
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/shared/components/ui/empty";
|
||||||
|
|
||||||
|
type NotificationBellEmptyStateProps = {
|
||||||
|
showArchived: boolean;
|
||||||
|
hasArchivedItems: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationBellEmptyState({
|
||||||
|
showArchived,
|
||||||
|
hasArchivedItems,
|
||||||
|
}: NotificationBellEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-8">
|
||||||
|
<Empty>
|
||||||
|
<EmptyMedia>
|
||||||
|
{showArchived ? (
|
||||||
|
<RiArchiveLine className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxCircleFill color="green" />
|
||||||
|
)}
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>
|
||||||
|
{showArchived
|
||||||
|
? "Nenhuma notificação arquivada"
|
||||||
|
: hasArchivedItems
|
||||||
|
? "Nenhuma notificação ativa"
|
||||||
|
: "Nenhuma notificação"}
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
{showArchived
|
||||||
|
? "Você ainda não arquivou nenhuma notificação."
|
||||||
|
: hasArchivedItems
|
||||||
|
? "As demais notificações estão arquivadas. Ative o filtro para revê-las."
|
||||||
|
: "Você está em dia com seus pagamentos!"}
|
||||||
|
</EmptyDescription>
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/shared/components/ui/toggle-group";
|
||||||
|
import type { NotificationViewMode } from "./types";
|
||||||
|
|
||||||
|
type NotificationBellHeaderProps = {
|
||||||
|
hasAnySourceItems: boolean;
|
||||||
|
headerCountLabel: string;
|
||||||
|
hasDashboardNotificationItems: boolean;
|
||||||
|
viewMode: NotificationViewMode;
|
||||||
|
hasArchivedItems: boolean;
|
||||||
|
archivedDashboardCount: number;
|
||||||
|
onViewModeChange: (viewMode: NotificationViewMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationBellHeader({
|
||||||
|
hasAnySourceItems,
|
||||||
|
headerCountLabel,
|
||||||
|
hasDashboardNotificationItems,
|
||||||
|
viewMode,
|
||||||
|
hasArchivedItems,
|
||||||
|
archivedDashboardCount,
|
||||||
|
onViewModeChange,
|
||||||
|
}: NotificationBellHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-b px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||||
|
<span>Notificações</span>
|
||||||
|
{hasAnySourceItems ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-medium">
|
||||||
|
{headerCountLabel}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{hasDashboardNotificationItems ? (
|
||||||
|
<div className="pt-2.5">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={viewMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
if (value === "archived" && !hasArchivedItems) return;
|
||||||
|
onViewModeChange(value as NotificationViewMode);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full rounded-md bg-muted/30 p-0.5"
|
||||||
|
aria-label="Filtro da lista de notificações"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value="active"
|
||||||
|
className="flex-1 text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||||
|
aria-label="Mostrar notificações ativas"
|
||||||
|
>
|
||||||
|
Ativas
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value="archived"
|
||||||
|
className="flex-1 text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||||
|
aria-label="Mostrar notificações arquivadas"
|
||||||
|
disabled={!hasArchivedItems && viewMode !== "archived"}
|
||||||
|
>
|
||||||
|
Arquivadas
|
||||||
|
{hasArchivedItems ? ` (${archivedDashboardCount})` : ""}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiNotification2Line } from "@remixicon/react";
|
||||||
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
|
import { DropdownMenuTrigger } from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
type NotificationBellTriggerProps = {
|
||||||
|
open: boolean;
|
||||||
|
hasAnySourceItems: boolean;
|
||||||
|
hasUnreadNotifications: boolean;
|
||||||
|
displayCount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationBellTrigger({
|
||||||
|
open,
|
||||||
|
hasAnySourceItems,
|
||||||
|
hasUnreadNotifications,
|
||||||
|
displayCount,
|
||||||
|
}: NotificationBellTriggerProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Notificações"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||||
|
"group relative shadow-none transition-all duration-200",
|
||||||
|
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
|
||||||
|
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
|
||||||
|
hasAnySourceItems ? "text-black" : "text-black/75",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiNotification2Line
|
||||||
|
className={cn(
|
||||||
|
"size-4 transition-transform duration-200",
|
||||||
|
open ? "scale-90" : "scale-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{hasUnreadNotifications ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
{displayCount}
|
||||||
|
</span>
|
||||||
|
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 [animation-iteration-count:3]" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" sideOffset={8}>
|
||||||
|
Notificações
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type {
|
||||||
|
BudgetNotification,
|
||||||
|
DashboardNotification,
|
||||||
|
} from "@/shared/lib/types/notifications";
|
||||||
|
|
||||||
|
export type StatefulNotification = {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
href: string;
|
||||||
|
isRead: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
readAt: Date | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationActionState = {
|
||||||
|
isRead: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationViewMode = "active" | "archived";
|
||||||
|
|
||||||
|
export type ResolvedDashboardNotification = DashboardNotification & {
|
||||||
|
isBusy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedBudgetNotification = BudgetNotification & {
|
||||||
|
isBusy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationBellProps = {
|
||||||
|
notifications: DashboardNotification[];
|
||||||
|
unreadCount: number;
|
||||||
|
visibleCount: number;
|
||||||
|
budgetNotifications: BudgetNotification[];
|
||||||
|
preLancamentosCount?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
archiveDashboardNotificationAction,
|
||||||
|
markDashboardNotificationAsReadAction,
|
||||||
|
markDashboardNotificationAsUnreadAction,
|
||||||
|
unarchiveDashboardNotificationAction,
|
||||||
|
} from "@/features/dashboard/notifications-actions";
|
||||||
|
import type {
|
||||||
|
NotificationActionState,
|
||||||
|
NotificationBellProps,
|
||||||
|
NotificationViewMode,
|
||||||
|
ResolvedBudgetNotification,
|
||||||
|
ResolvedDashboardNotification,
|
||||||
|
StatefulNotification,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type NotificationAction = "read" | "unread" | "archive" | "unarchive";
|
||||||
|
|
||||||
|
type UseNotificationBellReturn = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
viewMode: NotificationViewMode;
|
||||||
|
setViewMode: (viewMode: NotificationViewMode) => void;
|
||||||
|
displayCount: string;
|
||||||
|
hasUnreadNotifications: boolean;
|
||||||
|
hasAnySourceItems: boolean;
|
||||||
|
headerCountLabel: string;
|
||||||
|
hasDashboardNotificationItems: boolean;
|
||||||
|
hasArchivedItems: boolean;
|
||||||
|
archivedDashboardCount: number;
|
||||||
|
hasVisibleItems: boolean;
|
||||||
|
displayedPreLancamentosCount: number;
|
||||||
|
displayedBudgetNotifications: ResolvedBudgetNotification[];
|
||||||
|
invoiceNotifications: ResolvedDashboardNotification[];
|
||||||
|
boletoNotifications: ResolvedDashboardNotification[];
|
||||||
|
handleInboxNavigate: () => void;
|
||||||
|
handleNotificationNavigate: (
|
||||||
|
notification: StatefulNotification,
|
||||||
|
) => Promise<void>;
|
||||||
|
handleToggleRead: (notification: StatefulNotification) => Promise<void>;
|
||||||
|
handleToggleArchive: (notification: StatefulNotification) => Promise<void>;
|
||||||
|
showArchived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const optimisticStateByAction: Record<
|
||||||
|
NotificationAction,
|
||||||
|
(notification: StatefulNotification) => NotificationActionState
|
||||||
|
> = {
|
||||||
|
archive: () => ({ isRead: true, isArchived: true, isBusy: true }),
|
||||||
|
unarchive: () => ({ isRead: true, isArchived: false, isBusy: true }),
|
||||||
|
read: (n) => ({
|
||||||
|
isRead: true,
|
||||||
|
isArchived: n.isArchived,
|
||||||
|
isBusy: true,
|
||||||
|
}),
|
||||||
|
unread: (n) => ({
|
||||||
|
isRead: false,
|
||||||
|
isArchived: n.isArchived,
|
||||||
|
isBusy: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverActionByType: Record<
|
||||||
|
NotificationAction,
|
||||||
|
(input: {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}) => Promise<{ success: boolean; message?: string; error?: string }>
|
||||||
|
> = {
|
||||||
|
archive: archiveDashboardNotificationAction,
|
||||||
|
unarchive: unarchiveDashboardNotificationAction,
|
||||||
|
read: markDashboardNotificationAsReadAction,
|
||||||
|
unread: markDashboardNotificationAsUnreadAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useNotificationBell({
|
||||||
|
notifications,
|
||||||
|
unreadCount: initialUnreadCount,
|
||||||
|
visibleCount: initialVisibleCount,
|
||||||
|
budgetNotifications,
|
||||||
|
preLancamentosCount = 0,
|
||||||
|
}: NotificationBellProps): UseNotificationBellReturn {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<NotificationViewMode>("active");
|
||||||
|
const [notificationActions, setNotificationActions] = useState<
|
||||||
|
Record<string, NotificationActionState>
|
||||||
|
>({});
|
||||||
|
const router = useRouter();
|
||||||
|
const showArchived = viewMode === "archived";
|
||||||
|
|
||||||
|
// Limpar estado otimista quando o server retorna dados novos (via router.refresh)
|
||||||
|
const prevNotificationsRef = useRef(notifications);
|
||||||
|
const prevBudgetRef = useRef(budgetNotifications);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
prevNotificationsRef.current !== notifications ||
|
||||||
|
prevBudgetRef.current !== budgetNotifications
|
||||||
|
) {
|
||||||
|
prevNotificationsRef.current = notifications;
|
||||||
|
prevBudgetRef.current = budgetNotifications;
|
||||||
|
setNotificationActions({});
|
||||||
|
}
|
||||||
|
}, [notifications, budgetNotifications]);
|
||||||
|
|
||||||
|
const resolveNotificationState = <T extends StatefulNotification>(
|
||||||
|
notification: T,
|
||||||
|
): T & { isBusy: boolean } => {
|
||||||
|
const actionState = notificationActions[notification.notificationKey];
|
||||||
|
|
||||||
|
if (!actionState) {
|
||||||
|
return { ...notification, isBusy: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...notification,
|
||||||
|
isRead: actionState.isRead,
|
||||||
|
isArchived: actionState.isArchived,
|
||||||
|
isBusy: actionState.isBusy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const allResolvedNotifications = notifications.map((notification) =>
|
||||||
|
resolveNotificationState(notification),
|
||||||
|
);
|
||||||
|
const allResolvedBudgetNotifications = budgetNotifications.map(
|
||||||
|
(notification) => resolveNotificationState(notification),
|
||||||
|
);
|
||||||
|
const activeNotifications = allResolvedNotifications.filter(
|
||||||
|
(notification) => !notification.isArchived,
|
||||||
|
);
|
||||||
|
const activeBudgetNotifications = allResolvedBudgetNotifications.filter(
|
||||||
|
(notification) => !notification.isArchived,
|
||||||
|
);
|
||||||
|
const archivedNotifications = allResolvedNotifications.filter(
|
||||||
|
(notification) => notification.isArchived,
|
||||||
|
);
|
||||||
|
const archivedBudgetNotifications = allResolvedBudgetNotifications.filter(
|
||||||
|
(notification) => notification.isArchived,
|
||||||
|
);
|
||||||
|
const displayedNotifications = showArchived
|
||||||
|
? archivedNotifications
|
||||||
|
: activeNotifications;
|
||||||
|
const displayedBudgetNotifications = showArchived
|
||||||
|
? archivedBudgetNotifications
|
||||||
|
: activeBudgetNotifications;
|
||||||
|
const invoiceNotifications = displayedNotifications.filter(
|
||||||
|
(notification) => notification.type === "invoice",
|
||||||
|
);
|
||||||
|
const boletoNotifications = displayedNotifications.filter(
|
||||||
|
(notification) => notification.type === "boleto",
|
||||||
|
);
|
||||||
|
const unreadDashboardCount = [
|
||||||
|
...activeNotifications,
|
||||||
|
...activeBudgetNotifications,
|
||||||
|
].filter((notification) => !notification.isRead).length;
|
||||||
|
const activeDashboardCountFromItems =
|
||||||
|
activeNotifications.length + activeBudgetNotifications.length;
|
||||||
|
const displayedDashboardCountFromItems =
|
||||||
|
displayedNotifications.length + displayedBudgetNotifications.length;
|
||||||
|
const archivedDashboardCount =
|
||||||
|
allResolvedNotifications.length +
|
||||||
|
allResolvedBudgetNotifications.length -
|
||||||
|
activeDashboardCountFromItems;
|
||||||
|
const dashboardNotificationCount =
|
||||||
|
allResolvedNotifications.length + allResolvedBudgetNotifications.length;
|
||||||
|
const hasOptimisticState = Object.keys(notificationActions).length > 0;
|
||||||
|
const unreadDashboardCountValue = hasOptimisticState
|
||||||
|
? unreadDashboardCount
|
||||||
|
: initialUnreadCount;
|
||||||
|
const activeDashboardCount = hasOptimisticState
|
||||||
|
? activeDashboardCountFromItems
|
||||||
|
: initialVisibleCount;
|
||||||
|
const displayedDashboardCount = showArchived
|
||||||
|
? displayedDashboardCountFromItems
|
||||||
|
: activeDashboardCount;
|
||||||
|
const displayedPreLancamentosCount = showArchived ? 0 : preLancamentosCount;
|
||||||
|
const effectiveUnreadCount = unreadDashboardCountValue + preLancamentosCount;
|
||||||
|
const displayCount =
|
||||||
|
effectiveUnreadCount > 99 ? "99+" : effectiveUnreadCount.toString();
|
||||||
|
const hasUnreadNotifications = effectiveUnreadCount > 0;
|
||||||
|
const hasVisibleItems =
|
||||||
|
displayedDashboardCount + displayedPreLancamentosCount > 0;
|
||||||
|
const hasArchivedItems = archivedDashboardCount > 0;
|
||||||
|
const hasDashboardNotificationItems = dashboardNotificationCount > 0;
|
||||||
|
const hasAnySourceItems =
|
||||||
|
allResolvedNotifications.length +
|
||||||
|
allResolvedBudgetNotifications.length +
|
||||||
|
preLancamentosCount >
|
||||||
|
0;
|
||||||
|
const headerCountLabel = `${effectiveUnreadCount} ${effectiveUnreadCount === 1 ? "pendente" : "pendentes"}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showArchived && !hasArchivedItems) {
|
||||||
|
setViewMode("active");
|
||||||
|
}
|
||||||
|
}, [hasArchivedItems, showArchived]);
|
||||||
|
|
||||||
|
const persistNotificationState = async (
|
||||||
|
notification: StatefulNotification,
|
||||||
|
action: NotificationAction,
|
||||||
|
options?: { showToast?: boolean; refreshAfter?: boolean },
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const showToast = options?.showToast ?? true;
|
||||||
|
const refreshAfter = options?.refreshAfter ?? true;
|
||||||
|
|
||||||
|
const previousState: NotificationActionState = {
|
||||||
|
isRead: notification.isRead,
|
||||||
|
isArchived: notification.isArchived,
|
||||||
|
isBusy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const optimisticState = optimisticStateByAction[action](notification);
|
||||||
|
|
||||||
|
setNotificationActions((current) => ({
|
||||||
|
...current,
|
||||||
|
[notification.notificationKey]: optimisticState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await serverActionByType[action]({
|
||||||
|
notificationKey: notification.notificationKey,
|
||||||
|
fingerprint: notification.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setNotificationActions((current) => ({
|
||||||
|
...current,
|
||||||
|
[notification.notificationKey]: previousState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationActions((current) => ({
|
||||||
|
...current,
|
||||||
|
[notification.notificationKey]: {
|
||||||
|
isRead: optimisticState.isRead,
|
||||||
|
isArchived: optimisticState.isArchived,
|
||||||
|
isBusy: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.success(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshAfter) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxNavigate = () => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/inbox");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationNavigate = async (
|
||||||
|
notification: StatefulNotification,
|
||||||
|
) => {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
if (!notification.isRead) {
|
||||||
|
await persistNotificationState(notification, "read", {
|
||||||
|
showToast: false,
|
||||||
|
refreshAfter: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(notification.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleRead = async (notification: StatefulNotification) => {
|
||||||
|
await persistNotificationState(
|
||||||
|
notification,
|
||||||
|
notification.isRead ? "unread" : "read",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleArchive = async (notification: StatefulNotification) => {
|
||||||
|
await persistNotificationState(
|
||||||
|
notification,
|
||||||
|
notification.isArchived ? "unarchive" : "archive",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
displayCount,
|
||||||
|
hasUnreadNotifications,
|
||||||
|
hasAnySourceItems,
|
||||||
|
headerCountLabel,
|
||||||
|
hasDashboardNotificationItems,
|
||||||
|
hasArchivedItems,
|
||||||
|
archivedDashboardCount,
|
||||||
|
hasVisibleItems,
|
||||||
|
displayedPreLancamentosCount,
|
||||||
|
displayedBudgetNotifications,
|
||||||
|
invoiceNotifications,
|
||||||
|
boletoNotifications,
|
||||||
|
handleInboxNavigate,
|
||||||
|
handleNotificationNavigate,
|
||||||
|
handleToggleRead,
|
||||||
|
handleToggleArchive,
|
||||||
|
showArchived,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export const revalidateConfig = {
|
|||||||
budgets: ["/budgets"],
|
budgets: ["/budgets"],
|
||||||
payers: ["/payers"],
|
payers: ["/payers"],
|
||||||
notes: ["/notes", "/notes/archived", "/dashboard"],
|
notes: ["/notes", "/notes/archived", "/dashboard"],
|
||||||
|
notifications: ["/dashboard"],
|
||||||
transactions: ["/transactions", "/accounts"],
|
transactions: ["/transactions", "/accounts"],
|
||||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||||
} as const;
|
} as const;
|
||||||
@@ -43,6 +44,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
|
|||||||
"budgets",
|
"budgets",
|
||||||
"payers",
|
"payers",
|
||||||
"notes",
|
"notes",
|
||||||
|
"notifications",
|
||||||
"inbox",
|
"inbox",
|
||||||
"recurring",
|
"recurring",
|
||||||
]);
|
]);
|
||||||
|
|||||||
16
src/shared/lib/notifications/is-table-missing.ts
Normal file
16
src/shared/lib/notifications/is-table-missing.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Detecta se um erro indica que a tabela `dashboard_notification_states`
|
||||||
|
* ainda nao existe no banco (migration pendente).
|
||||||
|
*/
|
||||||
|
export function isNotificationStatesTableMissing(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
message.includes("dashboard_notification_states") &&
|
||||||
|
(message.includes("does not exist") || message.includes("relation"))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./actions";
|
export * from "./actions";
|
||||||
export * from "./calendar";
|
export * from "./calendar";
|
||||||
|
export * from "./notifications";
|
||||||
export * from "./reports";
|
export * from "./reports";
|
||||||
|
|||||||
39
src/shared/lib/types/notifications.ts
Normal file
39
src/shared/lib/types/notifications.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export type NotificationType = "overdue" | "due_soon";
|
||||||
|
|
||||||
|
export type BudgetStatus = "exceeded" | "critical";
|
||||||
|
|
||||||
|
export type DashboardNotificationStateFields = {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
href: string;
|
||||||
|
isRead: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
readAt: Date | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardNotification = {
|
||||||
|
type: "invoice" | "boleto";
|
||||||
|
name: string;
|
||||||
|
dueDate: string;
|
||||||
|
status: NotificationType;
|
||||||
|
amount: number;
|
||||||
|
period?: string;
|
||||||
|
showAmount: boolean;
|
||||||
|
cardLogo?: string | null;
|
||||||
|
} & DashboardNotificationStateFields;
|
||||||
|
|
||||||
|
export type BudgetNotification = {
|
||||||
|
categoryName: string;
|
||||||
|
budgetAmount: number;
|
||||||
|
spentAmount: number;
|
||||||
|
usedPercentage: number;
|
||||||
|
status: BudgetStatus;
|
||||||
|
} & DashboardNotificationStateFields;
|
||||||
|
|
||||||
|
export type DashboardNotificationsSnapshot = {
|
||||||
|
notifications: DashboardNotification[];
|
||||||
|
budgetNotifications: BudgetNotification[];
|
||||||
|
unreadCount: number;
|
||||||
|
visibleCount: number;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user