Compare commits

2 Commits

Author SHA1 Message Date
Jose Selesan
4784242a35 Added estimaded salary to dashboard 2026-07-22 12:31:58 -03:00
09f00aa3b9 Merge pull request 'feat/payment-wallets' (#5) from feat/payment-wallets into main
Reviewed-on: #5
2026-06-17 14:11:30 +00:00
9 changed files with 206 additions and 30 deletions

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
);
-- Seed
INSERT INTO "settings" ("key", "value", "createdAt", "updatedAt")
VALUES ('salary_multiplier', '4500', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

View File

@@ -122,6 +122,15 @@ model QuoteHistory {
@@map("quotes_history") @@map("quotes_history")
} }
model Setting {
key String @id
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("settings")
}
// Expenses // Expenses
enum PaymentStatus { enum PaymentStatus {

View File

@@ -11,6 +11,7 @@ import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes"; import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job"; import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes"; import quotesRouter from "./modules/quotes/quotes.routes";
import settingsRouter from "./modules/settings/settings.routes";
import telegramRouter from "./modules/telegram/telegram.router"; import telegramRouter from "./modules/telegram/telegram.router";
import walletsRouter from "./modules/wallets/wallets.routes"; import walletsRouter from "./modules/wallets/wallets.routes";
@@ -32,6 +33,7 @@ app.use("/api/*", async (c, next) => {
}); });
app.route("/api/quotes", quotesRouter); app.route("/api/quotes", quotesRouter);
app.route("/api/settings", settingsRouter);
app.route("/api", expensesRouter); app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter); app.route("/api/telegram", telegramRouter);
app.route("/api/wallets", walletsRouter); app.route("/api/wallets", walletsRouter);

View File

@@ -0,0 +1,29 @@
import type { Context } from "hono";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma";
export async function getSettings(c: Context) {
const cached = cache.get("settings:all");
if (cached) return c.json(cached);
const settings = await prisma.setting.findMany();
cache.set("settings:all", settings, 60_000);
return c.json(settings);
}
export async function updateSetting(c: Context) {
const body = await c.req.json<{ key: string; value: string }>();
if (!body.key || body.value === undefined) {
return c.json({ error: "key and value are required" }, 400);
}
const setting = await prisma.setting.upsert({
where: { key: body.key },
update: { value: body.value },
create: { key: body.key, value: body.value },
});
cache.invalidateByPrefix("settings:");
return c.json(setting);
}

View File

@@ -0,0 +1,9 @@
import { Hono } from "hono";
import { getSettings, updateSetting } from "./settings.handler";
const app = new Hono();
app.get("/", getSettings);
app.put("/", updateSetting);
export default app;

View File

