Compare commits
1 Commits
09f00aa3b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4784242a35 |
@@ -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);
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
29
apps/backend/src/modules/settings/settings.handler.ts
Normal file
29
apps/backend/src/modules/settings/settings.handler.ts
Normal 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);
|
||||||
|
}
|
||||||
9
apps/backend/src/modules/settings/settings.routes.ts
Normal file
9
apps/backend/src/modules/settings/settings.routes.ts
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user