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.
This commit is contained in:
David Arranz 2026-08-13 11:04:57 +02:00
parent 5dfbdd2a1c
commit ef2ae58046
23 changed files with 1191 additions and 6 deletions

View File

@ -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:*",

View File

@ -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<ListCustomersResponseDTO> => {
const { criteria, signal } = params;
return dataSource.getList<ListCustomersResponseDTO>("customers", {
signal,
...criteria,
});
};
export const getHomeProformas = (
dataSource: IDataSource,
params: HomeListParams
): Promise<ListProformasResponseDTO> => {
const { criteria, signal } = params;
return dataSource.getList<ListProformasResponseDTO>("proformas", {
signal,
...criteria,
});
};
export const getHomeIssuedInvoices = (
dataSource: IDataSource,
params: HomeListParams
): Promise<ListIssuedInvoicesResponseDTO> => {
const { criteria, signal } = params;
return dataSource.getList<ListIssuedInvoicesResponseDTO>("issued-invoices", {
signal,
...criteria,
});
};

View File

@ -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<HomeKpi[]>(() => {
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<HomeAttentionItem[]>(() => {
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<HomeProformaStatusSummaryItem[]>(() => {
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,
};
};

View File

@ -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;
}

View File

@ -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;
},
});
};

View File

@ -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<HomeMonthlyBilling>({
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,
};
},
});
};

View File

@ -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<HomeProformasOverview>({
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,
};
},
});
};

View File

@ -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<HomeRecentActivityItem>((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<HomeRecentActivityItem>((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()]);
},
};
};

View File

@ -0,0 +1 @@
export * from "./ui/pages/home-page";

View File

@ -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 (
<section className="rounded-xl border border-border/70 bg-background p-5">
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t("web.home.attention.title")}</h2>
<p className="text-sm text-muted-foreground">{t("web.home.attention.description")}</p>
</div>
{hasError ? (
<ErrorAlert
message={t("web.home.errors.partial.message")}
title={t("web.home.errors.partial.title")}
/>
) : null}
{!hasError && items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("web.home.attention.empty")}</p>
) : null}
{!hasError ? (
<ul className="divide-y divide-border/70">
{items.map((item) => (
<li key={item.id}>
<Link
className="flex items-center justify-between gap-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
to={item.href}
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
<span className="mr-2 tabular-nums">{item.count}</span>
{item.title}
</p>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
<ChevronRightIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
</Link>
</li>
))}
</ul>
) : null}
</div>
</section>
);
};

View File

@ -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 (
<article className="rounded-xl border border-border/70 bg-background p-5">
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">{item.label}</p>
<p className="text-3xl font-semibold tracking-tight tabular-nums text-foreground">
{item.value}
</p>
<Link
className={cn(
"inline-flex text-sm font-medium text-primary underline-offset-4 hover:underline",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)}
to={item.href}
>
{item.ctaLabel}
</Link>
</div>
</article>
);
};

View File

@ -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 (
<section className="rounded-xl border border-border/70 bg-background p-5">
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">
{t("web.home.monthlyBilling.title")}
</h2>
<p className="text-sm text-muted-foreground">{t("web.home.monthlyBilling.description")}</p>
</div>
{hasError ? (
<ErrorAlert
message={t("web.home.errors.partial.message")}
title={t("web.home.errors.partial.title")}
/>
) : null}
{!hasError ? (
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight tabular-nums text-foreground">
{data?.totalAmountLabel ??
(data?.unavailableReason === "noInvoices"
? t("web.home.monthlyBilling.emptyAmount")
: t("web.home.monthlyBilling.unavailableAmount"))}
</p>
<p className="text-sm text-muted-foreground">
{t("web.home.monthlyBilling.invoiceCount", {
count: data?.invoiceCount ?? 0,
})}
</p>
{data?.unavailableReason === "mixedCurrencies" ? (
<p className="text-sm text-muted-foreground">
{t("web.home.monthlyBilling.mixedCurrencies")}
</p>
) : null}
{data?.unavailableReason === "tooManyInvoices" ? (
<p className="text-sm text-muted-foreground">
{t("web.home.monthlyBilling.tooManyInvoices")}
</p>
) : null}
</div>
) : null}
</div>
</section>
);
};

View File