@@ -9,9 +9,9 @@ import {
import type { Quote } from "@/lib/api"; import type { Quote } from "@/lib/api";
import { import {
useFetchQuotes, useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals, useMonthlyTotals,
useQuotes, useQuotes,
useSettings,
useWallets, useWallets,
} from "@/lib/queries"; } from "@/lib/queries";
import { RelativeTime } from "@/lib/time"; import { RelativeTime } from "@/lib/time";
@@ -131,15 +131,15 @@ export function DashboardPage() {
const bna = quotes?.find((q) => q.type === "BNA"); const bna = quotes?.find((q) => q.type === "BNA");
const now = new Date(); const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals( const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(), now.getFullYear(),
now.getMonth() + 1, now.getMonth() + 1,
); );
const { data: settings } = useSettings();
const salaryMultiplier =
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
const isFetching = isLoading || isPending; const isFetching = isLoading || isPending;
const { data: wallets } = useWallets(); const { data: wallets } = useWallets();
@@ -173,9 +173,12 @@ export function DashboardPage() {
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p> <p className="text-sm text-muted-foreground">Sueldo</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
${priceFormatter.format(monthlyExpenses?.total ?? 0)} $
{priceFormatter.format(
(belo ? Number(belo.buy) : 0) * Number(salaryMultiplier),
)}
</p> </p>
</div> </div>
<QuoteCard <QuoteCard
@@ -202,12 +205,11 @@ export function DashboardPage() {
<h2 className="text-lg font-semibold">Billeteras</h2> <h2 className="text-lg font-semibold">Billeteras</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{wallets.map((w) => ( {wallets.map((w) => (
<div <div key={w.id} className="rounded-lg border p-4">
key={w.id}
className="rounded-lg border p-4"
>
<p className="text-sm text-muted-foreground">{w.name}</p> <p className="text-sm text-muted-foreground">{w.name}</p>
<p className="text-sm text-muted-foreground/60 text-xs">{w.currency}</p> <p className="text-sm text-muted-foreground/60 text-xs">
{w.currency}
</p>
<p className="text-2xl font-bold tabular-nums mt-1"> <p className="text-2xl font-bold tabular-nums mt-1">
{walletBalanceFormatter.format(w.balance)} {walletBalanceFormatter.format(w.balance)}
</p> </p>

View File

@@ -339,7 +339,12 @@ export type Wallet = {
export type WalletMovement = { export type WalletMovement = {
id: number; id: number;
walletId: number; walletId: number;
type: "DEPOSIT" | "WITHDRAWAL" | "TRANSFER_IN" | "TRANSFER_OUT" | "ADJUSTMENT"; type:
| "DEPOSIT"
| "WITHDRAWAL"
| "TRANSFER_IN"
| "TRANSFER_OUT"
| "ADJUSTMENT";
amount: number; amount: number;
description: string | null; description: string | null;
referenceWalletId: number | null; referenceWalletId: number | null;
@@ -390,10 +395,7 @@ export function deleteWallet(id: number): Promise<void> {
return mutator<void>(`/api/wallets/${id}`, "DELETE"); return mutator<void>(`/api/wallets/${id}`, "DELETE");
} }
export function depositWallet( export function depositWallet(id: number, data: DepositInput): Promise<Wallet> {
id: number,
data: DepositInput,
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/deposit`, "POST", data); return mutator<Wallet>(`/api/wallets/${id}/deposit`, "POST", data);
} }
@@ -418,9 +420,7 @@ export function setDefaultWallet(id: number): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/default`, "PUT"); return mutator<Wallet>(`/api/wallets/${id}/default`, "PUT");
} }
export function getWalletMovements( export function getWalletMovements(id: number): Promise<WalletMovement[]> {
id: number,
): Promise<WalletMovement[]> {
return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`); return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`);
} }
@@ -447,6 +447,21 @@ export function getWalletMovementsFiltered(
); );
} }
// Settings
export type Setting = {
key: string;
value: string;
};
export function getSettings(): Promise<Setting[]> {
return fetcher<Setting[]>("/api/settings");
}
export function updateSetting(key: string, value: string): Promise<Setting> {
return mutator<Setting>("/api/settings", "PUT", { key, value });
}
// Telegram // Telegram
export type TelegramStatus = { export type TelegramStatus = {

View File

@@ -6,23 +6,23 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
type AdjustBalanceInput, type AdjustBalanceInput,
adjustBalance as adjustBalanceApi,
type CreateNonPeriodicExpenseInput, type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput, type CreatePeriodicExpenseInput,
type CreateWalletInput, type CreateWalletInput,
type DepositInput,
type TransferInput,
type WalletMovementsFilter,
createNonPeriodicExpense as createNonPeriodicExpenseApi, createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi,
createWallet as createWalletApi,
type DepositInput,
deletePeriodicExpense as deletePeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi,
deleteWallet as deleteWalletApi,
depositWallet as depositWalletApi, depositWallet as depositWalletApi,
adjustBalance as adjustBalanceApi,
fetchQuotes, fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi, generateMonthlyExpense as generateMonthlyExpenseApi,
generateTelegramCode,
getDailyMinMax, getDailyMinMax,
getDailyQuotes, getDailyQuotes,
getExpenses, getExpenses,
generateTelegramCode,
getHistoricalMinMax, getHistoricalMinMax,
getMonthlyPayedTotal, getMonthlyPayedTotal,
getMonthlyTotals, getMonthlyTotals,
@@ -30,23 +30,25 @@ import {
getPeriodicExpenses, getPeriodicExpenses,
getQuoteHistory, getQuoteHistory,
getQuotes, getQuotes,
getSettings,
getTelegramStatus, getTelegramStatus,
getTotalPending, getTotalPending,
getWalletMovements, getWalletMovements,
getWalletMovementsFiltered, getWalletMovementsFiltered,
getWallets, getWallets,
setDefaultWallet as setDefaultWalletApi,
importExpenses, importExpenses,
importPeriodicExpenses, importPeriodicExpenses,
type PayExpenseInput, type PayExpenseInput,
payExpense as payExpenseApi, payExpense as payExpenseApi,
setDefaultWallet as setDefaultWalletApi,
type TransferInput,
transferWallet as transferWalletApi,
type UpdateExpenseInput, type UpdateExpenseInput,
updateExpense as updateExpenseApi, updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi, updatePeriodicExpense as updatePeriodicExpenseApi,
createWallet as createWalletApi, updateSetting as updateSettingApi,
updateWallet as updateWalletApi, updateWallet as updateWalletApi,
deleteWallet as deleteWalletApi, type WalletMovementsFilter,
transferWallet as transferWalletApi,
} from "./api"; } from "./api";
export const quoteKeys = { export const quoteKeys = {
@@ -422,6 +424,31 @@ export function useWalletMovementsFiltered(filter: WalletMovementsFilter) {
}); });
} }
// Settings
export const settingKeys = {
all: ["settings"] as const,
};
export function useSettings() {
return useQuery({
queryKey: settingKeys.all,
queryFn: getSettings,
staleTime: 60_000,
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
updateSettingApi(key, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: settingKeys.all });
},
});
}
// Telegram // Telegram
export function useTelegramStatus() { export function useTelegramStatus() {

View File

@@ -1,12 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Check, Copy, RefreshCw } from "lucide-react"; import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { import {
useGenerateTelegramCode, useGenerateTelegramCode,
useSettings,
useTelegramStatus, useTelegramStatus,
useUpdateSetting,
} from "@/lib/queries"; } from "@/lib/queries";
export const Route = createFileRoute("/_authenticated/settings")({ export const Route = createFileRoute("/_authenticated/settings")({
@@ -126,11 +128,79 @@ function SettingsPage() {
<hr className="border-border" /> <hr className="border-border" />
<SalaryMultiplierSection />
<TelegramSection /> <TelegramSection />
</div> </div>
); );
} }
function SalaryMultiplierSection() {
const { data: settings, isLoading } = useSettings();
const updateSetting = useUpdateSetting();
const current =
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
const [localValue, setLocalValue] = useState(current);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (settings) {
const val = settings.find((s) => s.key === "salary_multiplier")?.value;
if (val) setLocalValue(val);
}
}, [settings]);
async function handleSave() {
setSuccess(false);
await updateSetting.mutateAsync({
key: "salary_multiplier",
value: localValue,
});
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">
Multiplicador de sueldo
</h2>
<p className="text-sm text-muted-foreground">
Valor para calcular el sueldo (Sueldo = BELO bid x este valor)
</p>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
type="number"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
className="w-32"
/>
<Button
type="button"
onClick={handleSave}
disabled={updateSetting.isPending || localValue === current}
>
{updateSetting.isPending ? "Guardando..." : "Guardar"}
</Button>
</div>
{success && (
<p className="text-sm text-emerald-600">
Multiplicador actualizado correctamente
</p>
)}
</div>
)}
</div>
);
}
function TelegramSection() { function TelegramSection() {
const { data: status, isLoading } = useTelegramStatus(); const { data: status, isLoading } = useTelegramStatus();
const { const {