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:
parent
5dfbdd2a1c
commit
ef2ae58046
@ -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:*",
|
||||
|
||||
48
apps/web/src/home/api/home.api.ts
Normal file
48
apps/web/src/home/api/home.api.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
182
apps/web/src/home/controllers/use-home-page-controller.ts
Normal file
182
apps/web/src/home/controllers/use-home-page-controller.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
38
apps/web/src/home/entities/home.entity.ts
Normal file
38
apps/web/src/home/entities/home.entity.ts
Normal 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;
|
||||
}
|
||||
25
apps/web/src/home/hooks/use-home-customers-total-query.ts
Normal file
25
apps/web/src/home/hooks/use-home-customers-total-query.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
};
|
||||
96
apps/web/src/home/hooks/use-home-monthly-billing-query.ts
Normal file
96
apps/web/src/home/hooks/use-home-monthly-billing-query.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
53
apps/web/src/home/hooks/use-home-proformas-overview-query.ts
Normal file
53
apps/web/src/home/hooks/use-home-proformas-overview-query.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
90
apps/web/src/home/hooks/use-home-recent-activity-query.ts
Normal file
90
apps/web/src/home/hooks/use-home-recent-activity-query.ts
Normal 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()]);
|
||||
},
|
||||
};
|
||||
};
|
||||
1
apps/web/src/home/index.ts
Normal file
1
apps/web/src/home/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./ui/pages/home-page";
|
||||
60
apps/web/src/home/ui/blocks/home-attention-panel.tsx
Normal file
60
apps/web/src/home/ui/blocks/home-attention-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
apps/web/src/home/ui/blocks/home-kpi-card.tsx
Normal file
30
apps/web/src/home/ui/blocks/home-kpi-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
apps/web/src/home/ui/blocks/home-monthly-billing-summary.tsx
Normal file
63
apps/web/src/home/ui/blocks/home-monthly-billing-summary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/home/ui/blocks/home-proforma-status-summary.tsx
Normal file
57
apps/web/src/home/ui/blocks/home-proforma-status-summary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
apps/web/src/home/ui/blocks/home-recent-activity.tsx
Normal file
79
apps/web/src/home/ui/blocks/home-recent-activity.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
112
apps/web/src/home/ui/pages/home-page.tsx
Normal file
112
apps/web/src/home/ui/pages/home-page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user