@ -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 (
<section className="rounded-xl border border-border/70 bg-background p-5">
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t("web.home.proformas.title")}</h2>
<p className="text-sm text-muted-foreground">{t("web.home.proformas.description")}</p>
</div>
{hasError ? (
<ErrorAlert
message={t("web.home.errors.partial.message")}
title={t("web.home.errors.partial.title")}
/>
) : (
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id}>
<Link
className="flex items-center justify-between gap-4 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
to={item.href}
>
<span className="font-medium text-foreground">{item.label}</span>
<span className="tabular-nums text-muted-foreground">{item.count}</span>
</Link>
</li>
))}
</ul>
)}
<Link
className="inline-flex text-sm font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
to="/proformas"
>
{t("web.home.proformas.cta")}
</Link>
</div>
</section>
);
};

View File

@ -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 (
<section className="rounded-xl border border-border/70 bg-background p-5">
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">
{t("web.home.recentActivity.title")}
</h2>
<p className="text-sm text-muted-foreground">{t("web.home.recentActivity.description")}</p>
</div>
{hasPartialError ? (
<ErrorAlert
message={t("web.home.recentActivity.partialError")}
title={t("web.home.errors.partial.title")}
/>
) : null}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("web.home.recentActivity.empty")}</p>
) : (
<ul className="space-y-4">
{items.map((item) => (
<li key={`${item.type}-${item.id}`}>
<Link
className="flex flex-col gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
to={item.href}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
{item.amountLabel ? (
<span className="shrink-0 text-sm font-medium tabular-nums text-foreground">
{item.amountLabel}
</span>
) : null}
</div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{item.type === "proforma"
? t("web.home.recentActivity.proforma")
: t("web.home.recentActivity.issuedInvoice")}
{" · "}
{formatDocumentDate(item.occurredOn, i18n.language)}
</p>
</Link>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t("web.home.recentActivity.note")}</p>
</div>
</section>
);
};

View File

@ -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 (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div className="rounded-xl border border-border/70 bg-background p-5" key={index}>
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-16" />
<Skeleton className="h-4 w-20" />
</div>
</div>
))}
</div>
);
};
export const HomePage = () => {
const { t } = useAppTranslation();
const navigate = useNavigate();
const ctrl = useHomePageController();
if (ctrl.hasBlockingError && ctrl.pageError) {
return (
<RdxPageContainer width="full">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={ctrl.pageError.description}
primaryAction={{
label: t("web.actions.retry"),
onClick: ctrl.retry,
}}
secondaryAction={{
label: t("web.actions.goHome"),
onClick: () => navigate("/proformas"),
}}
title={ctrl.pageError.title}
variant="server"
/>
</div>
</RdxPageContainer>
);
}
return (
<RdxPageContainer width="full">
<RdxPageStack spacing="default">
<RdxPageHeader
description={t("web.home.header.description")}
title={t("web.home.header.title")}
/>
{ctrl.customersTotalQuery.isLoading ||
ctrl.proformasOverviewQuery.isLoading ||
ctrl.monthlyBillingQuery.isLoading ? (
<HomeKpisSkeleton />
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{ctrl.kpis.map((kpi) => (
<HomeKpiCard item={kpi} key={kpi.id} />
))}
</div>
)}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<HomeAttentionPanel
hasError={ctrl.proformasOverviewQuery.isError}
items={ctrl.attentionItems}
/>
<HomeProformaStatusSummary
hasError={ctrl.proformasOverviewQuery.isError}
items={ctrl.proformaStatusSummary}
/>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<HomeRecentActivity
hasPartialError={ctrl.recentActivityQuery.hasPartialError}
items={ctrl.recentActivityQuery.items}
/>
<HomeMonthlyBillingSummary
data={ctrl.monthlyBillingQuery.data}
hasError={ctrl.monthlyBillingQuery.isError}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("web.home.footer.note")}{" "}
<Link
className="underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
to="/customer-invoices"
>
{t("web.home.footer.link")}
</Link>
</p>
</RdxPageStack>
</RdxPageContainer>
);
};

View File

@ -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",

View File

@ -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
</kbd>
</div>
<div className="ml-auto hidden items-center lg:flex">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button className="gap-2" size="default" type="button">
<PlusIcon aria-hidden="true" className="size-4" />
{t("web.home.actions.new")}
<ChevronDownIcon aria-hidden="true" className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>{t("web.home.actions.new")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/proformas/create")}>
{t("web.home.actions.newProforma")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/customers/create")}>
{t("web.home.actions.newCustomer")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Separator className="h-full" orientation="vertical" />

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -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: <Navigate replace to="/proformas" />,
element: <Navigate replace to="/dashboard" />,
},
{
@ -119,7 +120,7 @@ export const getAppRouter = () => {
},
{
path: "dashboard",
element: <StaticRouteErrorPage variant="notFound" />,
element: <HomePage />,
},
{
path: "catalog",

View File

@ -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.",

View File

@ -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.",

View File

@ -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