chore: add Biome for lint and fix all lint errors
- Install @biomejs/biome as devDependency at root - Configure biome.json with 2-space indent, double quotes, Tailwind CSS support - Add lint/lint:fix/format/format:fix scripts to root and app package.json - Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button> - Fix noUnusedVariables: remove unused destructured vars - Fix useIterableCallbackReturn: arrow functions with block body - Fix noExplicitAny: recharts Tooltip formatters - Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels - Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays - Fix noArrayIndexKey: use error string as key - Fix CSS parse: enable tailwindDirectives parser - Normalize formatting across 80 files with biome check --write
This commit is contained in:
@@ -9,7 +9,9 @@
|
|||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
"prisma:seed": "bun run prisma/seed.ts"
|
"prisma:seed": "bun run prisma/seed.ts",
|
||||||
|
"lint": "biome check src/",
|
||||||
|
"lint:fix": "biome check --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-auth": "^1.6.11",
|
"better-auth": "^1.6.11",
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
|||||||
seed: "bun ./prisma/seed.ts",
|
seed: "bun ./prisma/seed.ts",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env["DATABASE_URL"],
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
import { PrismaClient } from "../src/generated/prisma/client";
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
|
||||||
|
|
||||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
|
const adapter = new PrismaPg({
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: required env var
|
||||||
|
connectionString: process.env.DATABASE_URL!,
|
||||||
|
});
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -26,6 +29,7 @@ async function main() {
|
|||||||
disableSignUp: false,
|
disableSignUp: false,
|
||||||
minPasswordLength: 4,
|
minPasswordLength: 4,
|
||||||
},
|
},
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: required env var
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret: process.env.BETTER_AUTH_SECRET!,
|
||||||
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { Hono } from "hono";
|
|||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { auth } from "./lib/auth";
|
import { auth } from "./lib/auth";
|
||||||
import { authMiddleware } from "./lib/auth-middleware";
|
import { authMiddleware } from "./lib/auth-middleware";
|
||||||
import { requestLogger } from "./lib/request-logger";
|
|
||||||
import { startQuoteJob } from "./modules/quotes/quote.job";
|
|
||||||
import quotesRouter from "./modules/quotes/quotes.routes";
|
|
||||||
import expensesRouter from "./modules/expenses/expenses.routes";
|
|
||||||
import { startExpenseJob } from "./modules/expenses/expense.job";
|
|
||||||
import { cache } from "./lib/cache";
|
import { cache } from "./lib/cache";
|
||||||
import { eventBus } from "./lib/event-bus";
|
import { eventBus } from "./lib/event-bus";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
|
import { requestLogger } from "./lib/request-logger";
|
||||||
|
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";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { betterAuth } from "better-auth";
|
|||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: required env vars
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET!;
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: required env vars
|
||||||
|
const baseURL = process.env.BETTER_AUTH_URL!;
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: "postgresql",
|
provider: "postgresql",
|
||||||
@@ -11,10 +16,7 @@ export const auth = betterAuth({
|
|||||||
disableSignUp: true,
|
disableSignUp: true,
|
||||||
minPasswordLength: 4,
|
minPasswordLength: 4,
|
||||||
},
|
},
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret,
|
||||||
baseURL: process.env.BETTER_AUTH_URL!,
|
baseURL,
|
||||||
trustedOrigins: [
|
trustedOrigins: [baseURL, "http://localhost:5173"],
|
||||||
process.env.BETTER_AUTH_URL!,
|
|
||||||
"http://localhost:5173",
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ class EventBus {
|
|||||||
if (!this.listeners.has(event)) {
|
if (!this.listeners.has(event)) {
|
||||||
this.listeners.set(event, new Set());
|
this.listeners.set(event, new Set());
|
||||||
}
|
}
|
||||||
this.listeners.get(event)!.add(listener);
|
this.listeners.get(event)?.add(listener);
|
||||||
return () => this.listeners.get(event)?.delete(listener);
|
return () => this.listeners.get(event)?.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(event: string, ...args: unknown[]): void {
|
emit(event: string, ...args: unknown[]): void {
|
||||||
this.listeners.get(event)?.forEach((listener) => listener(...args));
|
this.listeners.get(event)?.forEach((listener) => {
|
||||||
|
listener(...args);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,7 @@ import "dotenv/config";
|
|||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
import { PrismaClient } from "../generated/prisma/client";
|
import { PrismaClient } from "../generated/prisma/client";
|
||||||
|
|
||||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
|
// biome-ignore lint/style/noNonNullAssertion: required env var
|
||||||
|
const databaseUrl = process.env.DATABASE_URL!;
|
||||||
|
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||||
export const prisma = new PrismaClient({ adapter });
|
export const prisma = new PrismaClient({ adapter });
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
|
|
||||||
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
|
|
||||||
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
|
|
||||||
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
|
|
||||||
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
|
|
||||||
import { listExpensesHandler } from "./handlers/listExpenses";
|
|
||||||
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
|
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
|
||||||
import { payExpenseHandler } from "./handlers/payExpense";
|
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
|
||||||
|
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
|
||||||
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
|
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
|
||||||
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
|
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
|
||||||
import { getTotalPendingHandler } from "./handlers/getTotalPending";
|
|
||||||
import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses";
|
import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses";
|
||||||
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
|
import { getTotalPendingHandler } from "./handlers/getTotalPending";
|
||||||
import { importExpensesHandler } from "./handlers/importExpenses";
|
import { importExpensesHandler } from "./handlers/importExpenses";
|
||||||
|
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
|
||||||
|
import { listExpensesHandler } from "./handlers/listExpenses";
|
||||||
|
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
|
||||||
|
import { payExpenseHandler } from "./handlers/payExpense";
|
||||||
|
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
|
||||||
|
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -21,7 +21,10 @@ app.post("/periodic-expenses", createPeriodicExpenseHandler);
|
|||||||
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
||||||
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
||||||
app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
|
app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
|
||||||
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler);
|
app.post(
|
||||||
|
"/periodic-expenses/:id/generate-month",
|
||||||
|
generateMonthlyExpenseHandler,
|
||||||
|
);
|
||||||
|
|
||||||
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
|
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
|
||||||
app.get("/expenses/totals", getMonthlyTotalsHandler);
|
app.get("/expenses/totals", getMonthlyTotalsHandler);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "../../lib/prisma";
|
|
||||||
import { cache } from "../../lib/cache";
|
|
||||||
import { PaymentStatus } from "../../generated/prisma/client";
|
import { PaymentStatus } from "../../generated/prisma/client";
|
||||||
|
import { cache } from "../../lib/cache";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
|
||||||
export const createPeriodicExpenseSchema = z.object({
|
export const createPeriodicExpenseSchema = z.object({
|
||||||
description: z.string().min(1).max(50),
|
description: z.string().min(1).max(50),
|
||||||
@@ -27,7 +27,11 @@ function lastDayOfMonth(year: number, month: number): number {
|
|||||||
return new Date(year, month, 0).getDate();
|
return new Date(year, month, 0).getDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDueDate(year: number, month: number, dueDay: number): Date {
|
export function buildDueDate(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
dueDay: number,
|
||||||
|
): Date {
|
||||||
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
|
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
|
||||||
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
|
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
|
||||||
}
|
}
|
||||||
@@ -49,15 +53,20 @@ export async function listPeriodicExpenses() {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) {
|
export async function createPeriodicExpense(
|
||||||
|
data: z.infer<typeof createPeriodicExpenseSchema>,
|
||||||
|
) {
|
||||||
const result = await prisma.periodicExpense.create({ data });
|
const result = await prisma.periodicExpense.create({ data });
|
||||||
const { year, month } = getCurrentYearMonthUTC();
|
const { month } = getCurrentYearMonthUTC();
|
||||||
const currentMonthApplicable = data.periods.includes(month);
|
const currentMonthApplicable = data.periods.includes(month);
|
||||||
cache.invalidateByPrefix("expenses:");
|
cache.invalidateByPrefix("expenses:");
|
||||||
return { ...result, currentMonthApplicable };
|
return { ...result, currentMonthApplicable };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
|
export async function updatePeriodicExpense(
|
||||||
|
id: number,
|
||||||
|
data: z.infer<typeof updatePeriodicExpenseSchema>,
|
||||||
|
) {
|
||||||
const result = await prisma.periodicExpense.update({ where: { id }, data });
|
const result = await prisma.periodicExpense.update({ where: { id }, data });
|
||||||
cache.invalidateByPrefix("expenses:");
|
cache.invalidateByPrefix("expenses:");
|
||||||
return result;
|
return result;
|
||||||
@@ -73,7 +82,9 @@ export async function softDeletePeriodicExpense(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMonthlyExpense(id: number) {
|
export async function generateMonthlyExpense(id: number) {
|
||||||
const periodic = await prisma.periodicExpense.findUniqueOrThrow({ where: { id } });
|
const periodic = await prisma.periodicExpense.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
const { year, month } = getCurrentYearMonthUTC();
|
const { year, month } = getCurrentYearMonthUTC();
|
||||||
|
|
||||||
const existing = await prisma.expense.findFirst({
|
const existing = await prisma.expense.findFirst({
|
||||||
@@ -135,7 +146,9 @@ export async function listExpenses(params: {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
|
export async function createNonPeriodicExpense(
|
||||||
|
data: z.infer<typeof createNonPeriodicExpenseSchema>,
|
||||||
|
) {
|
||||||
const dueDate = new Date(data.dueDate);
|
const dueDate = new Date(data.dueDate);
|
||||||
const year = dueDate.getUTCFullYear();
|
const year = dueDate.getUTCFullYear();
|
||||||
const month = dueDate.getUTCMonth() + 1;
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
@@ -155,8 +168,13 @@ export async function createNonPeriodicExpense(data: z.infer<typeof createNonPer
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
|
export async function payExpense(
|
||||||
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
|
id: number,
|
||||||
|
data: z.infer<typeof payExpenseSchema>,
|
||||||
|
) {
|
||||||
|
const paymentDate = data.paymentDate
|
||||||
|
? new Date(data.paymentDate)
|
||||||
|
: new Date();
|
||||||
const result = await prisma.expense.update({
|
const result = await prisma.expense.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -189,7 +207,9 @@ export async function getMonthlyTotals(year: number, month: number) {
|
|||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<Array<{ payed: string | null; pending: string | null }>>`
|
const result = await prisma.$queryRaw<
|
||||||
|
Array<{ payed: string | null; pending: string | null }>
|
||||||
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed,
|
(SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed,
|
||||||
(SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
|
(SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
|
||||||
@@ -218,7 +238,17 @@ export async function getTotalPending() {
|
|||||||
|
|
||||||
export async function listPendingUpcomingExpenses() {
|
export async function listPendingUpcomingExpenses() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
|
const today = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
const fiveDaysLater = new Date(today);
|
const fiveDaysLater = new Date(today);
|
||||||
fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5);
|
fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5);
|
||||||
|
|
||||||
@@ -233,7 +263,8 @@ export async function listPendingUpcomingExpenses() {
|
|||||||
|
|
||||||
return data.map((expense) => ({
|
return data.map((expense) => ({
|
||||||
...expense,
|
...expense,
|
||||||
dueType: expense.dueDate < today ? "overdue" as const : "upcoming" as const,
|
dueType:
|
||||||
|
expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { createNonPeriodicExpense, createNonPeriodicExpenseSchema } from "../expenses.service";
|
import {
|
||||||
|
createNonPeriodicExpense,
|
||||||
|
createNonPeriodicExpenseSchema,
|
||||||
|
} from "../expenses.service";
|
||||||
|
|
||||||
export async function createNonPeriodicExpenseHandler(c: Context) {
|
export async function createNonPeriodicExpenseHandler(c: Context) {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { createPeriodicExpense, createPeriodicExpenseSchema } from "../expenses.service";
|
import {
|
||||||
|
createPeriodicExpense,
|
||||||
|
createPeriodicExpenseSchema,
|
||||||
|
} from "../expenses.service";
|
||||||
|
|
||||||
export async function createPeriodicExpenseHandler(c: Context) {
|
export async function createPeriodicExpenseHandler(c: Context) {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import type { Context } from "hono";
|
|||||||
import { getMonthlyPayedTotal } from "../expenses.service";
|
import { getMonthlyPayedTotal } from "../expenses.service";
|
||||||
|
|
||||||
export async function getMonthlyPayedTotalHandler(c: Context) {
|
export async function getMonthlyPayedTotalHandler(c: Context) {
|
||||||
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10);
|
const year = parseInt(
|
||||||
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10);
|
c.req.query("year") ?? String(new Date().getUTCFullYear()),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const month = parseInt(
|
||||||
|
c.req.query("month") ?? String(new Date().getUTCMonth() + 1),
|
||||||
|
10,
|
||||||
|
);
|
||||||
const result = await getMonthlyPayedTotal(year, month);
|
const result = await getMonthlyPayedTotal(year, month);
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import type { Context } from "hono";
|
|||||||
import { getMonthlyTotals } from "../expenses.service";
|
import { getMonthlyTotals } from "../expenses.service";
|
||||||
|
|
||||||
export async function getMonthlyTotalsHandler(c: Context) {
|
export async function getMonthlyTotalsHandler(c: Context) {
|
||||||
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10);
|
const year = parseInt(
|
||||||
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10);
|
c.req.query("year") ?? String(new Date().getUTCFullYear()),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const month = parseInt(
|
||||||
|
c.req.query("month") ?? String(new Date().getUTCMonth() + 1),
|
||||||
|
10,
|
||||||
|
);
|
||||||
const result = await getMonthlyTotals(year, month);
|
const result = await getMonthlyTotals(year, month);
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { PaymentStatus } from "../../../generated/prisma/client";
|
||||||
import { cache } from "../../../lib/cache";
|
import { cache } from "../../../lib/cache";
|
||||||
import { logger } from "../../../lib/logger";
|
import { logger } from "../../../lib/logger";
|
||||||
import { PaymentStatus } from "../../../generated/prisma/client";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
function parseAmount(raw: string): number | null {
|
function parseAmount(raw: string): number | null {
|
||||||
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
|
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
|
||||||
if (!cleaned) return null;
|
if (!cleaned) return null;
|
||||||
const n = Number(cleaned);
|
const n = Number(cleaned);
|
||||||
return isNaN(n) ? null : n;
|
return Number.isNaN(n) ? null : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDate(raw: string): Date | null {
|
function parseDate(raw: string): Date | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
const d = new Date(trimmed);
|
const d = new Date(trimmed);
|
||||||
return isNaN(d.getTime()) ? null : d;
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importExpensesHandler(c: Context) {
|
export async function importExpensesHandler(c: Context) {
|
||||||
const body = await c.req.parseBody();
|
const body = await c.req.parseBody();
|
||||||
const file = body["file"];
|
const file = body.file;
|
||||||
|
|
||||||
if (!file || !(file instanceof File)) {
|
if (!file || !(file instanceof File)) {
|
||||||
return c.json({ error: "No se envió ningún archivo" }, 400);
|
return c.json({ error: "No se envió ningún archivo" }, 400);
|
||||||
@@ -30,7 +30,10 @@ export async function importExpensesHandler(c: Context) {
|
|||||||
const lines = text.trim().split("\n");
|
const lines = text.trim().split("\n");
|
||||||
|
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400);
|
return c.json(
|
||||||
|
{ error: "El archivo está vacío o solo tiene encabezados" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
@@ -43,7 +46,9 @@ export async function importExpensesHandler(c: Context) {
|
|||||||
|
|
||||||
const parts = line.split("|");
|
const parts = line.split("|");
|
||||||
if (parts.length < 10) {
|
if (parts.length < 10) {
|
||||||
errors.push(`Línea ${i + 1}: formato inválido (${parts.length} columnas)`);
|
errors.push(
|
||||||
|
`Línea ${i + 1}: formato inválido (${parts.length} columnas)`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +73,9 @@ export async function importExpensesHandler(c: Context) {
|
|||||||
const paymentDate = parseDate(rawPaymentDate);
|
const paymentDate = parseDate(rawPaymentDate);
|
||||||
|
|
||||||
if (!dueDate) {
|
if (!dueDate) {
|
||||||
errors.push(`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`);
|
errors.push(
|
||||||
|
`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +97,8 @@ export async function importExpensesHandler(c: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING;
|
const status =
|
||||||
|
rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING;
|
||||||
|
|
||||||
await prisma.expense.create({
|
await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { cache } from "../../../lib/cache";
|
import { cache } from "../../../lib/cache";
|
||||||
import { logger } from "../../../lib/logger";
|
import { logger } from "../../../lib/logger";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
function parseArgentineAmount(raw: string): number {
|
function parseArgentineAmount(raw: string): number {
|
||||||
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
|
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
|
||||||
@@ -16,7 +16,7 @@ function parsePeriods(raw: string): number[] {
|
|||||||
|
|
||||||
export async function importPeriodicExpensesHandler(c: Context) {
|
export async function importPeriodicExpensesHandler(c: Context) {
|
||||||
const body = await c.req.parseBody();
|
const body = await c.req.parseBody();
|
||||||
const file = body["file"];
|
const file = body.file;
|
||||||
|
|
||||||
if (!file || !(file instanceof File)) {
|
if (!file || !(file instanceof File)) {
|
||||||
return c.json({ error: "No se envió ningún archivo" }, 400);
|
return c.json({ error: "No se envió ningún archivo" }, 400);
|
||||||
@@ -26,7 +26,10 @@ export async function importPeriodicExpensesHandler(c: Context) {
|
|||||||
const lines = text.trim().split("\n");
|
const lines = text.trim().split("\n");
|
||||||
|
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400);
|
return c.json(
|
||||||
|
{ error: "El archivo está vacío o solo tiene encabezados" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
@@ -71,8 +74,8 @@ export async function importPeriodicExpensesHandler(c: Context) {
|
|||||||
await prisma.periodicExpense.create({
|
await prisma.periodicExpense.create({
|
||||||
data: {
|
data: {
|
||||||
description,
|
description,
|
||||||
defaultDueDay: isNaN(defaultDueDay) ? 1 : defaultDueDay,
|
defaultDueDay: Number.isNaN(defaultDueDay) ? 1 : defaultDueDay,
|
||||||
defaultAmount: isNaN(defaultAmount) ? 0 : defaultAmount,
|
defaultAmount: Number.isNaN(defaultAmount) ? 0 : defaultAmount,
|
||||||
periods,
|
periods,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export async function listExpensesHandler(c: Context) {
|
|||||||
status,
|
status,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
periodicExpenseId: periodicExpenseId ? parseInt(periodicExpenseId, 10) : undefined,
|
periodicExpenseId: periodicExpenseId
|
||||||
|
? parseInt(periodicExpenseId, 10)
|
||||||
|
: undefined,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
});
|
});
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { updatePeriodicExpense, updatePeriodicExpenseSchema } from "../expenses.service";
|
import {
|
||||||
|
updatePeriodicExpense,
|
||||||
|
updatePeriodicExpenseSchema,
|
||||||
|
} from "../expenses.service";
|
||||||
|
|
||||||
export async function updatePeriodicExpenseHandler(c: Context) {
|
export async function updatePeriodicExpenseHandler(c: Context) {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export async function processBeloQuote(): Promise<void> {
|
|||||||
const diff = roundTo(sell - existingSell, 2);
|
const diff = roundTo(sell - existingSell, 2);
|
||||||
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
||||||
|
|
||||||
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BELO`);
|
logger.debug(
|
||||||
|
{ buy, sell, diferencia: diff, porcentaje: pct },
|
||||||
|
`Cotización BELO`,
|
||||||
|
);
|
||||||
|
|
||||||
const todayStart = startOfToday();
|
const todayStart = startOfToday();
|
||||||
|
|
||||||
@@ -62,7 +65,8 @@ export async function processBeloQuote(): Promise<void> {
|
|||||||
if (prevDayHistory) {
|
if (prevDayHistory) {
|
||||||
const prevSell = Number(prevDayHistory.sell);
|
const prevSell = Number(prevDayHistory.sell);
|
||||||
prevDayDiff = roundTo(sell - prevSell, 2);
|
prevDayDiff = roundTo(sell - prevSell, 2);
|
||||||
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
prevDayPct =
|
||||||
|
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.quote.update({
|
await prisma.quote.update({
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ interface DolaritoApiResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
|
export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
|
||||||
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", {
|
const response = await fetch(
|
||||||
|
"https://api.dolarito.ar/api/frontend/quotations/dolar",
|
||||||
|
{
|
||||||
credentials: "omit",
|
credentials: "omit",
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
@@ -25,7 +27,8 @@ export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
|
|||||||
referrer: "https://www.dolarito.ar/",
|
referrer: "https://www.dolarito.ar/",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
mode: "cors",
|
mode: "cors",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const data: DolaritoApiResponse = await response.json();
|
const data: DolaritoApiResponse = await response.json();
|
||||||
return { buy: data.informal.buy, sell: data.informal.sell };
|
return { buy: data.informal.buy, sell: data.informal.sell };
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,10 @@ export async function processBlueQuote(): Promise<void> {
|
|||||||
const diff = roundTo(sell - existingSell, 2);
|
const diff = roundTo(sell - existingSell, 2);
|
||||||
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
||||||
|
|
||||||
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BLUE`);
|
logger.debug(
|
||||||
|
{ buy, sell, diferencia: diff, porcentaje: pct },
|
||||||
|
`Cotización BLUE`,
|
||||||
|
);
|
||||||
|
|
||||||
const todayStart = startOfToday();
|
const todayStart = startOfToday();
|
||||||
|
|
||||||
@@ -76,7 +82,8 @@ export async function processBlueQuote(): Promise<void> {
|
|||||||
if (prevDayHistory) {
|
if (prevDayHistory) {
|
||||||
const prevSell = Number(prevDayHistory.sell);
|
const prevSell = Number(prevDayHistory.sell);
|
||||||
prevDayDiff = roundTo(sell - prevSell, 2);
|
prevDayDiff = roundTo(sell - prevSell, 2);
|
||||||
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
prevDayPct =
|
||||||
|
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.quote.update({
|
await prisma.quote.update({
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ interface DolaritoApiResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
|
export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
|
||||||
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", {
|
const response = await fetch(
|
||||||
|
"https://api.dolarito.ar/api/frontend/quotations/dolar",
|
||||||
|
{
|
||||||
credentials: "omit",
|
credentials: "omit",
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
@@ -25,7 +27,8 @@ export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
|
|||||||
referrer: "https://www.dolarito.ar/",
|
referrer: "https://www.dolarito.ar/",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
mode: "cors",
|
mode: "cors",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const data: DolaritoApiResponse = await response.json();
|
const data: DolaritoApiResponse = await response.json();
|
||||||
return { buy: data.oficial.buy, sell: data.oficial.sell };
|
return { buy: data.oficial.buy, sell: data.oficial.sell };
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,10 @@ export async function processBnaQuote(): Promise<void> {
|
|||||||
const diff = roundTo(sell - existingSell, 2);
|
const diff = roundTo(sell - existingSell, 2);
|
||||||
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
||||||
|
|
||||||
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BNA`);
|
logger.debug(
|
||||||
|
{ buy, sell, diferencia: diff, porcentaje: pct },
|
||||||
|
`Cotización BNA`,
|
||||||
|
);
|
||||||
|
|
||||||
const todayStart = startOfToday();
|
const todayStart = startOfToday();
|
||||||
|
|
||||||
@@ -76,7 +82,8 @@ export async function processBnaQuote(): Promise<void> {
|
|||||||
if (prevDayHistory) {
|
if (prevDayHistory) {
|
||||||
const prevSell = Number(prevDayHistory.sell);
|
const prevSell = Number(prevDayHistory.sell);
|
||||||
prevDayDiff = roundTo(sell - prevSell, 2);
|
prevDayDiff = roundTo(sell - prevSell, 2);
|
||||||
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
prevDayPct =
|
||||||
|
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.quote.update({
|
await prisma.quote.update({
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
|
import { eventBus } from "../../../lib/event-bus";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
import { processBeloQuote } from "../belo.service";
|
import { processBeloQuote } from "../belo.service";
|
||||||
import { processBlueQuote } from "../blue.service";
|
import { processBlueQuote } from "../blue.service";
|
||||||
import { processBnaQuote } from "../bna.service";
|
import { processBnaQuote } from "../bna.service";
|
||||||
import { logger } from "../../../lib/logger";
|
|
||||||
import { eventBus } from "../../../lib/event-bus";
|
|
||||||
|
|
||||||
export async function fetchQuotes(c: Context) {
|
export async function fetchQuotes(c: Context) {
|
||||||
const results: { type: string; status: string; error?: string }[] = [];
|
const results: { type: string; status: string; error?: string }[] = [];
|
||||||
|
|
||||||
for (const [name, fn] of [["BELO", processBeloQuote], ["BLUE", processBlueQuote], ["BNA", processBnaQuote]] as const) {
|
for (const [name, fn] of [
|
||||||
|
["BELO", processBeloQuote],
|
||||||
|
["BLUE", processBlueQuote],
|
||||||
|
["BNA", processBnaQuote],
|
||||||
|
] as const) {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
results.push({ type: name, status: "ok" });
|
results.push({ type: name, status: "ok" });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { cache } from "../../../lib/cache";
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
export async function getCurrentQuotes(c: Context) {
|
export async function getCurrentQuotes(c: Context) {
|
||||||
const cached = cache.get("quotes:current");
|
const cached = cache.get("quotes:current");
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { cache } from "../../../lib/cache";
|
|
||||||
import type { QuoteType } from "../../../generated/prisma/client";
|
import type { QuoteType } from "../../../generated/prisma/client";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
export async function getDailyMinMax(c: Context) {
|
export async function getDailyMinMax(c: Context) {
|
||||||
const rawType = c.req.param("type")!.toUpperCase();
|
const rawType = c.req.param("type")?.toUpperCase();
|
||||||
|
|
||||||
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
|
||||||
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDateParam = c.req.query("startDate");
|
const startDateParam = c.req.query("startDate");
|
||||||
@@ -23,7 +28,7 @@ export async function getDailyMinMax(c: Context) {
|
|||||||
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||||
: now;
|
: now;
|
||||||
|
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,13 +36,15 @@ export async function getDailyMinMax(c: Context) {
|
|||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return c.json(cached);
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<Array<{
|
const rows = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
date: string;
|
date: string;
|
||||||
minBuy: number;
|
minBuy: number;
|
||||||
maxBuy: number;
|
maxBuy: number;
|
||||||
minSell: number;
|
minSell: number;
|
||||||
maxSell: number;
|
maxSell: number;
|
||||||
}>>`
|
}>
|
||||||
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
DATE("timeStamp")::text AS date,
|
DATE("timeStamp")::text AS date,
|
||||||
MIN(buy::numeric)::float8 AS "minBuy",
|
MIN(buy::numeric)::float8 AS "minBuy",
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { cache } from "../../../lib/cache";
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
export async function getDailyQuotes(c: Context) {
|
export async function getDailyQuotes(c: Context) {
|
||||||
const rawType = c.req.param("type")!.toUpperCase();
|
const rawType = c.req.param("type")?.toUpperCase();
|
||||||
|
|
||||||
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
|
||||||
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDateParam = c.req.query("startDate");
|
const startDateParam = c.req.query("startDate");
|
||||||
@@ -22,7 +27,7 @@ export async function getDailyQuotes(c: Context) {
|
|||||||
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||||
: now;
|
: now;
|
||||||
|
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +35,13 @@ export async function getDailyQuotes(c: Context) {
|
|||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return c.json(cached);
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<Array<{
|
const rows = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
date: string;
|
date: string;
|
||||||
buy: number;
|
buy: number;
|
||||||
sell: number;
|
sell: number;
|
||||||
}>>`
|
}>
|
||||||
|
>`
|
||||||
SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM (
|
SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM (
|
||||||
SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell
|
SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell
|
||||||
FROM "quotes_history"
|
FROM "quotes_history"
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { cache } from "../../../lib/cache";
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
export async function getHistoricalMinMax(c: Context) {
|
export async function getHistoricalMinMax(c: Context) {
|
||||||
const rawType = c.req.param("type")!.toUpperCase();
|
const rawType = c.req.param("type")?.toUpperCase();
|
||||||
|
|
||||||
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
|
||||||
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `quotes:historical:minmax:${rawType}`;
|
const cacheKey = `quotes:historical:minmax:${rawType}`;
|
||||||
@@ -17,12 +22,14 @@ export async function getHistoricalMinMax(c: Context) {
|
|||||||
|
|
||||||
const type = rawType as string;
|
const type = rawType as string;
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<Array<{
|
const rows = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
minBuy: number;
|
minBuy: number;
|
||||||
maxBuy: number;
|
maxBuy: number;
|
||||||
minBuyDate: string;
|
minBuyDate: string;
|
||||||
maxBuyDate: string;
|
maxBuyDate: string;
|
||||||
}>>`
|
}>
|
||||||
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy",
|
(SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy",
|
||||||
(SELECT MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy",
|
(SELECT MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy",
|
||||||
@@ -30,7 +37,12 @@ export async function getHistoricalMinMax(c: Context) {
|
|||||||
(SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric DESC LIMIT 1) AS "maxBuyDate"
|
(SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric DESC LIMIT 1) AS "maxBuyDate"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = rows[0] ?? { minBuy: 0, maxBuy: 0, minBuyDate: null, maxBuyDate: null };
|
const result = rows[0] ?? {
|
||||||
|
minBuy: 0,
|
||||||
|
maxBuy: 0,
|
||||||
|
minBuyDate: null,
|
||||||
|
maxBuyDate: null,
|
||||||
|
};
|
||||||
|
|
||||||
cache.set(cacheKey, result);
|
cache.set(cacheKey, result);
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
|
||||||
import { cache } from "../../../lib/cache";
|
|
||||||
import type { QuoteType } from "../../../generated/prisma/client";
|
import type { QuoteType } from "../../../generated/prisma/client";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
export async function getQuoteHistory(c: Context) {
|
export async function getQuoteHistory(c: Context) {
|
||||||
const rawType = c.req.param("type")!.toUpperCase();
|
const rawType = c.req.param("type")?.toUpperCase();
|
||||||
|
|
||||||
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
|
||||||
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
return c.json(
|
||||||
|
{
|
||||||
|
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = rawType as QuoteType;
|
const type = rawType as QuoteType;
|
||||||
@@ -24,7 +29,7 @@ export async function getQuoteHistory(c: Context) {
|
|||||||
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||||
: now;
|
: now;
|
||||||
|
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export function quoteEvents(c: Context) {
|
|||||||
function cleanup() {
|
function cleanup() {
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
cleanups.forEach((fn) => fn());
|
cleanups.forEach((fn) => {
|
||||||
|
fn();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -43,7 +45,7 @@ export function quoteEvents(c: Context) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
Connection: "keep-alive",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
|
import { eventBus } from "../../lib/event-bus";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
import { processBeloQuote } from "./belo.service";
|
import { processBeloQuote } from "./belo.service";
|
||||||
import { processBlueQuote } from "./blue.service";
|
import { processBlueQuote } from "./blue.service";
|
||||||
import { processBnaQuote } from "./bna.service";
|
import { processBnaQuote } from "./bna.service";
|
||||||
import { logger } from "../../lib/logger";
|
|
||||||
import { eventBus } from "../../lib/event-bus";
|
|
||||||
|
|
||||||
async function safeProcess(
|
async function safeProcess(
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
|
|
||||||
import { fetchQuotes } from "./handlers/fetchQuotes";
|
import { fetchQuotes } from "./handlers/fetchQuotes";
|
||||||
import { getQuoteHistory } from "./handlers/getQuoteHistory";
|
import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
|
||||||
import { getDailyQuotes } from "./handlers/getDailyQuotes";
|
|
||||||
import { getDailyMinMax } from "./handlers/getDailyMinMax";
|
import { getDailyMinMax } from "./handlers/getDailyMinMax";
|
||||||
|
import { getDailyQuotes } from "./handlers/getDailyQuotes";
|
||||||
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
|
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
|
||||||
|
import { getQuoteHistory } from "./handlers/getQuoteHistory";
|
||||||
import { quoteEvents } from "./handlers/quoteEvents";
|
import { quoteEvents } from "./handlers/quoteEvents";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { PaymentStatus } from "../src/generated/prisma/client";
|
import { PaymentStatus } from "../src/generated/prisma/client";
|
||||||
|
|
||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
@@ -23,7 +23,9 @@ mock.module("../src/lib/prisma", () => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const model of Object.values(mockPrisma)) {
|
for (const model of Object.values(mockPrisma)) {
|
||||||
for (const fn of Object.values(model as Record<string, ReturnType<typeof mock>>)) {
|
for (const fn of Object.values(
|
||||||
|
model as Record<string, ReturnType<typeof mock>>,
|
||||||
|
)) {
|
||||||
fn.mockClear();
|
fn.mockClear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
createPeriodicExpenseSchema,
|
createPeriodicExpenseSchema,
|
||||||
updatePeriodicExpenseSchema,
|
|
||||||
createNonPeriodicExpenseSchema,
|
createNonPeriodicExpenseSchema,
|
||||||
payExpenseSchema,
|
payExpenseSchema,
|
||||||
buildDueDate,
|
buildDueDate,
|
||||||
@@ -54,7 +55,7 @@ describe("schemas", () => {
|
|||||||
const result = createPeriodicExpenseSchema.parse({
|
const result = createPeriodicExpenseSchema.parse({
|
||||||
description: "Gimnasio",
|
description: "Gimnasio",
|
||||||
defaultDueDay: 10,
|
defaultDueDay: 10,
|
||||||
defaultAmount: 1500.50,
|
defaultAmount: 1500.5,
|
||||||
periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||||
});
|
});
|
||||||
expect(result.description).toBe("Gimnasio");
|
expect(result.description).toBe("Gimnasio");
|
||||||
@@ -67,7 +68,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 10,
|
defaultDueDay: 10,
|
||||||
defaultAmount: 100,
|
defaultAmount: 100,
|
||||||
periods: [1],
|
periods: [1],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 10,
|
defaultDueDay: 10,
|
||||||
defaultAmount: 100,
|
defaultAmount: 100,
|
||||||
periods: [1],
|
periods: [1],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 0,
|
defaultDueDay: 0,
|
||||||
defaultAmount: 100,
|
defaultAmount: 100,
|
||||||
periods: [1],
|
periods: [1],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 32,
|
defaultDueDay: 32,
|
||||||
defaultAmount: 100,
|
defaultAmount: 100,
|
||||||
periods: [1],
|
periods: [1],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 15,
|
defaultDueDay: 15,
|
||||||
defaultAmount: 0,
|
defaultAmount: 0,
|
||||||
periods: [1],
|
periods: [1],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 15,
|
defaultDueDay: 15,
|
||||||
defaultAmount: 100,
|
defaultAmount: 100,
|
||||||
periods: [],
|
periods: [],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ describe("schemas", () => {
|
|||||||
defaultDueDay: 15,
|
defaultDueDay: 15,
|
||||||
defaultAmount: 100,
|
defaultAmount: 100,
|
||||||
periods: [0, 13],
|
periods: [0, 13],
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -154,7 +155,7 @@ describe("schemas", () => {
|
|||||||
description: "Ropa",
|
description: "Ropa",
|
||||||
amount: -100,
|
amount: -100,
|
||||||
dueDate: "2026-06-15T00:00:00.000Z",
|
dueDate: "2026-06-15T00:00:00.000Z",
|
||||||
})
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -175,9 +176,7 @@ describe("schemas", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("rejects zero amountPayed", () => {
|
test("rejects zero amountPayed", () => {
|
||||||
expect(() =>
|
expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow();
|
||||||
payExpenseSchema.parse({ amountPayed: 0 })
|
|
||||||
).toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -244,7 +243,12 @@ describe("createPeriodicExpense", () => {
|
|||||||
defaultAmount: 1500,
|
defaultAmount: 1500,
|
||||||
periods: [6],
|
periods: [6],
|
||||||
};
|
};
|
||||||
const created = { id: 1, ...input, isDeleted: false, createdAt: new Date() };
|
const created = {
|
||||||
|
id: 1,
|
||||||
|
...input,
|
||||||
|
isDeleted: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);
|
mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);
|
||||||
|
|
||||||
const result = await createPeriodicExpense(input);
|
const result = await createPeriodicExpense(input);
|
||||||
@@ -339,7 +343,11 @@ describe("listExpenses", () => {
|
|||||||
|
|
||||||
test("combines status, periodicExpenseId, and search filters", async () => {
|
test("combines status, periodicExpenseId, and search filters", async () => {
|
||||||
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
||||||
await listExpenses({ status: "PENDING", periodicExpenseId: 1, search: "Agua" });
|
await listExpenses({
|
||||||
|
status: "PENDING",
|
||||||
|
periodicExpenseId: 1,
|
||||||
|
search: "Agua",
|
||||||
|
});
|
||||||
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
status: "PENDING",
|
status: "PENDING",
|
||||||
@@ -406,13 +414,15 @@ describe("payExpense", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("defaults paymentDate to now when not provided", async () => {
|
test("defaults paymentDate to now when not provided", async () => {
|
||||||
mockPrisma.expense.update.mockImplementationOnce(async ({ where, data }) => ({
|
mockPrisma.expense.update.mockImplementationOnce(
|
||||||
|
async ({ where, data }) => ({
|
||||||
id: where.id,
|
id: where.id,
|
||||||
...data,
|
...data,
|
||||||
status: PaymentStatus.PAYED,
|
status: PaymentStatus.PAYED,
|
||||||
amountPayed: data.amountPayed,
|
amountPayed: data.amountPayed,
|
||||||
paymentDate: data.paymentDate,
|
paymentDate: data.paymentDate,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await payExpense(1, { amountPayed: 1500 });
|
const result = await payExpense(1, { amountPayed: 1500 });
|
||||||
expect(result.status).toBe(PaymentStatus.PAYED);
|
expect(result.status).toBe(PaymentStatus.PAYED);
|
||||||
@@ -424,7 +434,13 @@ describe("generateExpensesForCurrentMonth", () => {
|
|||||||
test("skips existing expenses to avoid duplicates", async () => {
|
test("skips existing expenses to avoid duplicates", async () => {
|
||||||
const { month } = getCurrentYearMonthUTC();
|
const { month } = getCurrentYearMonthUTC();
|
||||||
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([
|
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([
|
||||||
{ id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [month] },
|
{
|
||||||
|
id: 1,
|
||||||
|
description: "Gas",
|
||||||
|
defaultDueDay: 15,
|
||||||
|
defaultAmount: 2000,
|
||||||
|
periods: [month],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });
|
mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });
|
||||||
|
|
||||||
@@ -471,11 +487,23 @@ describe("generateExpensesForCurrentMonth", () => {
|
|||||||
|
|
||||||
describe("generateMonthlyExpense", () => {
|
describe("generateMonthlyExpense", () => {
|
||||||
test("creates expense for given periodic expense", async () => {
|
test("creates expense for given periodic expense", async () => {
|
||||||
const periodic = { id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6] };
|
const periodic = {
|
||||||
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(periodic);
|
id: 1,
|
||||||
|
description: "Gas",
|
||||||
|
defaultDueDay: 15,
|
||||||
|
defaultAmount: 2000,
|
||||||
|
periods: [6],
|
||||||
|
};
|
||||||
|
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(
|
||||||
|
periodic,
|
||||||
|
);
|
||||||
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
|
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
|
||||||
mockPrisma.expense.create.mockResolvedValueOnce({
|
mockPrisma.expense.create.mockResolvedValueOnce({
|
||||||
id: 1, description: "Gas", periodicExpenseId: 1, amount: 2000, status: "PENDING",
|
id: 1,
|
||||||
|
description: "Gas",
|
||||||
|
periodicExpenseId: 1,
|
||||||
|
amount: 2000,
|
||||||
|
status: "PENDING",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await generateMonthlyExpense(1);
|
const result = await generateMonthlyExpense(1);
|
||||||
@@ -485,7 +513,11 @@ describe("generateMonthlyExpense", () => {
|
|||||||
test("returns existing expense instead of duplicating", async () => {
|
test("returns existing expense instead of duplicating", async () => {
|
||||||
const existing = { id: 99, description: "Gas", periodicExpenseId: 1 };
|
const existing = { id: 99, description: "Gas", periodicExpenseId: 1 };
|
||||||
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({
|
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({
|
||||||
id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6],
|
id: 1,
|
||||||
|
description: "Gas",
|
||||||
|
defaultDueDay: 15,
|
||||||
|
defaultAmount: 2000,
|
||||||
|
periods: [6],
|
||||||
});
|
});
|
||||||
mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);
|
mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Personal Admin</title>
|
<title>Personal Admin</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(() => {
|
||||||
var theme = localStorage.getItem("theme");
|
var theme = localStorage.getItem("theme");
|
||||||
if (theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
if (theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "biome check src/",
|
||||||
|
"lint:fix": "biome check --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-auth": "^1.6.11",
|
"better-auth": "^1.6.11",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { router } from "@/router";
|
|
||||||
import { SseProvider } from "@/lib/sse-context";
|
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { SseProvider } from "@/lib/sse-context";
|
||||||
|
import { router } from "@/router";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Menu, Sun, Moon, Monitor, LogOut } from "lucide-react";
|
import { LogOut, Menu, Monitor, Moon, Sun } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useSseStatus } from "@/lib/sse-context";
|
import { useSseStatus } from "@/lib/sse-context";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Outlet } from "@tanstack/react-router";
|
import { Outlet } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LoadingBar } from "@/components/ui/loading-bar";
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { LoadingBar } from "@/components/ui/loading-bar";
|
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { FileText, LayoutDashboard, Settings, TrendingUp, Wallet } from "lucide-react";
|
import {
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Settings,
|
||||||
|
TrendingUp,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -22,9 +28,11 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<button
|
||||||
className="fixed inset-0 z-40 bg-black/20 md:hidden"
|
type="button"
|
||||||
|
className="fixed inset-0 z-40 bg-black/20 md:hidden cursor-default"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Cerrar menú"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
@@ -43,7 +51,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }}
|
activeProps={{
|
||||||
|
className:
|
||||||
|
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
|
||||||
|
}}
|
||||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
@@ -57,7 +68,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }}
|
activeProps={{
|
||||||
|
className:
|
||||||
|
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
|
||||||
|
}}
|
||||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { Slot } from "radix-ui";
|
||||||
import { Slot } from "radix-ui"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
@@ -38,8 +38,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -49,9 +49,9 @@ function Button({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -61,7 +61,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import {
|
||||||
import { DayPicker } from "react-day-picker"
|
ChevronDownIcon,
|
||||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
ChevronLeftIcon,
|
||||||
import { cn } from "@/lib/utils"
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
@@ -37,7 +41,8 @@ function Calendar({
|
|||||||
chevron: "size-3 text-muted-foreground",
|
chevron: "size-3 text-muted-foreground",
|
||||||
month_grid: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: "flex",
|
weekdays: "flex",
|
||||||
weekday: "w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center",
|
weekday:
|
||||||
|
"w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center",
|
||||||
week: "flex w-full",
|
week: "flex w-full",
|
||||||
day: "p-0 size-8 text-center text-sm",
|
day: "p-0 size-8 text-center text-sm",
|
||||||
day_button:
|
day_button:
|
||||||
@@ -57,31 +62,37 @@ function Calendar({
|
|||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
Chevron: (props) => {
|
Chevron: (props) => {
|
||||||
const { orientation, ...rest } = props
|
const { orientation, ...rest } = props;
|
||||||
if (orientation === "up" || orientation === "down") {
|
if (orientation === "up" || orientation === "down") {
|
||||||
return <ChevronDownIcon className="size-3" {...rest} />
|
return <ChevronDownIcon className="size-3" {...rest} />;
|
||||||
}
|
}
|
||||||
const Icon = orientation === "left" ? ChevronLeftIcon : ChevronRightIcon
|
const Icon =
|
||||||
return <Icon className="size-4" {...rest} />
|
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
|
||||||
|
return <Icon className="size-4" {...rest} />;
|
||||||
},
|
},
|
||||||
Select: (props) => {
|
Select: (props) => {
|
||||||
const { className, children, ...rest } = props
|
const { className, children, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={cn("h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat", className)}
|
className={cn(
|
||||||
style={{ backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")` }}
|
"h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Calendar.displayName = "Calendar"
|
Calendar.displayName = "Calendar";
|
||||||
|
|
||||||
export { Calendar }
|
export { Calendar };
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { format } from "date-fns";
|
||||||
import { format } from "date-fns"
|
import { es } from "date-fns/locale";
|
||||||
import { es } from "date-fns/locale"
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { CalendarIcon } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils"
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
value: Date | undefined
|
value: Date | undefined;
|
||||||
onChange: (date: Date | undefined) => void
|
onChange: (date: Date | undefined) => void;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DatePicker({
|
export function DatePicker({
|
||||||
@@ -25,7 +24,7 @@ export function DatePicker({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder = "Seleccionar fecha",
|
placeholder = "Seleccionar fecha",
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -34,7 +33,7 @@ export function DatePicker({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-full justify-start gap-2 px-2.5 font-normal",
|
"h-8 w-full justify-start gap-2 px-2.5 font-normal",
|
||||||
!value && "text-muted-foreground"
|
!value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="size-4 shrink-0" />
|
<CalendarIcon className="size-4 shrink-0" />
|
||||||
@@ -52,11 +51,11 @@ export function DatePicker({
|
|||||||
defaultMonth={value}
|
defaultMonth={value}
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
onChange(date)
|
onChange(date);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -9,11 +9,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { useRouterState } from "@tanstack/react-router";
|
|
||||||
import { useIsFetching, useIsMutating } from "@tanstack/react-query";
|
import { useIsFetching, useIsMutating } from "@tanstack/react-query";
|
||||||
|
import { useRouterState } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function LoadingBar() {
|
export function LoadingBar() {
|
||||||
const isNavigating = useRouterState({ select: (s) => s.status === "pending" });
|
const isNavigating = useRouterState({
|
||||||
|
select: (s) => s.status === "pending",
|
||||||
|
});
|
||||||
const isFetching = useIsFetching();
|
const isFetching = useIsFetching();
|
||||||
const isMutating = useIsMutating();
|
const isMutating = useIsMutating();
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import * as React from "react"
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
import { cn } from "@/lib/utils"
|
ChevronRightIcon,
|
||||||
import { Button } from "@/components/ui/button"
|
MoreHorizontalIcon,
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
} from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
role="navigation"
|
|
||||||
aria-label="pagination"
|
aria-label="pagination"
|
||||||
data-slot="pagination"
|
data-slot="pagination"
|
||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationContent({
|
function PaginationContent({
|
||||||
@@ -26,17 +28,17 @@ function PaginationContent({
|
|||||||
className={cn("flex items-center gap-0.5", className)}
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
return <li data-slot="pagination-item" {...props} />
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
type PaginationLinkProps = {
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
React.ComponentProps<"a">
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
function PaginationLink({
|
function PaginationLink({
|
||||||
className,
|
className,
|
||||||
@@ -58,7 +60,7 @@ function PaginationLink({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationPrevious({
|
function PaginationPrevious({
|
||||||
@@ -76,7 +78,7 @@ function PaginationPrevious({
|
|||||||
<ChevronLeftIcon data-icon="inline-start" />
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
<span className="hidden sm:block">{text}</span>
|
<span className="hidden sm:block">{text}</span>
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationNext({
|
function PaginationNext({
|
||||||
@@ -94,7 +96,7 @@ function PaginationNext({
|
|||||||
<span className="hidden sm:block">{text}</span>
|
<span className="hidden sm:block">{text}</span>
|
||||||
<ChevronRightIcon data-icon="inline-end" />
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationEllipsis({
|
function PaginationEllipsis({
|
||||||
@@ -107,15 +109,14 @@ function PaginationEllipsis({
|
|||||||
data-slot="pagination-ellipsis"
|
data-slot="pagination-ellipsis"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon
|
<MoreHorizontalIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">More pages</span>
|
<span className="sr-only">More pages</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -126,4 +127,4 @@ export {
|
|||||||
PaginationLink,
|
PaginationLink,
|
||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -37,12 +37,12 @@ function PopoverContent({
|
|||||||
"data-[side=left]:slide-in-from-right-2",
|
"data-[side=left]:slide-in-from-right-2",
|
||||||
"data-[side=right]:slide-in-from-left-2",
|
"data-[side=right]:slide-in-from-left-2",
|
||||||
"data-[side=top]:slide-in-from-bottom-2",
|
"data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent }
|
export { Popover, PopoverContent, PopoverTrigger };
|
||||||
|
|||||||
@@ -1,41 +1,45 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import { XIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils"
|
import * as React from "react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { XIcon } from "lucide-react"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MEDIA_QUERY = "(min-width: 640px)"
|
const MEDIA_QUERY = "(min-width: 640px)";
|
||||||
|
|
||||||
function useMediaQuery(query: string) {
|
function useMediaQuery(query: string) {
|
||||||
const [matches, setMatches] = React.useState(() => {
|
const [matches, setMatches] = React.useState(() => {
|
||||||
if (typeof window === "undefined") return true
|
if (typeof window === "undefined") return true;
|
||||||
return window.matchMedia(query).matches
|
return window.matchMedia(query).matches;
|
||||||
})
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const mql = window.matchMedia(query)
|
const mql = window.matchMedia(query);
|
||||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
mql.addEventListener("change", handler)
|
mql.addEventListener("change", handler);
|
||||||
return () => mql.removeEventListener("change", handler)
|
return () => mql.removeEventListener("change", handler);
|
||||||
}, [query])
|
}, [query]);
|
||||||
|
|
||||||
return matches
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResponsiveDialogContextValue {
|
interface ResponsiveDialogContextValue {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
isDesktop: boolean
|
isDesktop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResponsiveDialogContext = React.createContext<ResponsiveDialogContextValue | null>(null)
|
const ResponsiveDialogContext =
|
||||||
|
React.createContext<ResponsiveDialogContextValue | null>(null);
|
||||||
|
|
||||||
function useResponsiveDialog() {
|
function useResponsiveDialog() {
|
||||||
const ctx = React.useContext(ResponsiveDialogContext)
|
const ctx = React.useContext(ResponsiveDialogContext);
|
||||||
if (!ctx) throw new Error("ResponsiveDialog components must be used within ResponsiveDialog")
|
if (!ctx)
|
||||||
return ctx
|
throw new Error(
|
||||||
|
"ResponsiveDialog components must be used within ResponsiveDialog",
|
||||||
|
);
|
||||||
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialog({
|
function ResponsiveDialog({
|
||||||
@@ -43,19 +47,22 @@ function ResponsiveDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
open?: boolean
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||||
const isDesktop = useMediaQuery(MEDIA_QUERY)
|
const isDesktop = useMediaQuery(MEDIA_QUERY);
|
||||||
|
|
||||||
const controlled = open !== undefined
|
const controlled = open !== undefined;
|
||||||
const currentOpen = controlled ? open : internalOpen
|
const currentOpen = controlled ? open : internalOpen;
|
||||||
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen
|
// biome-ignore lint/style/noNonNullAssertion: controlled ensures it's defined
|
||||||
|
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDialogContext value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}>
|
<ResponsiveDialogContext
|
||||||
|
value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}
|
||||||
|
>
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
|
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
|
||||||
{children}
|
{children}
|
||||||
@@ -66,7 +73,7 @@ function ResponsiveDialog({
|
|||||||
</DialogPrimitive.Root>
|
</DialogPrimitive.Root>
|
||||||
)}
|
)}
|
||||||
</ResponsiveDialogContext>
|
</ResponsiveDialogContext>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogTrigger({
|
function ResponsiveDialogTrigger({
|
||||||
@@ -78,7 +85,7 @@ function ResponsiveDialogTrigger({
|
|||||||
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
|
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</DialogPrimitive.Trigger>
|
</DialogPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogContent({
|
function ResponsiveDialogContent({
|
||||||
@@ -86,7 +93,7 @@ function ResponsiveDialogContent({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
const { isDesktop, open, onOpenChange } = useResponsiveDialog()
|
const { isDesktop, open, onOpenChange } = useResponsiveDialog();
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
@@ -103,7 +110,7 @@ function ResponsiveDialogContent({
|
|||||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]",
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]",
|
||||||
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]",
|
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]",
|
||||||
"duration-200",
|
"duration-200",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -120,22 +127,27 @@ function ResponsiveDialogContent({
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:hidden" data-slot="responsive-dialog-mobile">
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs"
|
className="fixed inset-0 z-50 flex items-end sm:hidden"
|
||||||
|
data-slot="responsive-dialog-mobile"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs cursor-default"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Cerrar"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg",
|
"relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg",
|
||||||
"animate-in slide-in-from-bottom-10 fade-in-0 duration-200",
|
"animate-in slide-in-from-bottom-10 fade-in-0 duration-200",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
|
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
|
||||||
@@ -152,7 +164,7 @@ function ResponsiveDialogContent({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogHeader({
|
function ResponsiveDialogHeader({
|
||||||
@@ -165,7 +177,7 @@ function ResponsiveDialogHeader({
|
|||||||
className={cn("flex flex-col gap-0.5", className)}
|
className={cn("flex flex-col gap-0.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogFooter({
|
function ResponsiveDialogFooter({
|
||||||
@@ -175,10 +187,13 @@ function ResponsiveDialogFooter({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="responsive-dialog-footer"
|
data-slot="responsive-dialog-footer"
|
||||||
className={cn("mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2", className)}
|
className={cn(
|
||||||
|
"mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogTitle({
|
function ResponsiveDialogTitle({
|
||||||
@@ -191,7 +206,7 @@ function ResponsiveDialogTitle({
|
|||||||
className={cn("text-base font-medium text-foreground", className)}
|
className={cn("text-base font-medium text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogDescription({
|
function ResponsiveDialogDescription({
|
||||||
@@ -204,22 +219,24 @@ function ResponsiveDialogDescription({
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsiveDialogClose({
|
function ResponsiveDialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
|
return (
|
||||||
|
<DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ResponsiveDialog,
|
ResponsiveDialog,
|
||||||
ResponsiveDialogTrigger,
|
|
||||||
ResponsiveDialogContent,
|
|
||||||
ResponsiveDialogHeader,
|
|
||||||
ResponsiveDialogFooter,
|
|
||||||
ResponsiveDialogTitle,
|
|
||||||
ResponsiveDialogDescription,
|
|
||||||
ResponsiveDialogClose,
|
ResponsiveDialogClose,
|
||||||
}
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
ResponsiveDialogTrigger,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import * as React from "react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
import { Select as SelectPrimitive } from "radix-ui";
|
||||||
|
import type * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
@@ -20,13 +19,13 @@ function SelectGroup({
|
|||||||
className={cn("scroll-my-1 p-1", className)}
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
@@ -35,7 +34,7 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
@@ -43,7 +42,7 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -52,7 +51,7 @@ function SelectTrigger({
|
|||||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
@@ -67,7 +66,12 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={position === "item-aligned"}
|
data-align-trigger={position === "item-aligned"}
|
||||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
align={align}
|
align={align}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -77,7 +81,7 @@ function SelectContent({
|
|||||||
data-position={position}
|
data-position={position}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
position === "popper" && ""
|
position === "popper" && "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -85,7 +89,7 @@ function SelectContent({
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
@@ -98,7 +102,7 @@ function SelectLabel({
|
|||||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
@@ -111,7 +115,7 @@ function SelectItem({
|
|||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -122,7 +126,7 @@ function SelectItem({
|
|||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
@@ -135,7 +139,7 @@ function SelectSeparator({
|
|||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
@@ -147,14 +151,13 @@ function SelectScrollUpButton({
|
|||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon />
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
@@ -166,14 +169,13 @@ function SelectScrollDownButton({
|
|||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon />
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -187,4 +189,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
@@ -16,11 +16,11 @@ function Separator({
|
|||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { XIcon } from "lucide-react";
|
||||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||||
|
import type * as React from "react";
|
||||||
import { cn } from "@/lib/utils"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Button } from "@/components/ui/button"
|
import { cn } from "@/lib/utils";
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
function SheetOverlay({
|
||||||
@@ -38,11 +37,11 @@ function SheetOverlay({
|
|||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetContent({
|
function SheetContent({
|
||||||
@@ -52,8 +51,8 @@ function SheetContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -63,7 +62,7 @@ function SheetContent({
|
|||||||
data-side={side}
|
data-side={side}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -75,15 +74,14 @@ function SheetContent({
|
|||||||
className="absolute top-3 right-3"
|
className="absolute top-3 right-3"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -93,7 +91,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -103,7 +101,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({
|
function SheetTitle({
|
||||||
@@ -113,13 +111,10 @@ function SheetTitle({
|
|||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
className={cn(
|
className={cn("text-base font-medium text-foreground", className)}
|
||||||
"text-base font-medium text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetDescription({
|
function SheetDescription({
|
||||||
@@ -132,16 +127,16 @@ function SheetDescription({
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetTrigger,
|
|
||||||
SheetClose,
|
SheetClose,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
|
||||||
SheetFooter,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
@@ -15,19 +15,19 @@ function TooltipProvider({
|
|||||||
delayDuration={delayDuration}
|
delayDuration={delayDuration}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({
|
function Tooltip({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
@@ -43,14 +43,14 @@ function TooltipContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 max-w-sm rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"z-50 max-w-sm rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { subDays } from "date-fns";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
ReferenceLine,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { subDays } from "date-fns";
|
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
import { useHistoricalMinMax, useDailyQuotes } from "@/lib/queries";
|
import { useDailyQuotes, useHistoricalMinMax } from "@/lib/queries";
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -45,8 +45,13 @@ export function BeloAnalysisPage() {
|
|||||||
const startDateStr = formatDate(startDate);
|
const startDateStr = formatDate(startDate);
|
||||||
const endDateStr = formatDate(endDate);
|
const endDateStr = formatDate(endDate);
|
||||||
|
|
||||||
const { data: historical, isLoading: historicalLoading } = useHistoricalMinMax("BELO");
|
const { data: historical, isLoading: historicalLoading } =
|
||||||
const { data: daily, isLoading: dailyLoading } = useDailyQuotes("BELO", startDateStr, endDateStr);
|
useHistoricalMinMax("BELO");
|
||||||
|
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
|
||||||
|
"BELO",
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!daily) return [];
|
if (!daily) return [];
|
||||||
@@ -57,7 +62,13 @@ export function BeloAnalysisPage() {
|
|||||||
}, [daily]);
|
}, [daily]);
|
||||||
|
|
||||||
const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => {
|
const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => {
|
||||||
if (chartData.length === 0) return { yDomain: [0, 0] as [number, number], minBuyValue: 0, maxBuyValue: 0, avgBuyValue: 0 };
|
if (chartData.length === 0)
|
||||||
|
return {
|
||||||
|
yDomain: [0, 0] as [number, number],
|
||||||
|
minBuyValue: 0,
|
||||||
|
maxBuyValue: 0,
|
||||||
|
avgBuyValue: 0,
|
||||||
|
};
|
||||||
let min = Infinity;
|
let min = Infinity;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
@@ -92,7 +103,9 @@ export function BeloAnalysisPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground mb-2">Mínimo histórico (compra)</p>
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Mínimo histórico (compra)
|
||||||
|
</p>
|
||||||
{historicalLoading ? (
|
{historicalLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -109,7 +122,9 @@ export function BeloAnalysisPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground mb-2">Máximo histórico (compra)</p>
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Máximo histórico (compra)
|
||||||
|
</p>
|
||||||
{historicalLoading ? (
|
{historicalLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -128,16 +143,26 @@ export function BeloAnalysisPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<h2 className="text-sm font-medium mb-4">Evolución del precio de compra</h2>
|
<h2 className="text-sm font-medium mb-4">
|
||||||
|
Evolución del precio de compra
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-muted-foreground">Desde</label>
|
<span className="text-xs text-muted-foreground">Desde</span>
|
||||||
<DatePicker value={startDate} onChange={(d) => d && setStartDate(d)} placeholder="Fecha inicio" />
|
<DatePicker
|
||||||
|
value={startDate}
|
||||||
|
onChange={(d) => d && setStartDate(d)}
|
||||||
|
placeholder="Fecha inicio"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-muted-foreground">Hasta</label>
|
<span className="text-xs text-muted-foreground">Hasta</span>
|
||||||
<DatePicker value={endDate} onChange={(d) => d && setEndDate(d)} placeholder="Fecha fin" />
|
<DatePicker
|
||||||
|
value={endDate}
|
||||||
|
onChange={(d) => d && setEndDate(d)}
|
||||||
|
placeholder="Fecha fin"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,7 +179,10 @@ export function BeloAnalysisPage() {
|
|||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
@@ -164,7 +192,9 @@ export function BeloAnalysisPage() {
|
|||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
tickFormatter={(v: number) => `$${priceFormatter.format(v)}`}
|
tickFormatter={(v: number) =>
|
||||||
|
`$${priceFormatter.format(v)}`
|
||||||
|
}
|
||||||
width={80}
|
width={80}
|
||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
/>
|
/>
|
||||||
@@ -232,19 +262,31 @@ export function BeloAnalysisPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-4 mt-3">
|
<div className="flex items-center justify-center gap-4 mt-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} />
|
<span
|
||||||
|
className="size-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--chart-1)" }}
|
||||||
|
/>
|
||||||
<span className="text-xs text-muted-foreground">Compra</span>
|
<span className="text-xs text-muted-foreground">Compra</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-1)" }} />
|
<div
|
||||||
|
className="size-2.5 border-t-2 border-dashed"
|
||||||
|
style={{ borderColor: "var(--chart-1)" }}
|
||||||
|
/>
|
||||||
<span className="text-xs text-muted-foreground">Máx</span>
|
<span className="text-xs text-muted-foreground">Máx</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-2)" }} />
|
<div
|
||||||
|
className="size-2.5 border-t-2 border-dashed"
|
||||||
|
style={{ borderColor: "var(--chart-2)" }}
|
||||||
|
/>
|
||||||
<span className="text-xs text-muted-foreground">Mín</span>
|
<span className="text-xs text-muted-foreground">Mín</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-3)" }} />
|
<div
|
||||||
|
className="size-2.5 border-t-2 border-dashed"
|
||||||
|
style={{ borderColor: "var(--chart-3)" }}
|
||||||
|
/>
|
||||||
<span className="text-xs text-muted-foreground">Prom</span>
|
<span className="text-xs text-muted-foreground">Prom</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { BarChart3, RefreshCw } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
ReferenceLine,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import {
|
import {
|
||||||
Gauge,
|
Gauge,
|
||||||
GaugeIndicator,
|
GaugeIndicator,
|
||||||
GaugeTrack,
|
|
||||||
GaugeRange,
|
GaugeRange,
|
||||||
|
GaugeTrack,
|
||||||
GaugeValueText,
|
GaugeValueText,
|
||||||
} from "@/components/ui/gauge";
|
} from "@/components/ui/gauge";
|
||||||
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries";
|
import {
|
||||||
import { Link } from "@tanstack/react-router";
|
useDailyMinMax,
|
||||||
|
useDailyQuotes,
|
||||||
|
useFetchQuotes,
|
||||||
|
useQuoteHistory,
|
||||||
|
useQuotes,
|
||||||
|
} from "@/lib/queries";
|
||||||
import { RelativeTime } from "@/lib/time";
|
import { RelativeTime } from "@/lib/time";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BarChart3, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -105,15 +111,13 @@ export function BeloPage() {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = formatDate(now);
|
const todayStr = formatDate(now);
|
||||||
const firstOfMonth = formatDate(new Date(now.getFullYear(), now.getMonth(), 1));
|
const firstOfMonth = formatDate(
|
||||||
|
new Date(now.getFullYear(), now.getMonth(), 1),
|
||||||
|
);
|
||||||
|
|
||||||
const { data: quotes, isLoading: quotesLoading } = useQuotes();
|
const { data: quotes, isLoading: quotesLoading } = useQuotes();
|
||||||
const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
|
const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
|
||||||
const { data: dailyMinMax, isLoading: minMaxLoading } = useDailyMinMax(
|
const { data: dailyMinMax } = useDailyMinMax("BELO", todayStr, todayStr);
|
||||||
"BELO",
|
|
||||||
todayStr,
|
|
||||||
todayStr,
|
|
||||||
);
|
|
||||||
const { data: monthlyMinMax } = useDailyMinMax(
|
const { data: monthlyMinMax } = useDailyMinMax(
|
||||||
"BELO",
|
"BELO",
|
||||||
firstOfMonth,
|
firstOfMonth,
|
||||||
@@ -140,7 +144,8 @@ export function BeloPage() {
|
|||||||
const minSell = maxData ? Number(maxData.minSell) : 0;
|
const minSell = maxData ? Number(maxData.minSell) : 0;
|
||||||
const maxSell = maxData ? Number(maxData.maxSell) : 0;
|
const maxSell = maxData ? Number(maxData.maxSell) : 0;
|
||||||
|
|
||||||
const gaugeValue = currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
|
const gaugeValue =
|
||||||
|
currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
|
||||||
|
|
||||||
const gaugePercentage = useMemo(() => {
|
const gaugePercentage = useMemo(() => {
|
||||||
if (gaugeValue !== null && maxBuy > minBuy) {
|
if (gaugeValue !== null && maxBuy > minBuy) {
|
||||||
@@ -242,28 +247,18 @@ export function BeloPage() {
|
|||||||
onClick={() => doFetchQuotes()}
|
onClick={() => doFetchQuotes()}
|
||||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-5">
|
||||||
<StatCard
|
<StatCard label="Precio actual" buy={currentBuy} sell={currentSell} />
|
||||||
label="Precio actual"
|
<StatCard label="Mínimo del día" buy={minBuy} sell={minSell} />
|
||||||
buy={currentBuy}
|
<StatCard label="Máximo del día" buy={maxBuy} sell={maxSell} />
|
||||||
sell={currentSell}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Mínimo del día"
|
|
||||||
buy={minBuy}
|
|
||||||
sell={minSell}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Máximo del día"
|
|
||||||
buy={maxBuy}
|
|
||||||
sell={maxSell}
|
|
||||||
/>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Máximo del mes"
|
label="Máximo del mes"
|
||||||
buy={monthlyMax?.maxBuy ?? 0}
|
buy={monthlyMax?.maxBuy ?? 0}
|
||||||
@@ -282,7 +277,8 @@ export function BeloPage() {
|
|||||||
startAngle={0}
|
startAngle={0}
|
||||||
endAngle={360}
|
endAngle={360}
|
||||||
getValueText={(v, m, mx) => {
|
getValueText={(v, m, mx) => {
|
||||||
const pct = mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
|
const pct =
|
||||||
|
mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
|
||||||
return `${pct}%`;
|
return `${pct}%`;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -290,7 +286,12 @@ export function BeloPage() {
|
|||||||
<GaugeTrack />
|
<GaugeTrack />
|
||||||
<GaugeRange className={getGaugeColor(gaugePercentage)} />
|
<GaugeRange className={getGaugeColor(gaugePercentage)} />
|
||||||
</GaugeIndicator>
|
</GaugeIndicator>
|
||||||
<GaugeValueText className={cn(getGaugeColor(gaugePercentage), "text-sm font-semibold")} />
|
<GaugeValueText
|
||||||
|
className={cn(
|
||||||
|
getGaugeColor(gaugePercentage),
|
||||||
|
"text-sm font-semibold",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Gauge>
|
</Gauge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,7 +337,10 @@ export function BeloPage() {
|
|||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
@@ -351,11 +355,11 @@ export function BeloPage() {
|
|||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// biome-ignore lint/suspicious/noExplicitAny: recharts types
|
||||||
formatter={(value: any) => [
|
formatter={(value: any) => [
|
||||||
`$${priceFormatter.format(Number(value))}`,
|
`$${priceFormatter.format(Number(value))}`,
|
||||||
]}
|
]}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// biome-ignore lint/suspicious/noExplicitAny: recharts types
|
||||||
labelFormatter={(label: any) =>
|
labelFormatter={(label: any) =>
|
||||||
period === "day" ? `Hora: ${label}` : `Fecha: ${label}`
|
period === "day" ? `Hora: ${label}` : `Fecha: ${label}`
|
||||||
}
|
}
|
||||||
@@ -417,11 +421,17 @@ export function BeloPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-center gap-4 mt-3">
|
<div className="flex items-center justify-center gap-4 mt-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} />
|
<span
|
||||||
|
className="size-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--chart-1)" }}
|
||||||
|
/>
|
||||||
<span className="text-xs text-muted-foreground">Compra</span>
|
<span className="text-xs text-muted-foreground">Compra</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-2)" }} />
|
<span
|
||||||
|
className="size-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--chart-2)" }}
|
||||||
|
/>
|
||||||
<span className="text-xs text-muted-foreground">Venta</span>
|
<span className="text-xs text-muted-foreground">Venta</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
|
||||||
import { RelativeTime } from "@/lib/time";
|
|
||||||
import type { Quote } from "@/lib/api";
|
|
||||||
import { useQuotes, useFetchQuotes, useMonthlyPayedTotal, useMonthlyTotals } from "@/lib/queries";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { Quote } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
useFetchQuotes,
|
||||||
|
useMonthlyPayedTotal,
|
||||||
|
useMonthlyTotals,
|
||||||
|
useQuotes,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
import { RelativeTime } from "@/lib/time";
|
||||||
import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
|
import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
@@ -16,7 +21,17 @@ const priceFormatter = new Intl.NumberFormat("es-AR", {
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; title: string; to?: string; variant?: "default" | "minimal" }) {
|
function QuoteCard({
|
||||||
|
quote,
|
||||||
|
title,
|
||||||
|
to,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
quote: Quote | undefined;
|
||||||
|
title: string;
|
||||||
|
to?: string;
|
||||||
|
variant?: "default" | "minimal";
|
||||||
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const diff = quote ? Number(quote.difference) : 0;
|
const diff = quote ? Number(quote.difference) : 0;
|
||||||
const pct = quote ? Number(quote.percentage) : 0;
|
const pct = quote ? Number(quote.percentage) : 0;
|
||||||
@@ -24,10 +39,13 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
|
|||||||
const isDown = diff < 0;
|
const isDown = diff < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: conditional button
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border p-4 cursor-pointer"
|
className="rounded-lg border p-4 cursor-pointer"
|
||||||
onClick={() => to && navigate({ to })}
|
onClick={() => to && navigate({ to })}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" && to) navigate({ to }); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && to) navigate({ to });
|
||||||
|
}}
|
||||||
role={to ? "button" : undefined}
|
role={to ? "button" : undefined}
|
||||||
tabIndex={to ? 0 : undefined}
|
tabIndex={to ? 0 : undefined}
|
||||||
>
|
>
|
||||||
@@ -70,7 +88,9 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="cursor-default">
|
<TooltipTrigger className="cursor-default">
|
||||||
<p className="text-2xl font-bold tabular-nums text-left">${priceFormatter.format(Number(quote.buy))}</p>
|
<p className="text-2xl font-bold tabular-nums text-left">
|
||||||
|
${priceFormatter.format(Number(quote.buy))}
|
||||||
|
</p>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>Compra: ${priceFormatter.format(Number(quote.buy))}</p>
|
<p>Compra: ${priceFormatter.format(Number(quote.buy))}</p>
|
||||||
@@ -81,10 +101,16 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Compra: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.buy))}</span>
|
Compra:{" "}
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
${priceFormatter.format(Number(quote.buy))}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Venta: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.sell))}</span>
|
Venta:{" "}
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
${priceFormatter.format(Number(quote.sell))}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -103,8 +129,14 @@ export function DashboardPage() {
|
|||||||
const blue = quotes?.find((q) => q.type === "BLUE");
|
const blue = quotes?.find((q) => q.type === "BLUE");
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1);
|
const { data: monthlyExpenses } = useMonthlyPayedTotal(
|
||||||
const { data: monthlyTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
|
now.getFullYear(),
|
||||||
|
now.getMonth() + 1,
|
||||||
|
);
|
||||||
|
const { data: monthlyTotals } = useMonthlyTotals(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth() + 1,
|
||||||
|
);
|
||||||
|
|
||||||
const isFetching = isLoading || isPending;
|
const isFetching = isLoading || isPending;
|
||||||
|
|
||||||
@@ -118,20 +150,31 @@ export function DashboardPage() {
|
|||||||
onClick={() => doFetchQuotes()}
|
onClick={() => doFetchQuotes()}
|
||||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Pagos pendientes</p>
|
<p className="text-sm text-muted-foreground">Pagos pendientes</p>
|
||||||
<p className="text-2xl font-bold">${priceFormatter.format(monthlyTotals?.pending ?? 0)}</p>
|
<p className="text-2xl font-bold">
|
||||||
|
${priceFormatter.format(monthlyTotals?.pending ?? 0)}
|
||||||
|
</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">Gastos del mes</p>
|
||||||
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p>
|
<p className="text-2xl font-bold">
|
||||||
|
${priceFormatter.format(monthlyExpenses?.total ?? 0)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
|
<QuoteCard
|
||||||
|
quote={belo}
|
||||||
|
title="BELO"
|
||||||
|
to="/quotes/belo"
|
||||||
|
variant="minimal"
|
||||||
|
/>
|
||||||
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
||||||
</div>
|
</div>
|
||||||
<DashboardExpensesTable />
|
<DashboardExpensesTable />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { CalendarClock, ExternalLink } from "lucide-react";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { usePendingUpcomingExpenses } from "@/lib/queries";
|
import { CalendarClock, ExternalLink } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import type { PendingUpcomingExpense } from "@/lib/api";
|
import type { PendingUpcomingExpense } from "@/lib/api";
|
||||||
|
import { usePendingUpcomingExpenses } from "@/lib/queries";
|
||||||
|
|
||||||
type GroupedExpense = {
|
type GroupedExpense = {
|
||||||
periodicExpenseId: number;
|
periodicExpenseId: number;
|
||||||
@@ -100,12 +100,15 @@ export function DashboardExpensesTable() {
|
|||||||
<CalendarClock className="size-4 text-muted-foreground" />
|
<CalendarClock className="size-4 text-muted-foreground" />
|
||||||
<h2 className="text-sm font-medium">Gastos por vencer</h2>
|
<h2 className="text-sm font-medium">Gastos por vencer</h2>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Cargando...</span>
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
Cargando...
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isLoading && hasData && (
|
{!isLoading && hasData && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{overdueCount} vencido{overdueCount !== 1 ? "s" : ""}
|
{overdueCount} vencido{overdueCount !== 1 ? "s" : ""}
|
||||||
{upcomingCount > 0 && ` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`}
|
{upcomingCount > 0 &&
|
||||||
|
` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,16 +166,29 @@ export function DashboardExpensesTable() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Descripción</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Monto</th>
|
Descripción
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Vencimiento</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Cantidad</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Estado</th>
|
Monto
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
Vencimiento
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
Cantidad
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<tr key={group.periodicExpenseId} className="border-b last:border-0 hover:bg-muted/30">
|
<tr
|
||||||
|
key={group.periodicExpenseId}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30"
|
||||||
|
>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -183,8 +199,12 @@ export function DashboardExpensesTable() {
|
|||||||
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 tabular-nums">{formatAmount(group.totalAmount)}</td>
|
<td className="px-3 py-2.5 tabular-nums">
|
||||||
<td className="px-3 py-2.5 text-muted-foreground">{formatDate(group.earliestDueDate)}</td>
|
{formatAmount(group.totalAmount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-muted-foreground">
|
||||||
|
{formatDate(group.earliestDueDate)}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2.5 text-muted-foreground tabular-nums">
|
<td className="px-3 py-2.5 text-muted-foreground tabular-nums">
|
||||||
{group.count > 1 ? `${group.count} gastos` : "1 gasto"}
|
{group.count > 1 ? `${group.count} gastos` : "1 gasto"}
|
||||||
</td>
|
</td>
|
||||||
@@ -196,7 +216,9 @@ export function DashboardExpensesTable() {
|
|||||||
{hasData && (
|
{hasData && (
|
||||||
<tr className="border-t bg-muted/50 font-medium">
|
<tr className="border-t bg-muted/50 font-medium">
|
||||||
<td className="px-3 py-2.5">Total</td>
|
<td className="px-3 py-2.5">Total</td>
|
||||||
<td className="px-3 py-2.5 tabular-nums">{formatAmount(grandTotal)}</td>
|
<td className="px-3 py-2.5 tabular-nums">
|
||||||
|
{formatAmount(grandTotal)}
|
||||||
|
</td>
|
||||||
<td colSpan={3} />
|
<td colSpan={3} />
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Route } from "@/routes/_authenticated/expenses";
|
import { Route } from "@/routes/_authenticated/expenses";
|
||||||
import { ExpensesProvider } from "./ExpensesProvider";
|
|
||||||
import { ExpensesTabContent } from "./components/ExpensesTabContent";
|
import { ExpensesTabContent } from "./components/ExpensesTabContent";
|
||||||
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
|
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
|
||||||
|
import { ExpensesProvider } from "./ExpensesProvider";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "expenses", label: "Gastos" },
|
{ id: "expenses", label: "Gastos" },
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
|
||||||
import {
|
import {
|
||||||
usePeriodicExpenses,
|
createContext,
|
||||||
useCreatePeriodicExpense,
|
type ReactNode,
|
||||||
useUpdatePeriodicExpense,
|
useCallback,
|
||||||
useDeletePeriodicExpense,
|
useContext,
|
||||||
useGenerateMonthlyExpense,
|
useEffect,
|
||||||
useExpenses,
|
useRef,
|
||||||
useCreateNonPeriodicExpense,
|
useState,
|
||||||
usePayExpense,
|
} from "react";
|
||||||
useMonthlyTotals,
|
|
||||||
useTotalPending,
|
|
||||||
useImportPeriodicExpenses,
|
|
||||||
useImportExpenses,
|
|
||||||
} from "@/lib/queries";
|
|
||||||
import type {
|
import type {
|
||||||
PeriodicExpense,
|
|
||||||
Expense,
|
|
||||||
CreatePeriodicExpenseInput,
|
|
||||||
CreateNonPeriodicExpenseInput,
|
CreateNonPeriodicExpenseInput,
|
||||||
PayExpenseInput,
|
CreatePeriodicExpenseInput,
|
||||||
PaginatedResponse,
|
Expense,
|
||||||
MonthlyTotals,
|
|
||||||
MonthlyExpensesTotal,
|
|
||||||
ImportResult,
|
|
||||||
ImportExpensesResult,
|
ImportExpensesResult,
|
||||||
|
ImportResult,
|
||||||
|
MonthlyExpensesTotal,
|
||||||
|
MonthlyTotals,
|
||||||
|
PaginatedResponse,
|
||||||
|
PayExpenseInput,
|
||||||
|
PeriodicExpense,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
useCreateNonPeriodicExpense,
|
||||||
|
useCreatePeriodicExpense,
|
||||||
|
useDeletePeriodicExpense,
|
||||||
|
useExpenses,
|
||||||
|
useGenerateMonthlyExpense,
|
||||||
|
useImportExpenses,
|
||||||
|
useImportPeriodicExpenses,
|
||||||
|
useMonthlyTotals,
|
||||||
|
usePayExpense,
|
||||||
|
usePeriodicExpenses,
|
||||||
|
useTotalPending,
|
||||||
|
useUpdatePeriodicExpense,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
|
||||||
interface ExpensesContextValue {
|
interface ExpensesContextValue {
|
||||||
periodicExpenses: PeriodicExpense[];
|
periodicExpenses: PeriodicExpense[];
|
||||||
@@ -47,11 +55,18 @@ interface ExpensesContextValue {
|
|||||||
totalPending: MonthlyExpensesTotal | undefined;
|
totalPending: MonthlyExpensesTotal | undefined;
|
||||||
isLoadingTotalPending: boolean;
|
isLoadingTotalPending: boolean;
|
||||||
|
|
||||||
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
|
createPeriodicExpense: (
|
||||||
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
|
data: CreatePeriodicExpenseInput,
|
||||||
|
) => Promise<PeriodicExpense>;
|
||||||
|
updatePeriodicExpense: (
|
||||||
|
id: number,
|
||||||
|
data: CreatePeriodicExpenseInput,
|
||||||
|
) => Promise<void>;
|
||||||
deletePeriodicExpense: (id: number) => Promise<void>;
|
deletePeriodicExpense: (id: number) => Promise<void>;
|
||||||
generateMonthlyExpense: (id: number) => Promise<Expense>;
|
generateMonthlyExpense: (id: number) => Promise<Expense>;
|
||||||
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>;
|
createNonPeriodicExpense: (
|
||||||
|
data: CreateNonPeriodicExpenseInput,
|
||||||
|
) => Promise<void>;
|
||||||
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
||||||
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
|
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
|
||||||
importExpenses: (file: File) => Promise<ImportExpensesResult>;
|
importExpenses: (file: File) => Promise<ImportExpensesResult>;
|
||||||
@@ -59,11 +74,19 @@ interface ExpensesContextValue {
|
|||||||
|
|
||||||
const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
||||||
|
|
||||||
export function ExpensesProvider({ children, initialPeriodicExpenseId }: { children: ReactNode; initialPeriodicExpenseId?: number }) {
|
export function ExpensesProvider({
|
||||||
|
children,
|
||||||
|
initialPeriodicExpenseId,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
initialPeriodicExpenseId?: number;
|
||||||
|
}) {
|
||||||
const [statusFilter, setStatusFilter] = useState("PENDING");
|
const [statusFilter, setStatusFilter] = useState("PENDING");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<number | undefined>(initialPeriodicExpenseId);
|
const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<
|
||||||
|
number | undefined
|
||||||
|
>(initialPeriodicExpenseId);
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -81,15 +104,28 @@ export function ExpensesProvider({ children, initialPeriodicExpenseId }: { child
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [periodicExpenseFilter, searchQuery]);
|
}, []);
|
||||||
|
|
||||||
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses();
|
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } =
|
||||||
const { data: expenses = { data: [], total: 0, page: 1, pageSize: 10 }, isLoading: isLoadingExpenses } =
|
usePeriodicExpenses();
|
||||||
useExpenses(statusFilter, page, pageSize, periodicExpenseFilter, searchQuery || undefined);
|
const {
|
||||||
|
data: expenses = { data: [], total: 0, page: 1, pageSize: 10 },
|
||||||
|
isLoading: isLoadingExpenses,
|
||||||
|
} = useExpenses(
|
||||||
|
statusFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
periodicExpenseFilter,
|
||||||
|
searchQuery || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
|
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(
|
||||||
const { data: totalPending, isLoading: isLoadingTotalPending } = useTotalPending();
|
now.getFullYear(),
|
||||||
|
now.getMonth() + 1,
|
||||||
|
);
|
||||||
|
const { data: totalPending, isLoading: isLoadingTotalPending } =
|
||||||
|
useTotalPending();
|
||||||
|
|
||||||
const createPeriodicMutation = useCreatePeriodicExpense();
|
const createPeriodicMutation = useCreatePeriodicExpense();
|
||||||
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
||||||
@@ -193,6 +229,7 @@ export function ExpensesProvider({ children, initialPeriodicExpenseId }: { child
|
|||||||
|
|
||||||
export function useExpensesContext() {
|
export function useExpensesContext() {
|
||||||
const ctx = useContext(ExpensesContext);
|
const ctx = useContext(ExpensesContext);
|
||||||
if (!ctx) throw new Error("useExpensesContext must be used within ExpensesProvider");
|
if (!ctx)
|
||||||
|
throw new Error("useExpensesContext must be used within ExpensesProvider");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { useState, useRef } from "react";
|
import { Plus, Search, Upload, X } from "lucide-react";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useRef, useState } from "react";
|
||||||
import { ExpensesTable } from "./ExpensesTable";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PayExpenseDialog } from "./PayExpenseDialog";
|
import { Input } from "@/components/ui/input";
|
||||||
import { NewExpenseDialog } from "./NewExpenseDialog";
|
|
||||||
import {
|
import {
|
||||||
ResponsiveDialog,
|
ResponsiveDialog,
|
||||||
ResponsiveDialogContent,
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
ResponsiveDialogDescription,
|
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -19,8 +16,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Plus, Upload, Search, X } from "lucide-react";
|
|
||||||
import type { Expense, ImportExpensesResult } from "@/lib/api";
|
import type { Expense, ImportExpensesResult } from "@/lib/api";
|
||||||
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
|
import { ExpensesTable } from "./ExpensesTable";
|
||||||
|
import { NewExpenseDialog } from "./NewExpenseDialog";
|
||||||
|
import { PayExpenseDialog } from "./PayExpenseDialog";
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -33,9 +33,7 @@ export function ExpensesTabContent() {
|
|||||||
isLoadingExpenses,
|
isLoadingExpenses,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
page,
|
|
||||||
setPage,
|
setPage,
|
||||||
pageSize,
|
|
||||||
monthlyTotals,
|
monthlyTotals,
|
||||||
isLoadingTotals,
|
isLoadingTotals,
|
||||||
totalPending,
|
totalPending,
|
||||||
@@ -48,12 +46,16 @@ export function ExpensesTabContent() {
|
|||||||
importExpenses,
|
importExpenses,
|
||||||
} = useExpensesContext();
|
} = useExpensesContext();
|
||||||
|
|
||||||
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
|
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
||||||
|
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<ImportExpensesResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportExpensesResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
@@ -72,7 +74,10 @@ export function ExpensesTabContent() {
|
|||||||
variant={statusFilter === f.value ? "default" : "outline"}
|
variant={statusFilter === f.value ? "default" : "outline"}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
onClick={() => {
|
||||||
|
setStatusFilter(f.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -98,7 +103,11 @@ export function ExpensesTabContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={periodicExpenseFilter !== undefined ? String(periodicExpenseFilter) : "all"}
|
value={
|
||||||
|
periodicExpenseFilter !== undefined
|
||||||
|
? String(periodicExpenseFilter)
|
||||||
|
: "all"
|
||||||
|
}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setPeriodicExpenseFilter(val === "all" ? undefined : Number(val));
|
setPeriodicExpenseFilter(val === "all" ? undefined : Number(val));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -117,11 +126,20 @@ export function ExpensesTabContent() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex">
|
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||||
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm" className="min-w-0">
|
<Button
|
||||||
|
onClick={() => setImportDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
<span className="truncate">Importar</span>
|
<span className="truncate">Importar</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setNewExpenseOpen(true)} size="sm" className="min-w-0">
|
<Button
|
||||||
|
onClick={() => setNewExpenseOpen(true)}
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
<span className="truncate">Nuevo gasto</span>
|
<span className="truncate">Nuevo gasto</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -152,7 +170,9 @@ export function ExpensesTabContent() {
|
|||||||
</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">Total Pendiente del Mes</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Total Pendiente del Mes
|
||||||
|
</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
{isLoadingTotals
|
{isLoadingTotals
|
||||||
? "..."
|
? "..."
|
||||||
@@ -160,7 +180,9 @@ export function ExpensesTabContent() {
|
|||||||
</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">Total Pendiente General</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Total Pendiente General
|
||||||
|
</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
{isLoadingTotalPending
|
{isLoadingTotalPending
|
||||||
? "..."
|
? "..."
|
||||||
@@ -177,19 +199,26 @@ export function ExpensesTabContent() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
|
<NewExpenseDialog
|
||||||
|
open={newExpenseOpen}
|
||||||
|
onOpenChange={setNewExpenseOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => {
|
<ResponsiveDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
setImportDialogOpen(open);
|
setImportDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<ResponsiveDialogContent>
|
<ResponsiveDialogContent>
|
||||||
<ResponsiveDialogHeader>
|
<ResponsiveDialogHeader>
|
||||||
<ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle>
|
<ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle>
|
||||||
<ResponsiveDialogDescription>
|
<ResponsiveDialogDescription>
|
||||||
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos.
|
Seleccioná un archivo CSV (delimitado por pipes) para importar
|
||||||
|
gastos.
|
||||||
</ResponsiveDialogDescription>
|
</ResponsiveDialogDescription>
|
||||||
</ResponsiveDialogHeader>
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
@@ -208,15 +237,20 @@ export function ExpensesTabContent() {
|
|||||||
</ul>
|
</ul>
|
||||||
{importResult.errors && importResult.errors.length > 0 && (
|
{importResult.errors && importResult.errors.length > 0 && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||||
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p>
|
<p className="mb-1 text-xs font-medium text-destructive">
|
||||||
|
Errores:
|
||||||
|
</p>
|
||||||
<ul className="list-inside list-disc text-xs text-destructive/80">
|
<ul className="list-inside list-disc text-xs text-destructive/80">
|
||||||
{importResult.errors.map((err, i) => (
|
{importResult.errors.map((err) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={err}>{err}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setImportDialogOpen(false)}
|
||||||
|
>
|
||||||
Cerrar
|
Cerrar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,8 +273,13 @@ export function ExpensesTabContent() {
|
|||||||
const result = await importExpenses(file);
|
const result = await importExpenses(file);
|
||||||
setImportResult(result);
|
setImportResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message =
|
||||||
setImportResult({ imported: 0, skipped: 0, errors: [message] });
|
err instanceof Error ? err.message : String(err);
|
||||||
|
setImportResult({
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [message],
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
type ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import type { Expense } from "@/lib/api";
|
import {
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
CircleDollarSign,
|
||||||
|
SkipBackIcon,
|
||||||
|
SkipForwardIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CircleDollarSign, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -16,6 +21,7 @@ import {
|
|||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination";
|
} from "@/components/ui/pagination";
|
||||||
|
import type { Expense } from "@/lib/api";
|
||||||
|
|
||||||
interface ExpensesTableProps {
|
interface ExpensesTableProps {
|
||||||
data: Expense[];
|
data: Expense[];
|
||||||
@@ -37,7 +43,10 @@ function formatExpenseDate(value: string) {
|
|||||||
|
|
||||||
function formatExpenseAmount(expense: Expense) {
|
function formatExpenseAmount(expense: Expense) {
|
||||||
const isPayed = expense.status === "PAYED";
|
const isPayed = expense.status === "PAYED";
|
||||||
const value = isPayed && expense.amountPayed ? Number(expense.amountPayed) : Number(expense.amount);
|
const value =
|
||||||
|
isPayed && expense.amountPayed
|
||||||
|
? Number(expense.amountPayed)
|
||||||
|
: Number(expense.amount);
|
||||||
|
|
||||||
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
|
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
@@ -58,7 +67,14 @@ function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) {
|
export function ExpensesTable({
|
||||||
|
data,
|
||||||
|
onPay,
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
}: ExpensesTableProps) {
|
||||||
const columns = useMemo<ColumnDef<Expense>[]>(
|
const columns = useMemo<ColumnDef<Expense>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -74,9 +90,7 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const expense = row.original;
|
const expense = row.original;
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">{formatExpenseAmount(expense)}</span>
|
||||||
{formatExpenseAmount(expense)}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -95,7 +109,11 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
header: "Estado",
|
header: "Estado",
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <ExpenseStatusBadge status={row.getValue("status") as Expense["status"]} />;
|
return (
|
||||||
|
<ExpenseStatusBadge
|
||||||
|
status={row.getValue("status") as Expense["status"]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,7 +167,8 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
|
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
|
||||||
const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
const lastItem = Math.min(page * pageSize, total);
|
const lastItem = Math.min(page * pageSize, total);
|
||||||
const mobilePagination = total > 0 ? (
|
const mobilePagination =
|
||||||
|
total > 0 ? (
|
||||||
<div className="rounded-lg border bg-card p-2 sm:hidden">
|
<div className="rounded-lg border bg-card p-2 sm:hidden">
|
||||||
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
|
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
|
||||||
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
|
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
|
||||||
@@ -181,7 +200,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
|
|
||||||
<div className="space-y-2 sm:hidden">
|
<div className="space-y-2 sm:hidden">
|
||||||
{data.map((expense) => (
|
{data.map((expense) => (
|
||||||
<div key={expense.id} className="rounded-lg border bg-card p-3 text-sm">
|
<div
|
||||||
|
key={expense.id}
|
||||||
|
className="rounded-lg border bg-card p-3 text-sm"
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{expense.description}</p>
|
<p className="truncate font-medium">{expense.description}</p>
|
||||||
@@ -194,9 +216,13 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
|
|
||||||
<div className="mt-3 flex items-end justify-between gap-3">
|
<div className="mt-3 flex items-end justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-lg font-semibold tabular-nums">{formatExpenseAmount(expense)}</p>
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{formatExpenseAmount(expense)}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{expense.paymentDate ? `Pagado ${formatExpenseDate(expense.paymentDate)}` : "Sin fecha de pago"}
|
{expense.paymentDate
|
||||||
|
? `Pagado ${formatExpenseDate(expense.paymentDate)}`
|
||||||
|
: "Sin fecha de pago"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,7 +257,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
key={header.id}
|
key={header.id}
|
||||||
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -239,7 +268,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30"
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-3 py-2.5">
|
<td key={cell.id} className="px-3 py-2.5">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -249,7 +281,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
))}
|
))}
|
||||||
{table.getRowModel().rows.length === 0 && (
|
{table.getRowModel().rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-3 py-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
No hay gastos para mostrar.
|
No hay gastos para mostrar.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -265,7 +300,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
onClick={(e) => { e.preventDefault(); onPageChange(1); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPageChange(1);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
aria-label="Ir a la primera página"
|
aria-label="Ir a la primera página"
|
||||||
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
@@ -275,7 +313,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (page > 1) onPageChange(page - 1);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
/>
|
/>
|
||||||
@@ -283,7 +324,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
{currentGroup > 0 && (
|
{currentGroup > 0 && (
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
onClick={(e) => { e.preventDefault(); onPageChange(startPage - 1); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPageChange(startPage - 1);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
aria-label="Ir al grupo anterior"
|
aria-label="Ir al grupo anterior"
|
||||||
>
|
>
|
||||||
@@ -291,11 +335,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
)}
|
)}
|
||||||
{Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((p) => (
|
{Array.from(
|
||||||
|
{ length: endPage - startPage + 1 },
|
||||||
|
(_, i) => startPage + i,
|
||||||
|
).map((p) => (
|
||||||
<PaginationItem key={p}>
|
<PaginationItem key={p}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
isActive={p === page}
|
isActive={p === page}
|
||||||
onClick={(e) => { e.preventDefault(); onPageChange(p); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPageChange(p);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -305,7 +355,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
|
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
onClick={(e) => { e.preventDefault(); onPageChange(endPage + 1); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPageChange(endPage + 1);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
aria-label="Ir al siguiente grupo"
|
aria-label="Ir al siguiente grupo"
|
||||||
>
|
>
|
||||||
@@ -315,17 +368,27 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
)}
|
)}
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (page < totalPages) onPageChange(page + 1);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
|
className={
|
||||||
|
page >= totalPages ? "pointer-events-none opacity-50" : ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPageChange(totalPages);
|
||||||
|
}}
|
||||||
href="#"
|
href="#"
|
||||||
aria-label="Ir a la última página"
|
aria-label="Ir a la última página"
|
||||||
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
|
className={
|
||||||
|
page >= totalPages ? "pointer-events-none opacity-50" : ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SkipForwardIcon className="size-4" />
|
<SkipForwardIcon className="size-4" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ResponsiveDialog,
|
ResponsiveDialog,
|
||||||
ResponsiveDialogContent,
|
ResponsiveDialogContent,
|
||||||
ResponsiveDialogHeader,
|
|
||||||
ResponsiveDialogTitle,
|
|
||||||
ResponsiveDialogDescription,
|
ResponsiveDialogDescription,
|
||||||
ResponsiveDialogFooter,
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface GenerateCurrentMonthDialogProps {
|
interface GenerateCurrentMonthDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -27,18 +27,19 @@ export function GenerateCurrentMonthDialog({
|
|||||||
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<ResponsiveDialogContent>
|
<ResponsiveDialogContent>
|
||||||
<ResponsiveDialogHeader>
|
<ResponsiveDialogHeader>
|
||||||
<ResponsiveDialogTitle>¿Generar gasto para este mes?</ResponsiveDialogTitle>
|
<ResponsiveDialogTitle>
|
||||||
|
¿Generar gasto para este mes?
|
||||||
|
</ResponsiveDialogTitle>
|
||||||
<ResponsiveDialogDescription>
|
<ResponsiveDialogDescription>
|
||||||
<strong>{description}</strong> aplica al mes actual. ¿Querés generar el gasto ahora?
|
<strong>{description}</strong> aplica al mes actual. ¿Querés generar
|
||||||
|
el gasto ahora?
|
||||||
</ResponsiveDialogDescription>
|
</ResponsiveDialogDescription>
|
||||||
</ResponsiveDialogHeader>
|
</ResponsiveDialogHeader>
|
||||||
<ResponsiveDialogFooter>
|
<ResponsiveDialogFooter>
|
||||||
<Button variant="outline" onClick={onSkip}>
|
<Button variant="outline" onClick={onSkip}>
|
||||||
No, gracias
|
No, gracias
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onConfirm}>
|
<Button onClick={onConfirm}>Sí, generar</Button>
|
||||||
Sí, generar
|
|
||||||
</Button>
|
|
||||||
</ResponsiveDialogFooter>
|
</ResponsiveDialogFooter>
|
||||||
</ResponsiveDialogContent>
|
</ResponsiveDialogContent>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useForm, Controller } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
ResponsiveDialog,
|
ResponsiveDialog,
|
||||||
ResponsiveDialogContent,
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
ResponsiveDialogFooter,
|
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
|
|
||||||
const newExpenseSchema = z.object({
|
const newExpenseSchema = z.object({
|
||||||
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(1, "La descripción es obligatoria")
|
||||||
|
.max(50, "Máximo 50 caracteres"),
|
||||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
||||||
});
|
});
|
||||||
@@ -26,7 +28,10 @@ interface NewExpenseDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) {
|
export function NewExpenseDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: NewExpenseDialogProps) {
|
||||||
const { createNonPeriodicExpense } = useExpensesContext();
|
const { createNonPeriodicExpense } = useExpensesContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -65,7 +70,11 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
|
|||||||
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
|
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
|
||||||
</ResponsiveDialogHeader>
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
<form id="new-expense-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
<form
|
||||||
|
id="new-expense-form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="description" className="text-sm font-medium">
|
<label htmlFor="description" className="text-sm font-medium">
|
||||||
Descripción
|
Descripción
|
||||||
@@ -77,7 +86,9 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
|
|||||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{errors.description && (
|
{errors.description && (
|
||||||
<span className="text-xs text-destructive">{errors.description.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.description.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,14 +106,14 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
|
|||||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{errors.amount && (
|
{errors.amount && (
|
||||||
<span className="text-xs text-destructive">{errors.amount.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.amount.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium">
|
<span className="text-sm font-medium">Fecha del gasto</span>
|
||||||
Fecha del gasto
|
|
||||||
</label>
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="dueDate"
|
name="dueDate"
|
||||||
@@ -115,13 +126,19 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.dueDate && (
|
{errors.dueDate && (
|
||||||
<span className="text-xs text-destructive">{errors.dueDate.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.dueDate.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
<ResponsiveDialogFooter>
|
||||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleClose(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="new-expense-form" disabled={isSubmitting}>
|
<Button type="submit" form="new-expense-form" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useForm, Controller } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import type { Expense } from "@/lib/api";
|
import { useEffect, useRef } from "react";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
ResponsiveDialog,
|
ResponsiveDialog,
|
||||||
ResponsiveDialogContent,
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
ResponsiveDialogFooter,
|
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import type { Expense } from "@/lib/api";
|
||||||
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
|
|
||||||
const paySchema = z.object({
|
const paySchema = z.object({
|
||||||
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
|
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
@@ -27,7 +27,11 @@ interface PayExpenseDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
|
export function PayExpenseDialog({
|
||||||
|
expense,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: PayExpenseDialogProps) {
|
||||||
const { payExpense } = useExpensesContext();
|
const { payExpense } = useExpensesContext();
|
||||||
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -94,11 +98,18 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
|
|||||||
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
|
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
|
||||||
<span className="font-medium">{expense.description}</span>
|
<span className="font-medium">{expense.description}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Monto original: ${Number(expense.amount).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
|
Monto original: $
|
||||||
|
{Number(expense.amount).toLocaleString("es-AR", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="pay-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
<form
|
||||||
|
id="pay-form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="amountPayed" className="text-sm font-medium">
|
<label htmlFor="amountPayed" className="text-sm font-medium">
|
||||||
Monto pagado
|
Monto pagado
|
||||||
@@ -116,14 +127,14 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
|
|||||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{errors.amountPayed && (
|
{errors.amountPayed && (
|
||||||
<span className="text-xs text-destructive">{errors.amountPayed.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.amountPayed.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium">
|
<span className="text-sm font-medium">Fecha de pago</span>
|
||||||
Fecha de pago
|
|
||||||
</label>
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="paymentDate"
|
name="paymentDate"
|
||||||
@@ -136,13 +147,19 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.paymentDate && (
|
{errors.paymentDate && (
|
||||||
<span className="text-xs text-destructive">{errors.paymentDate.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.paymentDate.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
<ResponsiveDialogFooter>
|
||||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleClose(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="pay-form" disabled={isSubmitting}>
|
<Button type="submit" form="pay-form" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import type { PeriodicExpense } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { PeriodicExpense } from "@/lib/api";
|
||||||
|
|
||||||
const months = [
|
const months = [
|
||||||
{ value: 1, label: "Ene" },
|
{ value: 1, label: "Ene" },
|
||||||
@@ -20,8 +20,15 @@ const months = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const periodicExpenseFormSchema = z.object({
|
const periodicExpenseFormSchema = z.object({
|
||||||
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
|
description: z
|
||||||
defaultDueDay: z.number().int().min(1, "Día entre 1 y 31").max(31, "Día entre 1 y 31"),
|
.string()
|
||||||
|
.min(1, "La descripción es obligatoria")
|
||||||
|
.max(50, "Máximo 50 caracteres"),
|
||||||
|
defaultDueDay: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "Día entre 1 y 31")
|
||||||
|
.max(31, "Día entre 1 y 31"),
|
||||||
defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
|
defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
|
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
|
||||||
});
|
});
|
||||||
@@ -34,7 +41,11 @@ interface PeriodicExpenseFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: PeriodicExpenseFormProps) {
|
export function PeriodicExpenseForm({
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: PeriodicExpenseFormProps) {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -62,9 +73,15 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
|
|||||||
|
|
||||||
function toggleMonth(month: number) {
|
function toggleMonth(month: number) {
|
||||||
if (selectedPeriods.includes(month)) {
|
if (selectedPeriods.includes(month)) {
|
||||||
setValue("periods", selectedPeriods.filter((m) => m !== month), { shouldValidate: true });
|
setValue(
|
||||||
|
"periods",
|
||||||
|
selectedPeriods.filter((m) => m !== month),
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setValue("periods", [...selectedPeriods, month], { shouldValidate: true });
|
setValue("periods", [...selectedPeriods, month], {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +98,9 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
|
|||||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{errors.description && (
|
{errors.description && (
|
||||||
<span className="text-xs text-destructive">{errors.description.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.description.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +119,9 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
|
|||||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{errors.defaultAmount && (
|
{errors.defaultAmount && (
|
||||||
<span className="text-xs text-destructive">{errors.defaultAmount.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.defaultAmount.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,14 +138,16 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
|
|||||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{errors.defaultDueDay && (
|
{errors.defaultDueDay && (
|
||||||
<span className="text-xs text-destructive">{errors.defaultDueDay.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.defaultDueDay.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">Meses de aplicación</label>
|
<span className="text-sm font-medium">Meses de aplicación</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -162,13 +185,20 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{errors.periods && (
|
{errors.periods && (
|
||||||
<span className="text-xs text-destructive">{errors.periods.message}</span>
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.periods.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<input type="hidden" {...register("periods")} />
|
<input type="hidden" {...register("periods")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2">
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useState, useRef } from "react";
|
import { Plus, Upload } from "lucide-react";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useRef, useState } from "react";
|
||||||
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
|
|
||||||
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
|
|
||||||
import {
|
import {
|
||||||
ResponsiveDialog,
|
ResponsiveDialog,
|
||||||
ResponsiveDialogContent,
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
ResponsiveDialogDescription,
|
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import type { ImportResult, PeriodicExpense } from "@/lib/api";
|
||||||
import { Plus, Upload } from "lucide-react";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
import type { PeriodicExpense, ImportResult } from "@/lib/api";
|
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
|
||||||
|
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
|
||||||
|
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
|
||||||
|
|
||||||
export function PeriodicExpensesTabContent() {
|
export function PeriodicExpensesTabContent() {
|
||||||
const {
|
const {
|
||||||
@@ -53,7 +53,12 @@ export function PeriodicExpensesTabContent() {
|
|||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(data: { description: string; defaultDueDay: number; defaultAmount: number; periods: number[] }) {
|
async function handleSubmit(data: {
|
||||||
|
description: string;
|
||||||
|
defaultDueDay: number;
|
||||||
|
defaultAmount: number;
|
||||||
|
periods: number[];
|
||||||
|
}) {
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
await updatePeriodicExpense(editingItem.id, data);
|
await updatePeriodicExpense(editingItem.id, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -97,7 +102,12 @@ export function PeriodicExpensesTabContent() {
|
|||||||
Administrá tus gastos recurrentes.
|
Administrá tus gastos recurrentes.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex">
|
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||||
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm" className="min-w-0">
|
<Button
|
||||||
|
onClick={() => setImportDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
<span className="truncate">Importar</span>
|
<span className="truncate">Importar</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -135,23 +145,31 @@ export function PeriodicExpensesTabContent() {
|
|||||||
|
|
||||||
<GenerateCurrentMonthDialog
|
<GenerateCurrentMonthDialog
|
||||||
open={generateDialog.open}
|
open={generateDialog.open}
|
||||||
onOpenChange={(open) => setGenerateDialog((prev) => ({ ...prev, open }))}
|
onOpenChange={(open) =>
|
||||||
|
setGenerateDialog((prev) => ({ ...prev, open }))
|
||||||
|
}
|
||||||
description={generateDialog.description}
|
description={generateDialog.description}
|
||||||
onConfirm={handleConfirmGenerate}
|
onConfirm={handleConfirmGenerate}
|
||||||
onSkip={handleSkipGenerate}
|
onSkip={handleSkipGenerate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => {
|
<ResponsiveDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
setImportDialogOpen(open);
|
setImportDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<ResponsiveDialogContent>
|
<ResponsiveDialogContent>
|
||||||
<ResponsiveDialogHeader>
|
<ResponsiveDialogHeader>
|
||||||
<ResponsiveDialogTitle>Importar gastos periódicos</ResponsiveDialogTitle>
|
<ResponsiveDialogTitle>
|
||||||
|
Importar gastos periódicos
|
||||||
|
</ResponsiveDialogTitle>
|
||||||
<ResponsiveDialogDescription>
|
<ResponsiveDialogDescription>
|
||||||
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos periódicos.
|
Seleccioná un archivo CSV (delimitado por pipes) para importar
|
||||||
|
gastos periódicos.
|
||||||
</ResponsiveDialogDescription>
|
</ResponsiveDialogDescription>
|
||||||
</ResponsiveDialogHeader>
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
@@ -174,15 +192,20 @@ export function PeriodicExpensesTabContent() {
|
|||||||
</ul>
|
</ul>
|
||||||
{importResult.errors && importResult.errors.length > 0 && (
|
{importResult.errors && importResult.errors.length > 0 && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||||
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p>
|
<p className="mb-1 text-xs font-medium text-destructive">
|
||||||
|
Errores:
|
||||||
|
</p>
|
||||||
<ul className="list-inside list-disc text-xs text-destructive/80">
|
<ul className="list-inside list-disc text-xs text-destructive/80">
|
||||||
{importResult.errors.map((err, i) => (
|
{importResult.errors.map((err) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={err}>{err}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setImportDialogOpen(false)}
|
||||||
|
>
|
||||||
Cerrar
|
Cerrar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,8 +228,15 @@ export function PeriodicExpensesTabContent() {
|
|||||||
const result = await importPeriodicExpenses(file);
|
const result = await importPeriodicExpenses(file);
|
||||||
setImportResult(result);
|
setImportResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message =
|
||||||
setImportResult({ imported: 0, skipped: 0, skippedNoPeriods: 0, skippedDuplicate: 0, errors: [message] });
|
err instanceof Error ? err.message : String(err);
|
||||||
|
setImportResult({
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
skippedNoPeriods: 0,
|
||||||
|
skippedDuplicate: 0,
|
||||||
|
errors: [message],
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
type ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import type { PeriodicExpense } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { PeriodicExpense } from "@/lib/api";
|
||||||
|
|
||||||
const monthLabels = [
|
const monthLabels = [
|
||||||
"", "Ene", "Feb", "Mar", "Abr", "May", "Jun",
|
"",
|
||||||
"Jul", "Ago", "Sep", "Oct", "Nov", "Dic",
|
"Ene",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Abr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Ago",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dic",
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatPeriods(periods: number[]): string {
|
function formatPeriods(periods: number[]): string {
|
||||||
@@ -28,7 +39,11 @@ function formatPeriods(periods: number[]): string {
|
|||||||
if (curr === prev + 1) {
|
if (curr === prev + 1) {
|
||||||
prev = curr;
|
prev = curr;
|
||||||
} else {
|
} else {
|
||||||
ranges.push(start === prev ? monthLabels[start] : `${monthLabels[start]}-${monthLabels[prev]}`);
|
ranges.push(
|
||||||
|
start === prev
|
||||||
|
? monthLabels[start]
|
||||||
|
: `${monthLabels[start]}-${monthLabels[prev]}`,
|
||||||
|
);
|
||||||
start = curr;
|
start = curr;
|
||||||
prev = curr;
|
prev = curr;
|
||||||
}
|
}
|
||||||
@@ -43,7 +58,11 @@ interface PeriodicExpensesTableProps {
|
|||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpensesTableProps) {
|
export function PeriodicExpensesTable({
|
||||||
|
data,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: PeriodicExpensesTableProps) {
|
||||||
const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
|
const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -122,7 +141,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{item.description}</p>
|
<p className="truncate font-medium">{item.description}</p>
|
||||||
<p className="mt-1 text-lg font-semibold tabular-nums">
|
<p className="mt-1 text-lg font-semibold tabular-nums">
|
||||||
${Number(item.defaultAmount).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
|
$
|
||||||
|
{Number(item.defaultAmount).toLocaleString("es-AR", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
@@ -148,11 +170,15 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
|
|||||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||||
<span className="block">Vence</span>
|
<span className="block">Vence</span>
|
||||||
<span className="font-medium text-foreground">Día {item.defaultDueDay}</span>
|
<span className="font-medium text-foreground">
|
||||||
|
Día {item.defaultDueDay}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 rounded-md bg-muted/40 px-2 py-1.5">
|
<div className="min-w-0 rounded-md bg-muted/40 px-2 py-1.5">
|
||||||
<span className="block">Meses</span>
|
<span className="block">Meses</span>
|
||||||
<span className="block truncate font-medium text-foreground">{formatPeriods(item.periods)}</span>
|
<span className="block truncate font-medium text-foreground">
|
||||||
|
{formatPeriods(item.periods)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +200,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
|
|||||||
key={header.id}
|
key={header.id}
|
||||||
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -182,7 +211,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30"
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-3 py-2.5">
|
<td key={cell.id} className="px-3 py-2.5">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -192,7 +224,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
|
|||||||
))}
|
))}
|
||||||
{table.getRowModel().rows.length === 0 && (
|
{table.getRowModel().rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-3 py-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
No hay gastos periódicos todavía.
|
No hay gastos periódicos todavía.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export function QuotesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-semibold">Cotizaciones</h1>
|
<h1 className="text-2xl font-semibold">Cotizaciones</h1>
|
||||||
<p className="text-muted-foreground">Creá y administrá tus cotizaciones acá.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Creá y administrá tus cotizaciones acá.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ async function fetcher<T>(url: string): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutator<T>(url: string, method: string, body?: unknown): Promise<T> {
|
async function mutator<T>(
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -154,11 +158,16 @@ export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
|||||||
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
|
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPeriodicExpense(data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> {
|
export function createPeriodicExpense(
|
||||||
|
data: CreatePeriodicExpenseInput,
|
||||||
|
): Promise<PeriodicExpense> {
|
||||||
return mutator<PeriodicExpense>("/api/periodic-expenses", "POST", data);
|
return mutator<PeriodicExpense>("/api/periodic-expenses", "POST", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePeriodicExpense(id: number, data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> {
|
export function updatePeriodicExpense(
|
||||||
|
id: number,
|
||||||
|
data: CreatePeriodicExpenseInput,
|
||||||
|
): Promise<PeriodicExpense> {
|
||||||
return mutator<PeriodicExpense>(`/api/periodic-expenses/${id}`, "PUT", data);
|
return mutator<PeriodicExpense>(`/api/periodic-expenses/${id}`, "PUT", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +176,18 @@ export function deletePeriodicExpense(id: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateMonthlyExpense(id: number): Promise<Expense> {
|
export function generateMonthlyExpense(id: number): Promise<Expense> {
|
||||||
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST");
|
return mutator<Expense>(
|
||||||
|
`/api/periodic-expenses/${id}/generate-month`,
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MonthlyExpensesTotal = { total: number };
|
export type MonthlyExpensesTotal = { total: number };
|
||||||
|
|
||||||
export function getMonthlyPayedTotal(year?: number, month?: number): Promise<MonthlyExpensesTotal> {
|
export function getMonthlyPayedTotal(
|
||||||
|
year?: number,
|
||||||
|
month?: number,
|
||||||
|
): Promise<MonthlyExpensesTotal> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (year) params.set("year", String(year));
|
if (year) params.set("year", String(year));
|
||||||
if (month) params.set("month", String(month));
|
if (month) params.set("month", String(month));
|
||||||
@@ -202,17 +217,22 @@ export function getExpenses(
|
|||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
if (page) params.set("page", String(page));
|
if (page) params.set("page", String(page));
|
||||||
if (pageSize) params.set("pageSize", String(pageSize));
|
if (pageSize) params.set("pageSize", String(pageSize));
|
||||||
if (periodicExpenseId) params.set("periodicExpenseId", String(periodicExpenseId));
|
if (periodicExpenseId)
|
||||||
|
params.set("periodicExpenseId", String(periodicExpenseId));
|
||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
|
return fetcher<PaginatedResponse<Expense>>(
|
||||||
|
`/api/expenses${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PendingUpcomingExpense = Expense & {
|
export type PendingUpcomingExpense = Expense & {
|
||||||
dueType: "overdue" | "upcoming";
|
dueType: "overdue" | "upcoming";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPendingUpcomingExpenses(): Promise<PendingUpcomingExpense[]> {
|
export function getPendingUpcomingExpenses(): Promise<
|
||||||
|
PendingUpcomingExpense[]
|
||||||
|
> {
|
||||||
return fetcher<PendingUpcomingExpense[]>("/api/expenses/pending-upcoming");
|
return fetcher<PendingUpcomingExpense[]>("/api/expenses/pending-upcoming");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,18 +240,26 @@ export function getTotalPending(): Promise<MonthlyExpensesTotal> {
|
|||||||
return fetcher<MonthlyExpensesTotal>("/api/expenses/pending-total");
|
return fetcher<MonthlyExpensesTotal>("/api/expenses/pending-total");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMonthlyTotals(year?: number, month?: number): Promise<MonthlyTotals> {
|
export function getMonthlyTotals(
|
||||||
|
year?: number,
|
||||||
|
month?: number,
|
||||||
|
): Promise<MonthlyTotals> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (year) params.set("year", String(year));
|
if (year) params.set("year", String(year));
|
||||||
if (month) params.set("month", String(month));
|
if (month) params.set("month", String(month));
|
||||||
return fetcher<MonthlyTotals>(`/api/expenses/totals?${params}`);
|
return fetcher<MonthlyTotals>(`/api/expenses/totals?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): Promise<Expense> {
|
export function createNonPeriodicExpense(
|
||||||
|
data: CreateNonPeriodicExpenseInput,
|
||||||
|
): Promise<Expense> {
|
||||||
return mutator<Expense>("/api/expenses", "POST", data);
|
return mutator<Expense>("/api/expenses", "POST", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function payExpense(id: number, data: PayExpenseInput): Promise<Expense> {
|
export function payExpense(
|
||||||
|
id: number,
|
||||||
|
data: PayExpenseInput,
|
||||||
|
): Promise<Expense> {
|
||||||
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
|
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useCallback, type ReactNode } from "react";
|
import { createContext, type ReactNode, useCallback, useContext } from "react";
|
||||||
import { authClient, type AuthSession } from "./auth-client";
|
import { type AuthSession, authClient } from "./auth-client";
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
session: AuthSession | null;
|
session: AuthSession | null;
|
||||||
@@ -7,7 +7,10 @@ interface AuthContextValue {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
signIn: (password: string) => Promise<void>;
|
signIn: (password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
changePassword: (
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { keepPreviousData, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes, getHistoricalMinMax,
|
keepPreviousData,
|
||||||
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
|
useMutation,
|
||||||
updatePeriodicExpense as updatePeriodicExpenseApi,
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
type CreateNonPeriodicExpenseInput,
|
||||||
|
type CreatePeriodicExpenseInput,
|
||||||
|
createNonPeriodicExpense as createNonPeriodicExpenseApi,
|
||||||
|
createPeriodicExpense as createPeriodicExpenseApi,
|
||||||
deletePeriodicExpense as deletePeriodicExpenseApi,
|
deletePeriodicExpense as deletePeriodicExpenseApi,
|
||||||
|
fetchQuotes,
|
||||||
generateMonthlyExpense as generateMonthlyExpenseApi,
|
generateMonthlyExpense as generateMonthlyExpenseApi,
|
||||||
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
|
getDailyMinMax,
|
||||||
payExpense as payExpenseApi,
|
getDailyQuotes,
|
||||||
|
getExpenses,
|
||||||
|
getHistoricalMinMax,
|
||||||
getMonthlyPayedTotal,
|
getMonthlyPayedTotal,
|
||||||
getMonthlyTotals,
|
getMonthlyTotals,
|
||||||
getTotalPending,
|
|
||||||
importPeriodicExpenses,
|
|
||||||
importExpenses,
|
|
||||||
getPendingUpcomingExpenses,
|
getPendingUpcomingExpenses,
|
||||||
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
getPeriodicExpenses,
|
||||||
|
getQuoteHistory,
|
||||||
|
getQuotes,
|
||||||
|
getTotalPending,
|
||||||
|
importExpenses,
|
||||||
|
importPeriodicExpenses,
|
||||||
|
type PayExpenseInput,
|
||||||
|
payExpense as payExpenseApi,
|
||||||
|
updatePeriodicExpense as updatePeriodicExpenseApi,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
export const quoteKeys = {
|
export const quoteKeys = {
|
||||||
@@ -48,7 +62,11 @@ export function useFetchQuotes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useQuoteHistory(type: string, startDate: string, endDate: string) {
|
export function useQuoteHistory(
|
||||||
|
type: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: quoteKeys.history(type, startDate, endDate),
|
queryKey: quoteKeys.history(type, startDate, endDate),
|
||||||
queryFn: () => getQuoteHistory(type, startDate, endDate),
|
queryFn: () => getQuoteHistory(type, startDate, endDate),
|
||||||
@@ -57,7 +75,11 @@ export function useQuoteHistory(type: string, startDate: string, endDate: string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDailyMinMax(type: string, startDate: string, endDate: string) {
|
export function useDailyMinMax(
|
||||||
|
type: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: quoteKeys.minMax(type, startDate, endDate),
|
queryKey: quoteKeys.minMax(type, startDate, endDate),
|
||||||
queryFn: () => getDailyMinMax(type, startDate, endDate),
|
queryFn: () => getDailyMinMax(type, startDate, endDate),
|
||||||
@@ -66,7 +88,11 @@ export function useDailyMinMax(type: string, startDate: string, endDate: string)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDailyQuotes(type: string, startDate: string, endDate: string) {
|
export function useDailyQuotes(
|
||||||
|
type: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: quoteKeys.daily(type, startDate, endDate),
|
queryKey: quoteKeys.daily(type, startDate, endDate),
|
||||||
queryFn: () => getDailyQuotes(type, startDate, endDate),
|
queryFn: () => getDailyQuotes(type, startDate, endDate),
|
||||||
@@ -88,10 +114,17 @@ export function useHistoricalMinMax(type: string) {
|
|||||||
export const expenseKeys = {
|
export const expenseKeys = {
|
||||||
periodic: ["periodic-expenses"] as const,
|
periodic: ["periodic-expenses"] as const,
|
||||||
all: ["expenses"] as const,
|
all: ["expenses"] as const,
|
||||||
byFilters: (status: string, page: number, pageSize: number, periodicExpenseId?: number, search?: string) =>
|
byFilters: (
|
||||||
["expenses", status, page, pageSize, periodicExpenseId, search] as const,
|
status: string,
|
||||||
monthlyTotal: (year: number, month: number) => ["expenses", "monthly-total", year, month] as const,
|
page: number,
|
||||||
monthlyTotals: (year: number, month: number) => ["expenses", "totals", year, month] as const,
|
pageSize: number,
|
||||||
|
periodicExpenseId?: number,
|
||||||
|
search?: string,
|
||||||
|
) => ["expenses", status, page, pageSize, periodicExpenseId, search] as const,
|
||||||
|
monthlyTotal: (year: number, month: number) =>
|
||||||
|
["expenses", "monthly-total", year, month] as const,
|
||||||
|
monthlyTotals: (year: number, month: number) =>
|
||||||
|
["expenses", "totals", year, month] as const,
|
||||||
totalPending: ["expenses", "pending-total"] as const,
|
totalPending: ["expenses", "pending-total"] as const,
|
||||||
pendingUpcoming: ["expenses", "pending-upcoming"] as const,
|
pendingUpcoming: ["expenses", "pending-upcoming"] as const,
|
||||||
};
|
};
|
||||||
@@ -106,7 +139,8 @@ export function usePeriodicExpenses() {
|
|||||||
export function useCreatePeriodicExpense() {
|
export function useCreatePeriodicExpense() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreatePeriodicExpenseInput) => createPeriodicExpenseApi(data),
|
mutationFn: (data: CreatePeriodicExpenseInput) =>
|
||||||
|
createPeriodicExpenseApi(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
||||||
},
|
},
|
||||||
@@ -116,8 +150,13 @@ export function useCreatePeriodicExpense() {
|
|||||||
export function useUpdatePeriodicExpense() {
|
export function useUpdatePeriodicExpense() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: CreatePeriodicExpenseInput }) =>
|
mutationFn: ({
|
||||||
updatePeriodicExpenseApi(id, data),
|
id,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
data: CreatePeriodicExpenseInput;
|
||||||
|
}) => updatePeriodicExpenseApi(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
||||||
},
|
},
|
||||||
@@ -144,11 +183,24 @@ export function useGenerateMonthlyExpense() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExpenses(status: string, page: number = 1, pageSize: number = 10, periodicExpenseId?: number, search?: string) {
|
export function useExpenses(
|
||||||
|
status: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
periodicExpenseId?: number,
|
||||||
|
search?: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: expenseKeys.byFilters(status, page, pageSize, periodicExpenseId, search),
|
queryKey: expenseKeys.byFilters(
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
periodicExpenseId,
|
||||||
|
search,
|
||||||
|
),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () => getExpenses(
|
queryFn: () =>
|
||||||
|
getExpenses(
|
||||||
status === "all" ? undefined : status,
|
status === "all" ? undefined : status,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -189,7 +241,8 @@ export function useMonthlyPayedTotal(year: number, month: number) {
|
|||||||
export function useCreateNonPeriodicExpense() {
|
export function useCreateNonPeriodicExpense() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateNonPeriodicExpenseInput) => createNonPeriodicExpenseApi(data),
|
mutationFn: (data: CreateNonPeriodicExpenseInput) =>
|
||||||
|
createNonPeriodicExpenseApi(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { quoteKeys } from "./queries";
|
import { quoteKeys } from "./queries";
|
||||||
|
|
||||||
type SseStatus = "connecting" | "connected" | "disconnected";
|
type SseStatus = "connecting" | "connected" | "disconnected";
|
||||||
@@ -17,7 +23,7 @@ export function SseProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const MAX_RETRIES = 10;
|
const MAX_RETRIES = 10;
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
let eventSource = new EventSource("/api/quotes/events");
|
const eventSource = new EventSource("/api/quotes/events");
|
||||||
|
|
||||||
eventSource.addEventListener("quotes-updated", () => {
|
eventSource.addEventListener("quotes-updated", () => {
|
||||||
queryClient.invalidateQueries({ queryKey: quoteKeys.all });
|
queryClient.invalidateQueries({ queryKey: quoteKeys.all });
|
||||||
@@ -43,9 +49,5 @@ export function SseProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return <SseContext.Provider value={status}>{children}</SseContext.Provider>;
|
||||||
<SseContext.Provider value={status}>
|
|
||||||
{children}
|
|
||||||
</SseContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
@@ -17,13 +23,16 @@ const THEME_CYCLE: Theme[] = ["light", "dark", "system"];
|
|||||||
function getStoredTheme(): Theme {
|
function getStoredTheme(): Theme {
|
||||||
if (typeof localStorage === "undefined") return "system";
|
if (typeof localStorage === "undefined") return "system";
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
if (stored === "light" || stored === "dark" || stored === "system")
|
||||||
|
return stored;
|
||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemTheme(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "light";
|
if (typeof window === "undefined") return "light";
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyThemeClass(resolved: "light" | "dark") {
|
function applyThemeClass(resolved: "light" | "dark") {
|
||||||
@@ -64,7 +73,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "d" && !e.shiftKey && !e.altKey) {
|
if (
|
||||||
|
(e.metaKey || e.ctrlKey) &&
|
||||||
|
e.key === "d" &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.altKey
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cycleTheme();
|
cycleTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { quoteKeys } from "./queries";
|
import { quoteKeys } from "./queries";
|
||||||
|
|
||||||
export function useQuoteEvents() {
|
export function useQuoteEvents() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { ThemeProvider } from "@/lib/theme";
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
import { AuthProvider } from "@/lib/auth-context";
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
import { ThemeProvider } from "@/lib/theme";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: root element exists in index.html
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export interface RouterContext {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
signIn: (password: string) => Promise<void>;
|
signIn: (password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
changePassword: (
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { createRootRouteWithContext, Outlet, redirect } from "@tanstack/react-router";
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
import type { RouterContext } from "@/router";
|
import type { RouterContext } from "@/router";
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated")({
|
export const Route = createFileRoute("/_authenticated")({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/quotes/belo")({
|
export const Route = createFileRoute("/_authenticated/quotes/belo")({
|
||||||
component: Outlet,
|
component: Outlet,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { 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";
|
||||||
@@ -51,14 +51,15 @@ function SettingsPage() {
|
|||||||
<div className="mx-auto max-w-md space-y-6">
|
<div className="mx-auto max-w-md space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Cambiar contraseña</p>
|
||||||
Cambiar contraseña
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Email</label>
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="email"
|
||||||
value={auth.session?.user.email ?? ""}
|
value={auth.session?.user.email ?? ""}
|
||||||
readOnly
|
readOnly
|
||||||
className="bg-muted"
|
className="bg-muted"
|
||||||
@@ -66,8 +67,11 @@ function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Contraseña actual</label>
|
<label htmlFor="current-password" className="text-sm font-medium">
|
||||||
|
Contraseña actual
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="current-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
@@ -75,8 +79,11 @@ function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Nueva contraseña</label>
|
<label htmlFor="new-password" className="text-sm font-medium">
|
||||||
|
Nueva contraseña
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="new-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
@@ -84,21 +91,30 @@ function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Confirmar nueva contraseña</label>
|
<label htmlFor="confirm-password" className="text-sm font-medium">
|
||||||
|
Confirmar nueva contraseña
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
{success && (
|
{success && (
|
||||||
<p className="text-sm text-emerald-600">Contraseña actualizada correctamente</p>
|
<p className="text-sm text-emerald-600">
|
||||||
|
Contraseña actualizada correctamente
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" className="w-full" disabled={loading || !currentPassword || !newPassword || !confirmPassword}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={
|
||||||
|
loading || !currentPassword || !newPassword || !confirmPassword
|
||||||
|
}
|
||||||
|
>
|
||||||
{loading ? "Guardando..." : "Cambiar contraseña"}
|
{loading ? "Guardando..." : "Cambiar contraseña"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
|
import { 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";
|
||||||
@@ -55,8 +55,11 @@ function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Email</label>
|
<label htmlFor="login-email" className="text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="login-email"
|
||||||
value="jselesan@gmail.com"
|
value="jselesan@gmail.com"
|
||||||
readOnly
|
readOnly
|
||||||
className="bg-muted text-muted-foreground"
|
className="bg-muted text-muted-foreground"
|
||||||
@@ -64,8 +67,11 @@ function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Contraseña</label>
|
<label htmlFor="login-password" className="text-sm font-medium">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="login-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -74,9 +80,7 @@ function LoginPage() {
|
|||||||
className="text-base"
|
className="text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import path from "path";
|
import path from "node:path";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
40
biome.json
Normal file
40
biome.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "conc -n backend,frontend -c blue,green \"bun run --cwd apps/backend dev\" \"bun run --cwd apps/frontend dev\"",
|
"dev": "conc -n backend,frontend -c blue,green \"bun run --cwd apps/backend dev\" \"bun run --cwd apps/frontend dev\"",
|
||||||
"build": "bun run --cwd apps/frontend build"
|
"build": "bun run --cwd apps/frontend build",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
|
"format": "biome format .",
|
||||||
|
"format:fix": "biome format --write ."
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.16",
|
||||||
"concurrently": "^9.1.0"
|
"concurrently": "^9.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user