mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
Remove infraestrutura de series recorrentes
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { Config } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
CREATE TABLE "recurring_series" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"status" text DEFAULT 'active' NOT NULL,
|
|
||||||
"day_of_month" smallint NOT NULL,
|
|
||||||
"last_generated_period" text NOT NULL,
|
|
||||||
"template_data" jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "lancamentos" ADD COLUMN "recurring_series_id" uuid;--> statement-breakpoint
|
|
||||||
ALTER TABLE "recurring_series" ADD CONSTRAINT "recurring_series_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "recurring_series_user_id_status_idx" ON "recurring_series" USING btree ("user_id","status");--> statement-breakpoint
|
|
||||||
ALTER TABLE "lancamentos" ADD CONSTRAINT "lancamentos_recurring_series_id_recurring_series_id_fk" FOREIGN KEY ("recurring_series_id") REFERENCES "public"."recurring_series"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -134,13 +134,6 @@
|
|||||||
"when": 1773020417482,
|
"when": 1773020417482,
|
||||||
"tag": "0018_rainy_epoch",
|
"tag": "0018_rainy_epoch",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 19,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1773265586360,
|
|
||||||
"tag": "0019_parched_mephistopheles",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard
|
|||||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||||
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||||
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
|
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
|
||||||
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
|
|
||||||
import {
|
import {
|
||||||
buildOptionSets,
|
buildOptionSets,
|
||||||
buildSluggedFilters,
|
buildSluggedFilters,
|
||||||
@@ -25,7 +24,6 @@ type PageProps = {
|
|||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
await triggerRecurringGeneration(user.id);
|
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
|
|
||||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||||
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
||||||
import {
|
import {
|
||||||
buildTransactionWhere,
|
|
||||||
buildOptionSets,
|
buildOptionSets,
|
||||||
buildSluggedFilters,
|
buildSluggedFilters,
|
||||||
buildSlugMaps,
|
buildSlugMaps,
|
||||||
|
buildTransactionWhere,
|
||||||
extractTransactionSearchFilters,
|
extractTransactionSearchFilters,
|
||||||
getSingleParam,
|
getSingleParam,
|
||||||
mapTransactionsData,
|
mapTransactionsData,
|
||||||
@@ -28,7 +27,6 @@ type PageProps = {
|
|||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
await triggerRecurringGeneration(userId);
|
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|
||||||
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -562,55 +562,6 @@ export const installmentAnticipations = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================== RECURRING SERIES =====================
|
|
||||||
|
|
||||||
export type RecurringSeriesTemplate = {
|
|
||||||
name: string;
|
|
||||||
amount: string;
|
|
||||||
transactionType: string;
|
|
||||||
paymentMethod: string;
|
|
||||||
categoryId: string | null;
|
|
||||||
accountId: string | null;
|
|
||||||
cardId: string | null;
|
|
||||||
payerId: string | null;
|
|
||||||
note: string | null;
|
|
||||||
condition: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const recurringSeries = pgTable(
|
|
||||||
"recurring_series",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
|
||||||
status: text("status").notNull().default("active"), // "active" | "paused" | "cancelled"
|
|
||||||
dayOfMonth: smallint("day_of_month").notNull(),
|
|
||||||
lastGeneratedPeriod: text("last_generated_period").notNull(), // YYYY-MM
|
|
||||||
templateData: jsonb("template_data")
|
|
||||||
.notNull()
|
|
||||||
.$type<RecurringSeriesTemplate>(),
|
|
||||||
createdAt: timestamp("created_at", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
userIdStatusIdx: index("recurring_series_user_id_status_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===================== TRANSACTIONS =====================
|
// ===================== TRANSACTIONS =====================
|
||||||
|
|
||||||
export const transactions = pgTable(
|
export const transactions = pgTable(
|
||||||
@@ -664,10 +615,6 @@ export const transactions = pgTable(
|
|||||||
}),
|
}),
|
||||||
seriesId: uuid("series_id"),
|
seriesId: uuid("series_id"),
|
||||||
transferId: uuid("transfer_id"),
|
transferId: uuid("transfer_id"),
|
||||||
recurringSeriesId: uuid("recurring_series_id").references(
|
|
||||||
() => recurringSeries.id,
|
|
||||||
{ onDelete: "set null" },
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
// Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard)
|
// Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard)
|
||||||
@@ -722,7 +669,6 @@ export const userRelations = relations(user, ({ many, one }) => ({
|
|||||||
installmentAnticipations: many(installmentAnticipations),
|
installmentAnticipations: many(installmentAnticipations),
|
||||||
apiTokens: many(apiTokens),
|
apiTokens: many(apiTokens),
|
||||||
inboxItems: many(inboxItems),
|
inboxItems: many(inboxItems),
|
||||||
recurringSeries: many(recurringSeries),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
@@ -876,23 +822,8 @@ export const transactionsRelations = relations(transactions, ({ one }) => ({
|
|||||||
fields: [transactions.anticipationId],
|
fields: [transactions.anticipationId],
|
||||||
references: [installmentAnticipations.id],
|
references: [installmentAnticipations.id],
|
||||||
}),
|
}),
|
||||||
recurringSeries: one(recurringSeries, {
|
|
||||||
fields: [transactions.recurringSeriesId],
|
|
||||||
references: [recurringSeries.id],
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const recurringSeriesRelations = relations(
|
|
||||||
recurringSeries,
|
|
||||||
({ one, many }) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [recurringSeries.userId],
|
|
||||||
references: [user.id],
|
|
||||||
}),
|
|
||||||
transactions: many(transactions),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const installmentAnticipationsRelations = relations(
|
export const installmentAnticipationsRelations = relations(
|
||||||
installmentAnticipations,
|
installmentAnticipations,
|
||||||
({ one, many }) => ({
|
({ one, many }) => ({
|
||||||
@@ -938,5 +869,3 @@ export type ApiToken = typeof apiTokens.$inferSelect;
|
|||||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||||
export type RecurringSeries = typeof recurringSeries.$inferSelect;
|
|
||||||
export type NewRecurringSeries = typeof recurringSeries.$inferInsert;
|
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
RiPauseCircleLine,
|
|
||||||
RiPlayCircleLine,
|
|
||||||
RiRefreshLine,
|
|
||||||
RiStopCircleLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { RecurringSeriesData } from "@/features/dashboard/recurring/recurring-series-queries";
|
|
||||||
import {
|
|
||||||
cancelRecurringSeriesAction,
|
|
||||||
pauseRecurringSeriesAction,
|
|
||||||
resumeRecurringSeriesAction,
|
|
||||||
} from "@/features/recurring/actions";
|
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
|
||||||
import { formatMonthYearLabel } from "@/shared/utils/period";
|
|
||||||
|
|
||||||
type RecurringSeriesWidgetProps = {
|
|
||||||
data: RecurringSeriesData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RecurringSeriesWidget({ data }: RecurringSeriesWidgetProps) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
if (data.series.length === 0) {
|
|
||||||
return (
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhuma série recorrente"
|
|
||||||
description="Séries recorrentes aparecerão aqui quando forem criadas."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePause = (seriesId: string) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await pauseRecurringSeriesAction({ seriesId });
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = (seriesId: string) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await resumeRecurringSeriesAction({ seriesId });
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = (seriesId: string) => {
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
"Tem certeza que deseja cancelar esta série recorrente? Lançamentos passados serão mantidos.",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await cancelRecurringSeriesAction({ seriesId });
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 px-0">
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{data.series.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<p className="truncate text-foreground text-sm font-medium">
|
|
||||||
{item.name}
|
|
||||||
</p>
|
|
||||||
<Badge
|
|
||||||
variant={item.status === "active" ? "default" : "secondary"}
|
|
||||||
className="shrink-0 text-[10px] px-1.5 py-0"
|
|
||||||
>
|
|
||||||
{item.status === "active" ? "Ativo" : "Pausado"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<MoneyValues amount={item.amount} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
|
|
||||||
<span>
|
|
||||||
Dia {item.dayOfMonth} · {item.paymentMethod}
|
|
||||||
{item.categoryName ? ` · ${item.categoryName}` : ""}
|
|
||||||
</span>
|
|
||||||
<span>Próx: {formatMonthYearLabel(item.nextPeriod)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 mt-1.5">
|
|
||||||
{item.status === "active" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => handlePause(item.id)}
|
|
||||||
>
|
|
||||||
<RiPauseCircleLine className="size-3.5 mr-1" />
|
|
||||||
Pausar
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => handleResume(item.id)}
|
|
||||||
>
|
|
||||||
<RiPlayCircleLine className="size-3.5 mr-1" />
|
|
||||||
Continuar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => handleCancel(item.id)}
|
|
||||||
>
|
|
||||||
<RiStopCircleLine className="size-3.5 mr-1" />
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import { fetchPaymentConditions } from "./payments/payment-conditions-queries";
|
|||||||
import { fetchPaymentMethods } from "./payments/payment-methods-queries";
|
import { fetchPaymentMethods } from "./payments/payment-methods-queries";
|
||||||
import { fetchPaymentStatus } from "./payments/payment-status-queries";
|
import { fetchPaymentStatus } from "./payments/payment-status-queries";
|
||||||
import { fetchPurchasesByCategory } from "./purchases-by-category-queries";
|
import { fetchPurchasesByCategory } from "./purchases-by-category-queries";
|
||||||
import { fetchRecurringSeries } from "./recurring/recurring-series-queries";
|
|
||||||
import { fetchTopEstablishments } from "./top-establishments-queries";
|
import { fetchTopEstablishments } from "./top-establishments-queries";
|
||||||
|
|
||||||
async function fetchDashboardDataInternal(userId: string, period: string) {
|
async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||||
@@ -40,7 +39,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
purchasesByCategoryData,
|
purchasesByCategoryData,
|
||||||
incomeByCategoryData,
|
incomeByCategoryData,
|
||||||
expensesByCategoryData,
|
expensesByCategoryData,
|
||||||
recurringSeriesData,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchDashboardCardMetrics(userId, period),
|
fetchDashboardCardMetrics(userId, period),
|
||||||
fetchDashboardAccounts(userId),
|
fetchDashboardAccounts(userId),
|
||||||
@@ -61,7 +59,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
fetchPurchasesByCategory(userId, period),
|
fetchPurchasesByCategory(userId, period),
|
||||||
fetchIncomeByCategory(userId, period),
|
fetchIncomeByCategory(userId, period),
|
||||||
fetchExpensesByCategory(userId, period),
|
fetchExpensesByCategory(userId, period),
|
||||||
fetchRecurringSeries(userId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,7 +81,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
purchasesByCategoryData,
|
purchasesByCategoryData,
|
||||||
incomeByCategoryData,
|
incomeByCategoryData,
|
||||||
expensesByCategoryData,
|
expensesByCategoryData,
|
||||||
recurringSeriesData,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import type { RecurringSeriesTemplate } from "@/db/schema";
|
|
||||||
import { categories, recurringSeries } from "@/db/schema";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
|
||||||
import { addMonthsToPeriod } from "@/shared/utils/period";
|
|
||||||
|
|
||||||
export type RecurringSeriesItem = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
amount: number;
|
|
||||||
categoryName: string | null;
|
|
||||||
categoryIcon: string | null;
|
|
||||||
paymentMethod: string;
|
|
||||||
dayOfMonth: number;
|
|
||||||
status: "active" | "paused" | "cancelled";
|
|
||||||
nextPeriod: string;
|
|
||||||
lastGeneratedPeriod: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecurringSeriesData = {
|
|
||||||
series: RecurringSeriesItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchRecurringSeries(
|
|
||||||
userId: string,
|
|
||||||
): Promise<RecurringSeriesData> {
|
|
||||||
const adminPayerId = await getAdminPayerId(userId);
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: recurringSeries.id,
|
|
||||||
status: recurringSeries.status,
|
|
||||||
dayOfMonth: recurringSeries.dayOfMonth,
|
|
||||||
lastGeneratedPeriod: recurringSeries.lastGeneratedPeriod,
|
|
||||||
templateData: recurringSeries.templateData,
|
|
||||||
})
|
|
||||||
.from(recurringSeries)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recurringSeries.userId, userId),
|
|
||||||
inArray(recurringSeries.status, ["active", "paused"]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return { series: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch category names for all series in one query
|
|
||||||
const categoryIds = rows
|
|
||||||
.map((r) => (r.templateData as RecurringSeriesTemplate).categoryId)
|
|
||||||
.filter((id): id is string => id !== null);
|
|
||||||
|
|
||||||
const categoryMap = new Map<string, { name: string; icon: string | null }>();
|
|
||||||
if (categoryIds.length > 0) {
|
|
||||||
const cats = await db
|
|
||||||
.select({
|
|
||||||
id: categories.id,
|
|
||||||
name: categories.name,
|
|
||||||
icon: categories.icon,
|
|
||||||
})
|
|
||||||
.from(categories)
|
|
||||||
.where(inArray(categories.id, categoryIds));
|
|
||||||
for (const cat of cats) {
|
|
||||||
categoryMap.set(cat.id, { name: cat.name, icon: cat.icon });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const series = rows
|
|
||||||
.filter((row) => {
|
|
||||||
// If admin pagador exists, only show series belonging to admin
|
|
||||||
if (!adminPayerId) return true;
|
|
||||||
const template = row.templateData as RecurringSeriesTemplate;
|
|
||||||
return template.payerId === adminPayerId || template.payerId === null;
|
|
||||||
})
|
|
||||||
.map((row): RecurringSeriesItem => {
|
|
||||||
const template = row.templateData as RecurringSeriesTemplate;
|
|
||||||
const category = template.categoryId
|
|
||||||
? categoryMap.get(template.categoryId)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: template.name,
|
|
||||||
amount: Math.abs(toNumber(template.amount)),
|
|
||||||
categoryName: category?.name ?? null,
|
|
||||||
categoryIcon: category?.icon ?? null,
|
|
||||||
paymentMethod: template.paymentMethod,
|
|
||||||
dayOfMonth: row.dayOfMonth,
|
|
||||||
status: row.status as "active" | "paused",
|
|
||||||
nextPeriod: addMonthsToPeriod(row.lastGeneratedPeriod, 1),
|
|
||||||
lastGeneratedPeriod: row.lastGeneratedPeriod,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { series };
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ import { PaymentOverviewWidget } from "@/features/dashboard/components/payment-o
|
|||||||
import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget";
|
import { PaymentStatusWidget } from "@/features/dashboard/components/payment-status-widget";
|
||||||
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
|
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
|
||||||
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
||||||
import { RecurringSeriesWidget } from "@/features/dashboard/components/recurring-series-widget";
|
|
||||||
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
||||||
import type { DashboardData } from "../fetch-dashboard-data";
|
import type { DashboardData } from "../fetch-dashboard-data";
|
||||||
|
|
||||||
@@ -164,15 +163,6 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "recurring-series",
|
|
||||||
title: "Séries Recorrentes",
|
|
||||||
subtitle: "Gerencie seus lançamentos recorrentes",
|
|
||||||
icon: <RiRefreshLine className="size-4" />,
|
|
||||||
component: ({ data }) => (
|
|
||||||
<RecurringSeriesWidget data={data.recurringSeriesData} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "installment-expenses",
|
id: "installment-expenses",
|
||||||
title: "Lançamentos Parcelados",
|
title: "Lançamentos Parcelados",
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { recurringSeries } from "@/db/schema";
|
|
||||||
import { generateRecurringTransactions } from "@/features/recurring/generate-recurring";
|
|
||||||
import {
|
|
||||||
handleActionError,
|
|
||||||
revalidateForEntity,
|
|
||||||
} from "@/shared/lib/actions/helpers";
|
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
import { recurringSeriesActionSchema } from "@/shared/lib/schemas/recurring-series";
|
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
|
||||||
|
|
||||||
const revalidate = () => revalidateForEntity("recurring");
|
|
||||||
|
|
||||||
async function findRecurringSeriesForUser(userId: string, seriesId: string) {
|
|
||||||
const [series] = await db
|
|
||||||
.select({
|
|
||||||
id: recurringSeries.id,
|
|
||||||
status: recurringSeries.status,
|
|
||||||
})
|
|
||||||
.from(recurringSeries)
|
|
||||||
.where(
|
|
||||||
and(eq(recurringSeries.id, seriesId), eq(recurringSeries.userId, userId)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return series ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pauseRecurringSeriesAction(input: {
|
|
||||||
seriesId: string;
|
|
||||||
}): Promise<ActionResult> {
|
|
||||||
try {
|
|
||||||
const user = await getUser();
|
|
||||||
const data = recurringSeriesActionSchema.parse(input);
|
|
||||||
|
|
||||||
const existing = await findRecurringSeriesForUser(user.id, data.seriesId);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return { success: false, error: "Série recorrente não encontrada." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.status !== "active") {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Apenas séries ativas podem ser pausadas.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(recurringSeries)
|
|
||||||
.set({ status: "paused", updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recurringSeries.id, data.seriesId),
|
|
||||||
eq(recurringSeries.userId, user.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
return { success: true, message: "Série recorrente pausada." };
|
|
||||||
} catch (error) {
|
|
||||||
return handleActionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resumeRecurringSeriesAction(input: {
|
|
||||||
seriesId: string;
|
|
||||||
}): Promise<ActionResult> {
|
|
||||||
try {
|
|
||||||
const user = await getUser();
|
|
||||||
const data = recurringSeriesActionSchema.parse(input);
|
|
||||||
|
|
||||||
const existing = await findRecurringSeriesForUser(user.id, data.seriesId);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return { success: false, error: "Série recorrente não encontrada." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.status !== "paused") {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Apenas séries pausadas podem ser retomadas.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(recurringSeries)
|
|
||||||
.set({ status: "active", updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recurringSeries.id, data.seriesId),
|
|
||||||
eq(recurringSeries.userId, user.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger catch-up generation for missed months
|
|
||||||
await generateRecurringTransactions(user.id);
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
return { success: true, message: "Série recorrente retomada." };
|
|
||||||
} catch (error) {
|
|
||||||
return handleActionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelRecurringSeriesAction(input: {
|
|
||||||
seriesId: string;
|
|
||||||
}): Promise<ActionResult> {
|
|
||||||
try {
|
|
||||||
const user = await getUser();
|
|
||||||
const data = recurringSeriesActionSchema.parse(input);
|
|
||||||
|
|
||||||
const existing = await findRecurringSeriesForUser(user.id, data.seriesId);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return { success: false, error: "Série recorrente não encontrada." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.status === "cancelled") {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Esta série já está cancelada.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(recurringSeries)
|
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recurringSeries.id, data.seriesId),
|
|
||||||
eq(recurringSeries.userId, user.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
return { success: true, message: "Série recorrente cancelada." };
|
|
||||||
} catch (error) {
|
|
||||||
return handleActionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { recurringSeries, transactions } from "@/db/schema";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
import {
|
|
||||||
addMonthsToPeriod,
|
|
||||||
comparePeriods,
|
|
||||||
getCurrentPeriod,
|
|
||||||
getNextPeriod,
|
|
||||||
parsePeriod,
|
|
||||||
} from "@/shared/utils/period";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the purchase date for a given period and day of month.
|
|
||||||
* Clamps to last day of month for short months (e.g., Feb 30 → Feb 28).
|
|
||||||
*/
|
|
||||||
function computePurchaseDate(period: string, dayOfMonth: number): Date {
|
|
||||||
const { year, month } = parsePeriod(period);
|
|
||||||
// month is 1-indexed, Date constructor expects 0-indexed
|
|
||||||
const lastDayOfMonth = new Date(year, month, 0).getDate();
|
|
||||||
const clampedDay = Math.min(dayOfMonth, lastDayOfMonth);
|
|
||||||
return new Date(year, month - 1, clampedDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates missing recurring transactions for a single user.
|
|
||||||
*
|
|
||||||
* For each active recurring series:
|
|
||||||
* 1. Determines which months are missing between lastGeneratedPeriod and current month
|
|
||||||
* 2. Creates lancamento rows for each missing month using the template data
|
|
||||||
* 3. Updates lastGeneratedPeriod on the series
|
|
||||||
*
|
|
||||||
* Uses a DB transaction for atomicity.
|
|
||||||
*/
|
|
||||||
export async function generateRecurringTransactions(
|
|
||||||
userId: string,
|
|
||||||
): Promise<{ generated: number }> {
|
|
||||||
const currentPeriod = getCurrentPeriod();
|
|
||||||
|
|
||||||
// Fetch all active recurring series for this user
|
|
||||||
const activeSeries = await db
|
|
||||||
.select()
|
|
||||||
.from(recurringSeries)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recurringSeries.userId, userId),
|
|
||||||
eq(recurringSeries.status, "active"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeSeries.length === 0) {
|
|
||||||
return { generated: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalGenerated = 0;
|
|
||||||
|
|
||||||
for (const series of activeSeries) {
|
|
||||||
// Determine missing periods: from lastGeneratedPeriod + 1 to currentPeriod
|
|
||||||
const startPeriod = getNextPeriod(series.lastGeneratedPeriod);
|
|
||||||
|
|
||||||
// If startPeriod is already past the current period, nothing to generate
|
|
||||||
if (comparePeriods(startPeriod, currentPeriod) > 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build list of periods to generate
|
|
||||||
const periodsToGenerate: string[] = [];
|
|
||||||
let iterPeriod = startPeriod;
|
|
||||||
while (comparePeriods(iterPeriod, currentPeriod) <= 0) {
|
|
||||||
periodsToGenerate.push(iterPeriod);
|
|
||||||
iterPeriod = addMonthsToPeriod(iterPeriod, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (periodsToGenerate.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = series.templateData;
|
|
||||||
|
|
||||||
// Create all transactions for missing periods in a transaction
|
|
||||||
await db.transaction(async (tx: typeof db) => {
|
|
||||||
const records = periodsToGenerate.map((period) => {
|
|
||||||
const purchaseDate = computePurchaseDate(period, series.dayOfMonth);
|
|
||||||
return {
|
|
||||||
name: template.name,
|
|
||||||
amount: template.amount,
|
|
||||||
transactionType: template.transactionType,
|
|
||||||
paymentMethod: template.paymentMethod,
|
|
||||||
condition: "Recorrente" as const,
|
|
||||||
categoryId: template.categoryId,
|
|
||||||
accountId: template.accountId,
|
|
||||||
cardId: template.cardId,
|
|
||||||
payerId: template.payerId,
|
|
||||||
note: template.note,
|
|
||||||
purchaseDate,
|
|
||||||
period,
|
|
||||||
isSettled: false,
|
|
||||||
recurrenceCount: null,
|
|
||||||
installmentCount: null,
|
|
||||||
currentInstallment: null,
|
|
||||||
isDivided: false,
|
|
||||||
userId,
|
|
||||||
seriesId: series.id,
|
|
||||||
recurringSeriesId: series.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.insert(transactions).values(records);
|
|
||||||
|
|
||||||
// Update lastGeneratedPeriod to the last period we generated
|
|
||||||
const lastPeriod =
|
|
||||||
periodsToGenerate[periodsToGenerate.length - 1] ??
|
|
||||||
series.lastGeneratedPeriod;
|
|
||||||
await tx
|
|
||||||
.update(recurringSeries)
|
|
||||||
.set({
|
|
||||||
lastGeneratedPeriod: lastPeriod,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(recurringSeries.id, series.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
totalGenerated += periodsToGenerate.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { generated: totalGenerated };
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { cache } from "react";
|
|
||||||
import { generateRecurringTransactions } from "./generate-recurring";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers recurring transaction generation for a user.
|
|
||||||
* Deduped per-request via React.cache to avoid multiple calls
|
|
||||||
* during the same server render (layout + page).
|
|
||||||
*
|
|
||||||
* Call this at the top of dashboard and lancamentos page server components.
|
|
||||||
*/
|
|
||||||
export const triggerRecurringGeneration = cache(
|
|
||||||
async (userId: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await generateRecurringTransactions(userId);
|
|
||||||
} catch (error) {
|
|
||||||
// Log but don't throw — generation failure should not block page render
|
|
||||||
console.error(
|
|
||||||
"[RecurringGeneration] Failed to generate recurring transactions:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// Re-export from schema for convenience
|
|
||||||
export type { RecurringSeriesTemplate } from "@/db/schema";
|
|
||||||
|
|
||||||
export type RecurringSeriesStatus = "active" | "paused" | "cancelled";
|
|
||||||
@@ -3,13 +3,11 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { RecurringSeriesTemplate } from "@/db/schema";
|
|
||||||
import {
|
import {
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
payers,
|
payers,
|
||||||
recurringSeries,
|
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -221,6 +219,22 @@ const refineLancamento = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.condition === "Recorrente") {
|
||||||
|
if (!data.recurrenceCount) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["recurrenceCount"],
|
||||||
|
message: "Informe por quantos meses a recorrência acontecerá.",
|
||||||
|
});
|
||||||
|
} else if (data.recurrenceCount < 2) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["recurrenceCount"],
|
||||||
|
message: "A recorrência deve ter ao menos dois meses.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.condition === "Parcelado") {
|
if (data.condition === "Parcelado") {
|
||||||
if (!data.installmentCount) {
|
if (!data.installmentCount) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -518,23 +532,33 @@ const buildLancamentoRecords = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.condition === "Recorrente") {
|
if (data.condition === "Recorrente") {
|
||||||
// For the new recurring model, only create 1 row (the current month)
|
const recurrenceTotal = data.recurrenceCount ?? 0;
|
||||||
// Future rows will be generated lazily by generateRecurringTransactions
|
|
||||||
shares.forEach((share) => {
|
for (let index = 0; index < recurrenceTotal; index += 1) {
|
||||||
const settled = resolveSettledValue(0);
|
const recurrencePeriod = addMonthsToPeriod(period, index);
|
||||||
records.push({
|
const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index);
|
||||||
...basePayload,
|
const recurrenceDueDate = dueDate
|
||||||
amount: centsToDecimalString(share.amountCents * amountSign),
|
? addMonthsToDate(dueDate, index)
|
||||||
payerId: share.payerId,
|
: null;
|
||||||
purchaseDate,
|
|
||||||
period,
|
shares.forEach((share) => {
|
||||||
isSettled: settled,
|
const settled = resolveSettledValue(index);
|
||||||
recurrenceCount: null,
|
records.push({
|
||||||
dueDate,
|
...basePayload,
|
||||||
boletoPaymentDate:
|
amount: centsToDecimalString(share.amountCents * amountSign),
|
||||||
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
payerId: share.payerId,
|
||||||
|
purchaseDate: recurrencePurchaseDate,
|
||||||
|
period: recurrencePeriod,
|
||||||
|
isSettled: settled,
|
||||||
|
recurrenceCount: recurrenceTotal,
|
||||||
|
dueDate: recurrenceDueDate,
|
||||||
|
boletoPaymentDate:
|
||||||
|
data.paymentMethod === "Boleto" && settled
|
||||||
|
? boletoPaymentDate
|
||||||
|
: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
@@ -661,42 +685,7 @@ export async function createTransactionAction(
|
|||||||
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx: typeof db) => {
|
await db.insert(transactions).values(records);
|
||||||
// If creating a recurring series, insert the series row first
|
|
||||||
if (data.condition === "Recorrente" && seriesId) {
|
|
||||||
const templateData: RecurringSeriesTemplate = {
|
|
||||||
name: data.name,
|
|
||||||
amount: centsToDecimalString(
|
|
||||||
Math.round(Math.abs(data.amount) * 100) *
|
|
||||||
(data.transactionType === "Despesa" ? -1 : 1),
|
|
||||||
),
|
|
||||||
transactionType: data.transactionType,
|
|
||||||
paymentMethod: data.paymentMethod,
|
|
||||||
categoryId: data.categoryId ?? null,
|
|
||||||
accountId: data.accountId ?? null,
|
|
||||||
cardId: data.cardId ?? null,
|
|
||||||
payerId: data.payerId ?? null,
|
|
||||||
note: data.note ?? null,
|
|
||||||
condition: "Recorrente",
|
|
||||||
};
|
|
||||||
|
|
||||||
await tx.insert(recurringSeries).values({
|
|
||||||
id: seriesId,
|
|
||||||
userId: user.id,
|
|
||||||
status: "active",
|
|
||||||
dayOfMonth: purchaseDate.getDate(),
|
|
||||||
lastGeneratedPeriod: period,
|
|
||||||
templateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Link lancamento records to the recurring series
|
|
||||||
for (const record of records) {
|
|
||||||
record.recurringSeriesId = seriesId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.insert(transactions).values(records);
|
|
||||||
});
|
|
||||||
|
|
||||||
const notificationEntries = buildEntriesByPayer(
|
const notificationEntries = buildEntriesByPayer(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
|
|||||||
@@ -101,14 +101,26 @@ export function ConditionSection({
|
|||||||
|
|
||||||
{showRecurrence ? (
|
{showRecurrence ? (
|
||||||
<div className="space-y-1 w-full md:w-1/2">
|
<div className="space-y-1 w-full md:w-1/2">
|
||||||
<Label htmlFor="recurrenceInfo">Recorrência</Label>
|
<Label htmlFor="recurrenceCount">Repetirá por</Label>
|
||||||
<p
|
<Select
|
||||||
id="recurrenceInfo"
|
value={formState.recurrenceCount}
|
||||||
className="text-xs text-muted-foreground rounded-md border border-dashed border-border p-2.5"
|
onValueChange={(value) => onFieldChange("recurrenceCount", value)}
|
||||||
>
|
>
|
||||||
Este lançamento será repetido todo mês automaticamente até ser
|
<SelectTrigger id="recurrenceCount" className="w-full">
|
||||||
pausado ou cancelado.
|
<SelectValue placeholder="Selecione">
|
||||||
</p>
|
{formState.recurrenceCount
|
||||||
|
? `${formState.recurrenceCount} meses`
|
||||||
|
: null}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[...Array(47)].map((_, index) => (
|
||||||
|
<SelectItem key={index + 2} value={String(index + 2)}>
|
||||||
|
{index + 2} meses
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiAddLine } from "@remixicon/react";
|
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -292,7 +292,10 @@ export function TransactionDialog({
|
|||||||
formState.condition === "Parcelado" && formState.installmentCount
|
formState.condition === "Parcelado" && formState.installmentCount
|
||||||
? Number(formState.installmentCount)
|
? Number(formState.installmentCount)
|
||||||
: undefined,
|
: undefined,
|
||||||
recurrenceCount: undefined,
|
recurrenceCount:
|
||||||
|
formState.condition === "Recorrente" && formState.recurrenceCount
|
||||||
|
? Number(formState.recurrenceCount)
|
||||||
|
: undefined,
|
||||||
dueDate:
|
dueDate:
|
||||||
formState.paymentMethod === "Boleto" && formState.dueDate
|
formState.paymentMethod === "Boleto" && formState.dueDate
|
||||||
? formState.dueDate
|
? formState.dueDate
|
||||||
@@ -375,7 +378,7 @@ export function TransactionDialog({
|
|||||||
const title =
|
const title =
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? isImportMode
|
? isImportMode
|
||||||
? "Importar para Minha FinancialAccount"
|
? "Importar para Minha Conta"
|
||||||
: isCopyMode
|
: isCopyMode
|
||||||
? "Copiar lançamento"
|
? "Copiar lançamento"
|
||||||
: isNewWithType
|
: isNewWithType
|
||||||
@@ -476,7 +479,7 @@ export function TransactionDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||||
<RiAddLine className="text-primary size-4 transition-transform duration-200" />
|
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
|
||||||
Condições e anotações
|
Condições e anotações
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-3 pt-3">
|
<CollapsibleContent className="space-y-3 pt-3">
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export const revalidateConfig = {
|
|||||||
notes: ["/notes", "/notes/archived", "/dashboard"],
|
notes: ["/notes", "/notes/archived", "/dashboard"],
|
||||||
transactions: ["/transactions", "/accounts"],
|
transactions: ["/transactions", "/accounts"],
|
||||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||||
recurring: ["/transactions", "/dashboard"],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Entities whose mutations should invalidate the dashboard cache */
|
/** Entities whose mutations should invalidate the dashboard cache */
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { uuidSchema } from "./common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema for pause/resume/cancel recurring series actions
|
|
||||||
*/
|
|
||||||
export const recurringSeriesActionSchema = z.object({
|
|
||||||
seriesId: uuidSchema("Série recorrente"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RecurringSeriesActionInput = z.infer<
|
|
||||||
typeof recurringSeriesActionSchema
|
|
||||||
>;
|
|
||||||
Reference in New Issue
Block a user