Added estimaded salary to dashboard
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
value String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
// Expenses
|
||||
|
||||
enum PaymentStatus {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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 {
|
||||
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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user