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/customer-invoices": "workspace:*",
|
||||||
"@erp/customers": "workspace:*",
|
"@erp/customers": "workspace:*",
|
||||||
"@erp/identity": "workspace:*",
|
"@erp/identity": "workspace:*",
|
||||||
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||||
"@repo/i18next": "workspace:*",
|
"@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,
|
ClipboardListIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
HouseIcon,
|
||||||
LandmarkIcon,
|
LandmarkIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
ReceiptIcon,
|
ReceiptIcon,
|
||||||
@ -40,6 +41,7 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
|
|||||||
title: "Ventas",
|
title: "Ventas",
|
||||||
accent: "blue",
|
accent: "blue",
|
||||||
items: [
|
items: [
|
||||||
|
{ title: "Inicio", href: "/dashboard", icon: HouseIcon, exact: true },
|
||||||
{
|
{
|
||||||
title: "Proformas",
|
title: "Proformas",
|
||||||
href: "/proformas",
|
href: "/proformas",
|
||||||
|
|||||||
@ -12,10 +12,13 @@ import {
|
|||||||
Separator,
|
Separator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@repo/shadcn-ui/components";
|
} 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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAppTranslation } from "@/locales/i18n";
|
||||||
|
|
||||||
export const AppTopbar = () => {
|
export const AppTopbar = () => {
|
||||||
|
const { t } = useAppTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account, logout } = useIdentityAuthSession();
|
const { account, logout } = useIdentityAuthSession();
|
||||||
|
|
||||||
@ -52,6 +55,33 @@ export const AppTopbar = () => {
|
|||||||
⌘K
|
⌘K
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Separator className="h-full" orientation="vertical" />
|
<Separator className="h-full" orientation="vertical" />
|
||||||
|
|||||||
@ -47,6 +47,97 @@
|
|||||||
"description": "Module configuration entries will appear here once they are published in the shell.",
|
"description": "Module configuration entries will appear here once they are published in the shell.",
|
||||||
"navigation": "There are no sections available yet."
|
"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.",
|
"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."
|
"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,
|
AuthLayoutFrame,
|
||||||
} from "@/layout";
|
} from "@/layout";
|
||||||
import { UiKitPage } from "@/development/ui-kit";
|
import { UiKitPage } from "@/development/ui-kit";
|
||||||
|
import { HomePage } from "@/home";
|
||||||
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
|
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
|
||||||
import { buildSettingsRoute } from "@/settings";
|
import { buildSettingsRoute } from "@/settings";
|
||||||
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
|
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
|
||||||
@ -76,7 +77,7 @@ export const getAppRouter = () => {
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
element: <Navigate replace to="/proformas" />,
|
element: <Navigate replace to="/dashboard" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -119,7 +120,7 @@ export const getAppRouter = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "dashboard",
|
path: "dashboard",
|
||||||
element: <StaticRouteErrorPage variant="notFound" />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "catalog",
|
path: "catalog",
|
||||||
|
|||||||
@ -3,6 +3,18 @@
|
|||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "Payment methods",
|
"title": "Payment methods",
|
||||||
"description": "Manage the payment methods available for the company.",
|
"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": {
|
"default": {
|
||||||
"label": "Default payment method",
|
"label": "Default payment method",
|
||||||
"rowLabel": "Set {{name}} as the default payment method"
|
"rowLabel": "Set {{name}} as the default payment method"
|
||||||
@ -54,6 +66,10 @@
|
|||||||
"title": "There are no payment methods configured.",
|
"title": "There are no payment methods configured.",
|
||||||
"description": "Available payment methods will appear here."
|
"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": {
|
"messages": {
|
||||||
"createdTitle": "Payment method created",
|
"createdTitle": "Payment method created",
|
||||||
"created": "The payment method was created successfully.",
|
"created": "The payment method was created successfully.",
|
||||||
|
|||||||
@ -3,6 +3,18 @@
|
|||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "Métodos de pago",
|
"title": "Métodos de pago",
|
||||||
"description": "Gestiona las formas de pago disponibles para la empresa.",
|
"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": {
|
"default": {
|
||||||
"label": "Método predeterminado",
|
"label": "Método predeterminado",
|
||||||
"rowLabel": "Establecer {{name}} como método predeterminado"
|
"rowLabel": "Establecer {{name}} como método predeterminado"
|
||||||
@ -54,6 +66,10 @@
|
|||||||
"title": "No hay métodos de pago configurados.",
|
"title": "No hay métodos de pago configurados.",
|
||||||
"description": "Cuando existan métodos de pago disponibles aparecerán aquí."
|
"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": {
|
"messages": {
|
||||||
"createdTitle": "Método de pago creado",
|
"createdTitle": "Método de pago creado",
|
||||||
"created": "El método de pago se ha creado correctamente.",
|
"created": "El método de pago se ha creado correctamente.",
|
||||||
|
|||||||
@ -258,6 +258,9 @@ importers:
|
|||||||
'@repo/rdx-ui':
|
'@repo/rdx-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-ui
|
version: link:../../packages/rdx-ui
|
||||||
|
'@repo/rdx-utils':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rdx-utils
|
||||||
'@repo/shadcn-ui':
|
'@repo/shadcn-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shadcn-ui
|
version: link:../../packages/shadcn-ui
|
||||||
@ -1331,12 +1334,12 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
tsx:
|
|
||||||
specifier: ^4.21.0
|
|
||||||
version: 4.21.0
|
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.1
|
specifier: ^8.5.1
|
||||||
version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.2)
|
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:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user