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")
}
model Setting {
key String @id
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("settings")
}
// Expenses
enum PaymentStatus {

View File

@@ -11,6 +11,7 @@ import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes";
import settingsRouter from "./modules/settings/settings.routes";
import telegramRouter from "./modules/telegram/telegram.router";
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/settings", settingsRouter);
app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter);
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 {
useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals,
useQuotes,
useSettings,
useWallets,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
@@ -131,15 +131,15 @@ export function DashboardPage() {
const bna = quotes?.find((q) => q.type === "BNA");
const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: settings } = useSettings();
const salaryMultiplier =
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
const isFetching = isLoading || isPending;
const { data: wallets } = useWallets();
@@ -173,9 +173,12 @@ export function DashboardPage() {
</p>
</div>
<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">
${priceFormatter.format(monthlyExpenses?.total ?? 0)}
$
{priceFormatter.format(
(belo ? Number(belo.buy) : 0) * Number(salaryMultiplier),
)}
</p>
</div>
<QuoteCard
@@ -202,12 +205,11 @@ export function DashboardPage() {
<h2 className="text-lg font-semibold">Billeteras</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{wallets.map((w) => (
<div
key={w.id}
className="rounded-lg border p-4"
>
<div 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/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">
{walletBalanceFormatter.format(w.balance)}
</p>

View File

@@ -339,7 +339,12 @@ export type Wallet = {
export type WalletMovement = {
id: number;
walletId: number;
type: "DEPOSIT" | "WITHDRAWAL" | "TRANSFER_IN" | "TRANSFER_OUT" | "ADJUSTMENT";
type:
| "DEPOSIT"
| "WITHDRAWAL"
| "TRANSFER_IN"
| "TRANSFER_OUT"
| "ADJUSTMENT";
amount: number;
description: string | null;
referenceWalletId: number | null;
@@ -390,10 +395,7 @@ export function deleteWallet(id: number): Promise<void> {
return mutator<void>(`/api/wallets/${id}`, "DELETE");
}
export function depositWallet(
id: number,
data: DepositInput,
): Promise<Wallet> {
export function depositWallet(id: number, data: DepositInput): Promise<Wallet> {
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");
}
export function getWalletMovements(
id: number,
): Promise<WalletMovement[]> {
export function getWalletMovements(id: number): Promise<WalletMovement[]> {
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
export type TelegramStatus = {

View File

@@ -6,23 +6,23 @@ import {
} from "@tanstack/react-query";
import {
type AdjustBalanceInput,
adjustBalance as adjustBalanceApi,
type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput,
type CreateWalletInput,
type DepositInput,
type TransferInput,
type WalletMovementsFilter,
createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi,
createWallet as createWalletApi,
type DepositInput,
deletePeriodicExpense as deletePeriodicExpenseApi,
deleteWallet as deleteWalletApi,
depositWallet as depositWalletApi,
adjustBalance as adjustBalanceApi,
fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi,
generateTelegramCode,
getDailyMinMax,
getDailyQuotes,
getExpenses,
generateTelegramCode,
getHistoricalMinMax,
getMonthlyPayedTotal,
getMonthlyTotals,
@@ -30,23 +30,25 @@ import {
getPeriodicExpenses,
getQuoteHistory,
getQuotes,
getSettings,
getTelegramStatus,
getTotalPending,
getWalletMovements,
getWalletMovementsFiltered,
getWallets,
setDefaultWallet as setDefaultWalletApi,
importExpenses,
importPeriodicExpenses,
type PayExpenseInput,
payExpense as payExpenseApi,
setDefaultWallet as setDefaultWalletApi,
type TransferInput,
transferWallet as transferWalletApi,
type UpdateExpenseInput,
updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
createWallet as createWalletApi,
updateSetting as updateSettingApi,
updateWallet as updateWalletApi,
deleteWallet as deleteWalletApi,
transferWallet as transferWalletApi,
type WalletMovementsFilter,
} from "./api";
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
export function useTelegramStatus() {

View File

@@ -1,12 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context";
import {
useGenerateTelegramCode,
useSettings,
useTelegramStatus,
useUpdateSetting,
} from "@/lib/queries";
export const Route = createFileRoute("/_authenticated/settings")({
@@ -126,11 +128,79 @@ function SettingsPage() {
<hr className="border-border" />
<SalaryMultiplierSection />
<TelegramSection />
</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() {
const { data: status, isLoading } = useTelegramStatus();
const {