From ef2ae58046cd15850d94c991920dbdbb3c314a59 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 13 Aug 2026 11:04:57 +0200 Subject: [PATCH] feat(home): implement home page with KPIs, recent activity, and attention items - Added Spanish translations for home page components. - Created API endpoints for fetching home-related data (customers, proformas, issued invoices). - Developed hooks for managing home page data queries. - Implemented home page layout with KPI cards, attention panels, monthly billing summary, and recent activity sections. - Added error handling and loading states for improved user experience. --- apps/web/package.json | 1 + apps/web/src/home/api/home.api.ts | 48 +++++ .../controllers/use-home-page-controller.ts | 182 ++++++++++++++++++ apps/web/src/home/entities/home.entity.ts | 38 ++++ .../hooks/use-home-customers-total-query.ts | 25 +++ .../hooks/use-home-monthly-billing-query.ts | 96 +++++++++ .../use-home-proformas-overview-query.ts | 53 +++++ .../hooks/use-home-recent-activity-query.ts | 90 +++++++++ apps/web/src/home/index.ts | 1 + .../home/ui/blocks/home-attention-panel.tsx | 60 ++++++ apps/web/src/home/ui/blocks/home-kpi-card.tsx | 30 +++ .../blocks/home-monthly-billing-summary.tsx | 63 ++++++ .../blocks/home-proforma-status-summary.tsx | 57 ++++++ .../home/ui/blocks/home-recent-activity.tsx | 79 ++++++++ apps/web/src/home/ui/pages/home-page.tsx | 112 +++++++++++ apps/web/src/layout/app-sidebar.config.ts | 2 + apps/web/src/layout/app-topbar.tsx | 32 ++- apps/web/src/locales/en.json | 91 +++++++++ apps/web/src/locales/es.json | 91 +++++++++ apps/web/src/routes/app-routes.tsx | 5 +- modules/catalogs/src/common/locales/en.json | 16 ++ modules/catalogs/src/common/locales/es.json | 16 ++ pnpm-lock.yaml | 9 +- 23 files changed, 1191 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/home/api/home.api.ts create mode 100644 apps/web/src/home/controllers/use-home-page-controller.ts create mode 100644 apps/web/src/home/entities/home.entity.ts create mode 100644 apps/web/src/home/hooks/use-home-customers-total-query.ts create mode 100644 apps/web/src/home/hooks/use-home-monthly-billing-query.ts create mode 100644 apps/web/src/home/hooks/use-home-proformas-overview-query.ts create mode 100644 apps/web/src/home/hooks/use-home-recent-activity-query.ts create mode 100644 apps/web/src/home/index.ts create mode 100644 apps/web/src/home/ui/blocks/home-attention-panel.tsx create mode 100644 apps/web/src/home/ui/blocks/home-kpi-card.tsx create mode 100644 apps/web/src/home/ui/blocks/home-monthly-billing-summary.tsx create mode 100644 apps/web/src/home/ui/blocks/home-proforma-status-summary.tsx create mode 100644 apps/web/src/home/ui/blocks/home-recent-activity.tsx create mode 100644 apps/web/src/home/ui/pages/home-page.tsx diff --git a/apps/web/package.json b/apps/web/package.json index d0d19532..5b80ab69 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "@erp/customer-invoices": "workspace:*", "@erp/customers": "workspace:*", "@erp/identity": "workspace:*", + "@repo/rdx-utils": "workspace:*", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@repo/i18next": "workspace:*", diff --git a/apps/web/src/home/api/home.api.ts b/apps/web/src/home/api/home.api.ts new file mode 100644 index 00000000..c77c50ea --- /dev/null +++ b/apps/web/src/home/api/home.api.ts @@ -0,0 +1,48 @@ +import type { CriteriaDTO } from "@erp/core"; +import type { IDataSource } from "@erp/core/client"; +import type { + ListIssuedInvoicesResponseDTO, + ListProformasResponseDTO, +} from "@erp/customer-invoices/common"; +import type { ListCustomersResponseDTO } from "@erp/customers/common"; + +interface HomeListParams { + criteria?: CriteriaDTO; + signal?: AbortSignal; +} + +export const getHomeCustomers = ( + dataSource: IDataSource, + params: HomeListParams +): Promise => { + const { criteria, signal } = params; + + return dataSource.getList("customers", { + signal, + ...criteria, + }); +}; + +export const getHomeProformas = ( + dataSource: IDataSource, + params: HomeListParams +): Promise => { + const { criteria, signal } = params; + + return dataSource.getList("proformas", { + signal, + ...criteria, + }); +}; + +export const getHomeIssuedInvoices = ( + dataSource: IDataSource, + params: HomeListParams +): Promise => { + const { criteria, signal } = params; + + return dataSource.getList("issued-invoices", { + signal, + ...criteria, + }); +}; diff --git a/apps/web/src/home/controllers/use-home-page-controller.ts b/apps/web/src/home/controllers/use-home-page-controller.ts new file mode 100644 index 00000000..17a51195 --- /dev/null +++ b/apps/web/src/home/controllers/use-home-page-controller.ts @@ -0,0 +1,182 @@ +import { useIdentityAuthSession } from "@erp/identity/client"; +import { useCallback, useMemo } from "react"; + +import { useAppTranslation } from "@/locales/i18n"; + +import type { + HomeAttentionItem, + HomeKpi, + HomeProformaStatusSummaryItem, +} from "../entities/home.entity"; +import { useHomeCustomersTotalQuery } from "../hooks/use-home-customers-total-query"; +import { useHomeMonthlyBillingQuery } from "../hooks/use-home-monthly-billing-query"; +import { useHomeProformasOverviewQuery } from "../hooks/use-home-proformas-overview-query"; +import { useHomeRecentActivityQuery } from "../hooks/use-home-recent-activity-query"; + +export const useHomePageController = () => { + const { t, i18n } = useAppTranslation(); + const { activeCompany, account } = useIdentityAuthSession(); + const customersTotalQuery = useHomeCustomersTotalQuery(); + const proformasOverviewQuery = useHomeProformasOverviewQuery(); + const monthlyBillingQuery = useHomeMonthlyBillingQuery(); + const recentActivityQuery = useHomeRecentActivityQuery(); + + const kpis = useMemo(() => { + const monthlyInvoiceCount = monthlyBillingQuery.data?.invoiceCount ?? 0; + const openProformasCount = proformasOverviewQuery.data?.openCount ?? 0; + const readyToIssueCount = proformasOverviewQuery.data?.readyToIssueCount ?? 0; + const customersTotal = customersTotalQuery.data ?? 0; + + return [ + { + id: "customers", + label: t("web.home.kpis.customers.label"), + value: new Intl.NumberFormat(i18n.language).format(customersTotal), + href: "/customers", + ctaLabel: t("web.home.kpis.customers.cta"), + }, + { + id: "open-proformas", + label: t("web.home.kpis.openProformas.label"), + value: new Intl.NumberFormat(i18n.language).format(openProformasCount), + href: "/proformas", + ctaLabel: t("web.home.kpis.openProformas.cta"), + }, + { + id: "ready-to-issue", + label: t("web.home.kpis.readyToIssue.label"), + value: new Intl.NumberFormat(i18n.language).format(readyToIssueCount), + href: "/proformas?status=approved", + ctaLabel: t("web.home.kpis.readyToIssue.cta"), + }, + { + id: "monthly-issued-invoices", + label: t("web.home.kpis.monthlyIssuedInvoices.label"), + value: new Intl.NumberFormat(i18n.language).format(monthlyInvoiceCount), + href: "/customer-invoices", + ctaLabel: t("web.home.kpis.monthlyIssuedInvoices.cta"), + }, + ]; + }, [ + customersTotalQuery.data, + i18n.language, + monthlyBillingQuery.data?.invoiceCount, + proformasOverviewQuery.data?.openCount, + proformasOverviewQuery.data?.readyToIssueCount, + t, + ]); + + const attentionItems = useMemo(() => { + const statusCounts = proformasOverviewQuery.data?.statusCounts; + + if (!statusCounts) { + return []; + } + + return [ + { + id: "approved", + count: statusCounts.approved, + href: "/proformas?status=approved", + title: t("web.home.attention.readyToIssue.title"), + description: t("web.home.attention.readyToIssue.description"), + }, + { + id: "sent", + count: statusCounts.sent, + href: "/proformas?status=sent", + title: t("web.home.attention.sent.title"), + description: t("web.home.attention.sent.description"), + }, + { + id: "draft", + count: statusCounts.draft, + href: "/proformas?status=draft", + title: t("web.home.attention.draft.title"), + description: t("web.home.attention.draft.description"), + }, + ].filter((item) => item.count > 0); + }, [proformasOverviewQuery.data?.statusCounts, t]); + + const proformaStatusSummary = useMemo(() => { + const statusCounts = proformasOverviewQuery.data?.statusCounts; + + if (!statusCounts) { + return []; + } + + return [ + { + id: "draft", + label: t("web.home.proformas.status.draft"), + count: statusCounts.draft, + href: "/proformas?status=draft", + }, + { + id: "sent", + label: t("web.home.proformas.status.sent"), + count: statusCounts.sent, + href: "/proformas?status=sent", + }, + { + id: "approved", + label: t("web.home.proformas.status.approved"), + count: statusCounts.approved, + href: "/proformas?status=approved", + }, + { + id: "rejected", + label: t("web.home.proformas.status.rejected"), + count: statusCounts.rejected, + href: "/proformas?status=rejected", + }, + { + id: "issued", + label: t("web.home.proformas.status.issued"), + count: statusCounts.issued, + href: "/proformas?status=issued", + }, + ]; + }, [proformasOverviewQuery.data?.statusCounts, t]); + + const hasBlockingError = + customersTotalQuery.isError && + proformasOverviewQuery.isError && + monthlyBillingQuery.isError && + recentActivityQuery.isError; + + const retry = useCallback(async () => { + await Promise.all([ + customersTotalQuery.refetch(), + proformasOverviewQuery.refetch(), + monthlyBillingQuery.refetch(), + recentActivityQuery.refetch(), + ]); + }, [customersTotalQuery, monthlyBillingQuery, proformasOverviewQuery, recentActivityQuery]); + + const pageError = useMemo(() => { + if (!hasBlockingError) { + return null; + } + + return { + title: t("web.home.errors.blocking.title"), + description: t("web.home.errors.blocking.description"), + }; + }, [hasBlockingError, t]); + + return { + account, + activeCompany, + kpis, + attentionItems, + proformaStatusSummary, + customersTotalQuery, + proformasOverviewQuery, + monthlyBillingQuery, + recentActivityQuery, + hasBlockingError, + pageError, + retry, + }; +}; diff --git a/apps/web/src/home/entities/home.entity.ts b/apps/web/src/home/entities/home.entity.ts new file mode 100644 index 00000000..a1af2e72 --- /dev/null +++ b/apps/web/src/home/entities/home.entity.ts @@ -0,0 +1,38 @@ +export interface HomeAttentionItem { + id: string; + count: number; + href: string; + title: string; + description: string; +} + +export interface HomeKpi { + id: string; + label: string; + value: string; + href: string; + ctaLabel: string; +} + +export interface HomeMonthlyBilling { + invoiceCount: number; + totalAmountLabel: string | null; + unavailableReason: "mixedCurrencies" | "tooManyInvoices" | "noInvoices" | null; +} + +export interface HomeProformaStatusSummaryItem { + id: string; + count: number; + href: string; + label: string; +} + +export interface HomeRecentActivityItem { + id: string; + href: string; + type: "proforma" | "issuedInvoice"; + title: string; + description: string; + amountLabel?: string; + occurredOn: string; +} diff --git a/apps/web/src/home/hooks/use-home-customers-total-query.ts b/apps/web/src/home/hooks/use-home-customers-total-query.ts new file mode 100644 index 00000000..b992f3eb --- /dev/null +++ b/apps/web/src/home/hooks/use-home-customers-total-query.ts @@ -0,0 +1,25 @@ +import { useDataSource } from "@erp/core/hooks"; +import { useQuery } from "@tanstack/react-query"; + +import { getHomeCustomers } from "../api/home.api"; + +export const useHomeCustomersTotalQuery = () => { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: ["home", "customers", "total"], + queryFn: async ({ signal }) => { + const response = await getHomeCustomers(dataSource, { + signal, + criteria: { + pageNumber: 0, + pageSize: 1, + orderBy: "name", + order: "asc", + }, + }); + + return response.total_items; + }, + }); +}; diff --git a/apps/web/src/home/hooks/use-home-monthly-billing-query.ts b/apps/web/src/home/hooks/use-home-monthly-billing-query.ts new file mode 100644 index 00000000..e8519a6e --- /dev/null +++ b/apps/web/src/home/hooks/use-home-monthly-billing-query.ts @@ -0,0 +1,96 @@ +import { MoneyDTOHelper } from "@erp/core"; +import { useDataSource } from "@erp/core/hooks"; +import { MoneyHelper } from "@repo/rdx-utils"; +import { useQuery } from "@tanstack/react-query"; +import { endOfMonth, format, startOfMonth } from "date-fns"; + +import { getHomeIssuedInvoices } from "../api/home.api"; +import type { HomeMonthlyBilling } from "../entities/home.entity"; + +const MONTHLY_BILLING_PAGE_SIZE = 100; + +export const useHomeMonthlyBillingQuery = () => { + const dataSource = useDataSource(); + const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd"); + const monthEnd = format(endOfMonth(new Date()), "yyyy-MM-dd"); + + return useQuery({ + queryKey: ["home", "issued-invoices", "monthly-billing", monthStart, monthEnd], + queryFn: async ({ signal }) => { + const response = await getHomeIssuedInvoices(dataSource, { + signal, + criteria: { + pageNumber: 0, + pageSize: MONTHLY_BILLING_PAGE_SIZE, + orderBy: "invoice_date", + order: "desc", + filters: [ + { + field: "invoice_date", + operator: "GREATER_THAN_OR_EQUAL", + value: monthStart, + }, + { + field: "invoice_date", + operator: "LOWER_THAN_OR_EQUAL", + value: monthEnd, + }, + ], + }, + }); + + if (response.total_items === 0) { + return { + invoiceCount: 0, + totalAmountLabel: null, + unavailableReason: "noInvoices", + }; + } + + if (response.total_items > response.items.length) { + return { + invoiceCount: response.total_items, + totalAmountLabel: null, + unavailableReason: "tooManyInvoices", + }; + } + + const [firstInvoice] = response.items; + + if (!firstInvoice) { + return { + invoiceCount: 0, + totalAmountLabel: null, + unavailableReason: "noInvoices", + }; + } + + const hasMixedCurrencies = response.items.some( + (invoice) => invoice.currency_code !== firstInvoice.currency_code + ); + + if (hasMixedCurrencies) { + return { + invoiceCount: response.total_items, + totalAmountLabel: null, + unavailableReason: "mixedCurrencies", + }; + } + + const totalAmount = response.items.reduce((accumulator, invoice) => { + return accumulator + MoneyDTOHelper.toNumber(invoice.total_amount); + }, 0); + + return { + invoiceCount: response.total_items, + totalAmountLabel: MoneyHelper.formatCurrency( + totalAmount, + Number(firstInvoice.total_amount.scale || 2), + firstInvoice.currency_code, + firstInvoice.language_code + ), + unavailableReason: null, + }; + }, + }); +}; diff --git a/apps/web/src/home/hooks/use-home-proformas-overview-query.ts b/apps/web/src/home/hooks/use-home-proformas-overview-query.ts new file mode 100644 index 00000000..f50813c1 --- /dev/null +++ b/apps/web/src/home/hooks/use-home-proformas-overview-query.ts @@ -0,0 +1,53 @@ +import { useDataSource } from "@erp/core/hooks"; +import { useQuery } from "@tanstack/react-query"; + +import { getHomeProformas } from "../api/home.api"; + +export interface HomeProformasOverview { + totalItems: number; + statusCounts: { + all: number; + draft: number; + sent: number; + approved: number; + rejected: number; + issued: number; + }; + openCount: number; + readyToIssueCount: number; +} + +export const useHomeProformasOverviewQuery = () => { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: ["home", "proformas", "overview"], + queryFn: async ({ signal }) => { + const response = await getHomeProformas(dataSource, { + signal, + criteria: { + pageNumber: 0, + pageSize: 1, + orderBy: "proforma_date", + order: "desc", + }, + }); + + const statusCounts = { + all: Number(response.metadata?.status_counts?.all ?? response.total_items ?? 0), + draft: Number(response.metadata?.status_counts?.draft ?? 0), + sent: Number(response.metadata?.status_counts?.sent ?? 0), + approved: Number(response.metadata?.status_counts?.approved ?? 0), + rejected: Number(response.metadata?.status_counts?.rejected ?? 0), + issued: Number(response.metadata?.status_counts?.issued ?? 0), + }; + + return { + totalItems: response.total_items, + statusCounts, + openCount: statusCounts.draft + statusCounts.sent + statusCounts.approved, + readyToIssueCount: statusCounts.approved, + }; + }, + }); +}; diff --git a/apps/web/src/home/hooks/use-home-recent-activity-query.ts b/apps/web/src/home/hooks/use-home-recent-activity-query.ts new file mode 100644 index 00000000..263fe21f --- /dev/null +++ b/apps/web/src/home/hooks/use-home-recent-activity-query.ts @@ -0,0 +1,90 @@ +import { MoneyDTOHelper } from "@erp/core"; +import { useDataSource } from "@erp/core/hooks"; +import { useQueries } from "@tanstack/react-query"; + +import { getHomeIssuedInvoices, getHomeProformas } from "../api/home.api"; +import type { HomeRecentActivityItem } from "../entities/home.entity"; + +const RECENT_ITEMS_LIMIT = 5; + +export const useHomeRecentActivityQuery = () => { + const dataSource = useDataSource(); + + const [recentProformasQuery, recentIssuedInvoicesQuery] = useQueries({ + queries: [ + { + queryKey: ["home", "proformas", "recent"], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const response = await getHomeProformas(dataSource, { + signal, + criteria: { + pageNumber: 0, + pageSize: RECENT_ITEMS_LIMIT, + orderBy: "proforma_date", + order: "desc", + }, + }); + + return response.items.map((proforma) => ({ + id: proforma.id, + href: `/proformas/${proforma.id}`, + type: "proforma", + title: proforma.proforma_reference, + description: proforma.recipient.name, + amountLabel: MoneyHelper.formatCurrency( + MoneyDTOHelper.toNumber(proforma.total_amount), + Number(proforma.total_amount.scale || 2), + proforma.currency_code, + proforma.language_code + ), + occurredOn: proforma.proforma_date, + })); + }, + }, + { + queryKey: ["home", "issued-invoices", "recent"], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const response = await getHomeIssuedInvoices(dataSource, { + signal, + criteria: { + pageNumber: 0, + pageSize: RECENT_ITEMS_LIMIT, + orderBy: "invoice_date", + order: "desc", + }, + }); + + return response.items.map((invoice) => ({ + id: invoice.id, + href: `/customer-invoices/${invoice.id}`, + type: "issuedInvoice", + title: invoice.invoice_number, + description: invoice.recipient.name, + amountLabel: MoneyHelper.formatCurrency( + MoneyDTOHelper.toNumber(invoice.total_amount), + Number(invoice.total_amount.scale || 2), + invoice.currency_code, + invoice.language_code + ), + occurredOn: invoice.invoice_date, + })); + }, + }, + ], + }); + + const items = [...(recentProformasQuery.data ?? []), ...(recentIssuedInvoicesQuery.data ?? [])] + .sort((left, right) => right.occurredOn.localeCompare(left.occurredOn)) + .slice(0, RECENT_ITEMS_LIMIT); + + return { + items, + isLoading: recentProformasQuery.isLoading || recentIssuedInvoicesQuery.isLoading, + isError: recentProformasQuery.isError && recentIssuedInvoicesQuery.isError, + hasPartialError: recentProformasQuery.isError || recentIssuedInvoicesQuery.isError, + error: recentProformasQuery.error ?? recentIssuedInvoicesQuery.error ?? null, + refetch: async () => { + await Promise.all([recentProformasQuery.refetch(), recentIssuedInvoicesQuery.refetch()]); + }, + }; +}; diff --git a/apps/web/src/home/index.ts b/apps/web/src/home/index.ts new file mode 100644 index 00000000..3d66b062 --- /dev/null +++ b/apps/web/src/home/index.ts @@ -0,0 +1 @@ +export * from "./ui/pages/home-page"; diff --git a/apps/web/src/home/ui/blocks/home-attention-panel.tsx b/apps/web/src/home/ui/blocks/home-attention-panel.tsx new file mode 100644 index 00000000..12b6feff --- /dev/null +++ b/apps/web/src/home/ui/blocks/home-attention-panel.tsx @@ -0,0 +1,60 @@ +import { ErrorAlert } from "@erp/core/components"; +import { ChevronRightIcon } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { useAppTranslation } from "@/locales/i18n"; + +import type { HomeAttentionItem } from "../../entities/home.entity"; + +interface HomeAttentionPanelProps { + items: HomeAttentionItem[]; + hasError: boolean; +} + +export const HomeAttentionPanel = ({ items, hasError }: HomeAttentionPanelProps) => { + const { t } = useAppTranslation(); + + return ( +
+
+
+

{t("web.home.attention.title")}

+

{t("web.home.attention.description")}

+
+ + {hasError ? ( + + ) : null} + + {!hasError && items.length === 0 ? ( +

{t("web.home.attention.empty")}

+ ) : null} + + {!hasError ? ( +
    + {items.map((item) => ( +
  • + +
    +

    + {item.count} + {item.title} +

    +

    {item.description}

    +
    + + +
  • + ))} +
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/src/home/ui/blocks/home-kpi-card.tsx b/apps/web/src/home/ui/blocks/home-kpi-card.tsx new file mode 100644 index 00000000..2b47ff2d --- /dev/null +++ b/apps/web/src/home/ui/blocks/home-kpi-card.tsx @@ -0,0 +1,30 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { Link } from "react-router-dom"; + +import type { HomeKpi } from "../../entities/home.entity"; + +interface HomeKpiCardProps { + item: HomeKpi; +} + +export const HomeKpiCard = ({ item }: HomeKpiCardProps) => { + return ( +
+
+

{item.label}

+

+ {item.value} +

+ + {item.ctaLabel} + +
+
+ ); +}; diff --git a/apps/web/src/home/ui/blocks/home-monthly-billing-summary.tsx b/apps/web/src/home/ui/blocks/home-monthly-billing-summary.tsx new file mode 100644 index 00000000..2c14f95c --- /dev/null +++ b/apps/web/src/home/ui/blocks/home-monthly-billing-summary.tsx @@ -0,0 +1,63 @@ +import { ErrorAlert } from "@erp/core/components"; + +import { useAppTranslation } from "@/locales/i18n"; + +import type { HomeMonthlyBilling } from "../../entities/home.entity"; + +interface HomeMonthlyBillingSummaryProps { + data: HomeMonthlyBilling | undefined; + hasError: boolean; +} + +export const HomeMonthlyBillingSummary = ({ + data, + hasError, +}: HomeMonthlyBillingSummaryProps) => { + const { t } = useAppTranslation(); + + return ( +
+
+
+

+ {t("web.home.monthlyBilling.title")} +

+

{t("web.home.monthlyBilling.description")}

+
+ + {hasError ? ( + + ) : null} + + {!hasError ? ( +
+

+ {data?.totalAmountLabel ?? + (data?.unavailableReason === "noInvoices" + ? t("web.home.monthlyBilling.emptyAmount") + : t("web.home.monthlyBilling.unavailableAmount"))} +

+

+ {t("web.home.monthlyBilling.invoiceCount", { + count: data?.invoiceCount ?? 0, + })} +

+ {data?.unavailableReason === "mixedCurrencies" ? ( +

+ {t("web.home.monthlyBilling.mixedCurrencies")} +

+ ) : null} + {data?.unavailableReason === "tooManyInvoices" ? ( +

+ {t("web.home.monthlyBilling.tooManyInvoices")} +

+ ) : null} +
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/src/home/ui/blocks/home-proforma-status-summary.tsx b/apps/web/src/home/ui/blocks/home-proforma-status-summary.tsx new file mode 100644 index 00000000..c4cf38c5 --- /dev/null +++ b/apps/web/src/home/ui/blocks/home-proforma-status-summary.tsx @@ -0,0 +1,57 @@ +import { ErrorAlert } from "@erp/core/components"; +import { Link } from "react-router-dom"; + +import { useAppTranslation } from "@/locales/i18n"; + +import type { HomeProformaStatusSummaryItem } from "../../entities/home.entity"; + +interface HomeProformaStatusSummaryProps { + items: HomeProformaStatusSummaryItem[]; + hasError: boolean; +} + +export const HomeProformaStatusSummary = ({ + items, + hasError, +}: HomeProformaStatusSummaryProps) => { + const { t } = useAppTranslation(); + + return ( +
+
+
+

{t("web.home.proformas.title")}

+

{t("web.home.proformas.description")}

+
+ + {hasError ? ( + + ) : ( +
    + {items.map((item) => ( +
  • + + {item.label} + {item.count} + +
  • + ))} +
+ )} + + + {t("web.home.proformas.cta")} + +
+
+ ); +}; diff --git a/apps/web/src/home/ui/blocks/home-recent-activity.tsx b/apps/web/src/home/ui/blocks/home-recent-activity.tsx new file mode 100644 index 00000000..52892760 --- /dev/null +++ b/apps/web/src/home/ui/blocks/home-recent-activity.tsx @@ -0,0 +1,79 @@ +import { ErrorAlert } from "@erp/core/components"; +import { Link } from "react-router-dom"; + +import { useAppTranslation } from "@/locales/i18n"; + +import type { HomeRecentActivityItem } from "../../entities/home.entity"; + +interface HomeRecentActivityProps { + items: HomeRecentActivityItem[]; + hasPartialError: boolean; +} + +const formatDocumentDate = (value: string, locale: string) => { + return new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(value)); +}; + +export const HomeRecentActivity = ({ items, hasPartialError }: HomeRecentActivityProps) => { + const { t, i18n } = useAppTranslation(); + + return ( +
+
+
+

+ {t("web.home.recentActivity.title")} +

+

{t("web.home.recentActivity.description")}

+
+ + {hasPartialError ? ( + + ) : null} + + {items.length === 0 ? ( +

{t("web.home.recentActivity.empty")}

+ ) : ( +
    + {items.map((item) => ( +
  • + +
    +
    +

    {item.title}

    +

    {item.description}

    +
    + {item.amountLabel ? ( + + {item.amountLabel} + + ) : null} +
    +

    + {item.type === "proforma" + ? t("web.home.recentActivity.proforma") + : t("web.home.recentActivity.issuedInvoice")} + {" · "} + {formatDocumentDate(item.occurredOn, i18n.language)} +

    + +
  • + ))} +
+ )} + +

{t("web.home.recentActivity.note")}

+
+
+ ); +}; diff --git a/apps/web/src/home/ui/pages/home-page.tsx b/apps/web/src/home/ui/pages/home-page.tsx new file mode 100644 index 00000000..74d0a510 --- /dev/null +++ b/apps/web/src/home/ui/pages/home-page.tsx @@ -0,0 +1,112 @@ +import { PageErrorState } from "@erp/core/components"; +import { RdxPageContainer, RdxPageHeader, RdxPageStack } from "@repo/rdx-ui/components"; +import { Skeleton } from "@repo/shadcn-ui/components"; +import { Link, useNavigate } from "react-router-dom"; + +import { useAppTranslation } from "@/locales/i18n"; + +import { useHomePageController } from "../../controllers/use-home-page-controller"; +import { HomeAttentionPanel } from "../blocks/home-attention-panel"; +import { HomeKpiCard } from "../blocks/home-kpi-card"; +import { HomeMonthlyBillingSummary } from "../blocks/home-monthly-billing-summary"; +import { HomeProformaStatusSummary } from "../blocks/home-proforma-status-summary"; +import { HomeRecentActivity } from "../blocks/home-recent-activity"; + +const HomeKpisSkeleton = () => { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+ + + +
+
+ ))} +
+ ); +}; + +export const HomePage = () => { + const { t } = useAppTranslation(); + const navigate = useNavigate(); + const ctrl = useHomePageController(); + + if (ctrl.hasBlockingError && ctrl.pageError) { + return ( + +
+ navigate("/proformas"), + }} + title={ctrl.pageError.title} + variant="server" + /> +
+
+ ); + } + + return ( + + + + + {ctrl.customersTotalQuery.isLoading || + ctrl.proformasOverviewQuery.isLoading || + ctrl.monthlyBillingQuery.isLoading ? ( + + ) : ( +
+ {ctrl.kpis.map((kpi) => ( + + ))} +
+ )} + +
+ + +
+ +
+ + +
+ +

+ {t("web.home.footer.note")}{" "} + + {t("web.home.footer.link")} + +

+
+
+ ); +}; diff --git a/apps/web/src/layout/app-sidebar.config.ts b/apps/web/src/layout/app-sidebar.config.ts index 65965432..3b88d501 100644 --- a/apps/web/src/layout/app-sidebar.config.ts +++ b/apps/web/src/layout/app-sidebar.config.ts @@ -6,6 +6,7 @@ import { ClipboardListIcon, CreditCardIcon, FileTextIcon, + HouseIcon, LandmarkIcon, PackageIcon, ReceiptIcon, @@ -40,6 +41,7 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [ title: "Ventas", accent: "blue", items: [ + { title: "Inicio", href: "/dashboard", icon: HouseIcon, exact: true }, { title: "Proformas", href: "/proformas", diff --git a/apps/web/src/layout/app-topbar.tsx b/apps/web/src/layout/app-topbar.tsx index 130177f5..04d67f7c 100644 --- a/apps/web/src/layout/app-topbar.tsx +++ b/apps/web/src/layout/app-topbar.tsx @@ -12,10 +12,13 @@ import { Separator, SidebarTrigger, } from "@repo/shadcn-ui/components"; -import { BellIcon, ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react"; +import { BellIcon, ChevronDownIcon, MessageSquareIcon, PlusIcon, SearchIcon } from "lucide-react"; import { useNavigate } from "react-router-dom"; +import { useAppTranslation } from "@/locales/i18n"; + export const AppTopbar = () => { + const { t } = useAppTranslation(); const navigate = useNavigate(); const { account, logout } = useIdentityAuthSession(); @@ -52,6 +55,33 @@ export const AppTopbar = () => { ⌘K + +
+ + + +
diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 5341f32c..9861fcf8 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -47,6 +47,97 @@ "description": "Module configuration entries will appear here once they are published in the shell.", "navigation": "There are no sections available yet." } + }, + "home": { + "header": { + "title": "Good morning", + "description": "Operational summary of your ERP" + }, + "actions": { + "new": "New", + "newProforma": "New proforma", + "newCustomer": "New customer" + }, + "kpis": { + "customers": { + "label": "Customers", + "cta": "View customers" + }, + "openProformas": { + "label": "Open proformas", + "cta": "View proformas" + }, + "readyToIssue": { + "label": "Pending issue", + "cta": "Review" + }, + "monthlyIssuedInvoices": { + "label": "Invoices issued this month", + "cta": "View invoices" + } + }, + "attention": { + "title": "Needs your attention", + "description": "Pending work backed by the real proforma states.", + "empty": "There are no proformas needing follow-up right now.", + "readyToIssue": { + "title": "Proformas ready to issue", + "description": "Approved and ready to generate an invoice." + }, + "sent": { + "title": "Sent proformas", + "description": "Waiting for customer response." + }, + "draft": { + "title": "Pending drafts", + "description": "Not sent to the customer yet." + } + }, + "proformas": { + "title": "Proformas", + "description": "Compact distribution by operational status.", + "cta": "View all proformas", + "status": { + "draft": "Draft", + "sent": "Sent", + "approved": "Approved", + "rejected": "Rejected", + "issued": "Issued" + } + }, + "recentActivity": { + "title": "Recent activity", + "description": "Latest documents by document date.", + "empty": "There are no recent proformas or issued invoices to show yet.", + "partialError": "Part of the recent activity could not be loaded.", + "proforma": "Proforma", + "issuedInvoice": "Issued invoice", + "note": "This section combines proformas and issued invoices by document date. Customers are not included because the current customer list does not expose creation or update timestamps." + }, + "monthlyBilling": { + "title": "Monthly billing", + "description": "Based only on issued invoices from the current calendar month.", + "invoiceCount_one": "{{count}} issued invoice", + "invoiceCount_other": "{{count}} issued invoices", + "emptyAmount": "No invoices", + "unavailableAmount": "Amount unavailable", + "mixedCurrencies": "The total amount is hidden because this month contains invoices in multiple currencies.", + "tooManyInvoices": "The total amount is hidden because the current API does not expose reliable monthly aggregates and the month exceeds the safe batch loaded by Home." + }, + "errors": { + "blocking": { + "title": "Home could not be loaded", + "description": "The main Home blocks are unavailable right now." + }, + "partial": { + "title": "Block unavailable", + "message": "This section could not be refreshed with the current data." + } + }, + "footer": { + "note": "Monthly billing is only calculated when the API returns every invoice from the month in a bounded batch and using a single currency.", + "link": "Open issued invoices" + } } } } diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 9e5bb077..826d4797 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -47,6 +47,97 @@ "description": "Las opciones de configuración de los módulos aparecerán aquí cuando se publiquen en el shell.", "navigation": "No hay secciones disponibles todavía." } + }, + "home": { + "header": { + "title": "Buenos días", + "description": "Resumen operativo de tu ERP" + }, + "actions": { + "new": "Nuevo", + "newProforma": "Nueva proforma", + "newCustomer": "Nuevo cliente" + }, + "kpis": { + "customers": { + "label": "Clientes", + "cta": "Ver clientes" + }, + "openProformas": { + "label": "Proformas abiertas", + "cta": "Ver proformas" + }, + "readyToIssue": { + "label": "Pendientes de emitir", + "cta": "Revisar" + }, + "monthlyIssuedInvoices": { + "label": "Facturas emitidas este mes", + "cta": "Ver facturas" + } + }, + "attention": { + "title": "Requiere tu atención", + "description": "Trabajo pendiente respaldado por los estados reales de proformas.", + "empty": "No hay proformas pendientes de seguimiento ahora mismo.", + "readyToIssue": { + "title": "Proformas listas para emitir", + "description": "Aprobadas y preparadas para generar factura." + }, + "sent": { + "title": "Proformas enviadas", + "description": "Pendientes de respuesta del cliente." + }, + "draft": { + "title": "Borradores pendientes", + "description": "Todavía no se han enviado al cliente." + } + }, + "proformas": { + "title": "Proformas", + "description": "Distribución compacta por estado operativo.", + "cta": "Ver todas las proformas", + "status": { + "draft": "Borrador", + "sent": "Enviadas", + "approved": "Aprobadas", + "rejected": "Rechazadas", + "issued": "Emitidas" + } + }, + "recentActivity": { + "title": "Actividad reciente", + "description": "Últimos documentos por fecha de documento.", + "empty": "Todavía no hay proformas ni facturas emitidas recientes para mostrar.", + "partialError": "No se pudo cargar parte de la actividad reciente.", + "proforma": "Proforma", + "issuedInvoice": "Factura emitida", + "note": "Esta sección combina proformas y facturas por fecha de documento. Los clientes no se incluyen porque su listado actual no expone fechas de creación o modificación." + }, + "monthlyBilling": { + "title": "Facturación del mes", + "description": "Basada únicamente en facturas emitidas del mes natural actual.", + "invoiceCount_one": "{{count}} factura emitida", + "invoiceCount_other": "{{count}} facturas emitidas", + "emptyAmount": "Sin facturas", + "unavailableAmount": "Importe no disponible", + "mixedCurrencies": "No se muestra el importe total porque este mes hay facturas en varias divisas.", + "tooManyInvoices": "No se muestra el importe total porque la API actual no expone agregados mensuales fiables y el mes supera el lote seguro cargado por la Home." + }, + "errors": { + "blocking": { + "title": "No se pudo cargar el inicio", + "description": "Los bloques principales de la Home no están disponibles ahora mismo." + }, + "partial": { + "title": "Bloque no disponible", + "message": "Esta sección no se ha podido actualizar con los datos actuales." + } + }, + "footer": { + "note": "La facturación mensual se calcula solo cuando la API devuelve todas las facturas del mes en un lote acotado y con una única divisa.", + "link": "Abrir facturas emitidas" + } } } } diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index e046f08b..a436550a 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -12,6 +12,7 @@ import { AuthLayoutFrame, } from "@/layout"; import { UiKitPage } from "@/development/ui-kit"; +import { HomePage } from "@/home"; import ShadcnShowcasePage from "@/pages/shadcn-ui-page"; import { buildSettingsRoute } from "@/settings"; import TailwindV4ShowcasePage from "@/pages/tailwindcss-page"; @@ -76,7 +77,7 @@ export const getAppRouter = () => { children: [ { index: true, - element: , + element: , }, { @@ -119,7 +120,7 @@ export const getAppRouter = () => { }, { path: "dashboard", - element: , + element: , }, { path: "catalog", diff --git a/modules/catalogs/src/common/locales/en.json b/modules/catalogs/src/common/locales/en.json index 1a2218cf..493a5a35 100644 --- a/modules/catalogs/src/common/locales/en.json +++ b/modules/catalogs/src/common/locales/en.json @@ -3,6 +3,18 @@ "paymentMethods": { "title": "Payment methods", "description": "Manage the payment methods available for the company.", + "search": { + "placeholder": "Search method..." + }, + "summary": { + "activeCountSingular": "{{count}} active", + "activeCountPlural": "{{count}} active" + }, + "meta": { + "systemDescription": "System method available for the company.", + "customDescription": "Custom method created for this company.", + "defaultDescription": "Default payment method for the company." + }, "default": { "label": "Default payment method", "rowLabel": "Set {{name}} as the default payment method" @@ -54,6 +66,10 @@ "title": "There are no payment methods configured.", "description": "Available payment methods will appear here." }, + "emptyFiltered": { + "title": "There are no results for this search.", + "description": "Try another name or clear the filter to see every method." + }, "messages": { "createdTitle": "Payment method created", "created": "The payment method was created successfully.", diff --git a/modules/catalogs/src/common/locales/es.json b/modules/catalogs/src/common/locales/es.json index 41a40566..20b8081f 100644 --- a/modules/catalogs/src/common/locales/es.json +++ b/modules/catalogs/src/common/locales/es.json @@ -3,6 +3,18 @@ "paymentMethods": { "title": "Métodos de pago", "description": "Gestiona las formas de pago disponibles para la empresa.", + "search": { + "placeholder": "Buscar método..." + }, + "summary": { + "activeCountSingular": "{{count}} activo", + "activeCountPlural": "{{count}} activos" + }, + "meta": { + "systemDescription": "Método del sistema disponible para la empresa.", + "customDescription": "Método personalizado creado para esta empresa.", + "defaultDescription": "Método predeterminado de la empresa." + }, "default": { "label": "Método predeterminado", "rowLabel": "Establecer {{name}} como método predeterminado" @@ -54,6 +66,10 @@ "title": "No hay métodos de pago configurados.", "description": "Cuando existan métodos de pago disponibles aparecerán aquí." }, + "emptyFiltered": { + "title": "No hay resultados para esta búsqueda.", + "description": "Prueba con otro nombre o limpia el filtro para ver todos los métodos." + }, "messages": { "createdTitle": "Método de pago creado", "created": "El método de pago se ha creado correctamente.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f734b456..36ada777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: '@repo/rdx-ui': specifier: workspace:* version: link:../../packages/rdx-ui + '@repo/rdx-utils': + specifier: workspace:* + version: link:../../packages/rdx-utils '@repo/shadcn-ui': specifier: workspace:* version: link:../../packages/shadcn-ui @@ -1331,12 +1334,12 @@ importers: tailwindcss: specifier: ^4.2.2 version: 4.3.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.2) + tsx: + specifier: ^4.21.0 + version: 4.21.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0