New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View File

@@ -40,7 +40,7 @@ server {
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
access_log off;
try_files $uri @fallback;
try_files $uri =404;
}
# Special handling for PWA assets

View File

@@ -39,6 +39,7 @@
"react-chartjs-2": "^5.3.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.48.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^13.5.0",
@@ -138,6 +139,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2267,7 +2269,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2",
@@ -2280,7 +2281,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -2290,7 +2290,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/icu-skeleton-parser": "1.8.16",
@@ -2302,7 +2301,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"tslib": "^2.8.0"
@@ -2313,7 +2311,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -2742,6 +2739,7 @@
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -6044,6 +6042,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz",
"integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -6133,6 +6132,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.89.0.tgz",
"integrity": "sha512-SXbtWSTSRXyBOe80mszPxpEbaN4XPRUp/i0EfQK1uyj3KCk/c8FuPJNIRwzOVe/OU3rzxrYtiNabsAmk1l714A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.89.0"
},
@@ -6625,6 +6625,7 @@
"integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -6636,6 +6637,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -6777,6 +6779,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -7115,6 +7118,7 @@
"integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "1.6.1",
"fast-glob": "^3.3.2",
@@ -7212,6 +7216,7 @@
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7754,6 +7759,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -7959,6 +7965,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -8323,7 +8330,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3-array": {
"version": "3.2.4",
@@ -8505,6 +8513,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
@@ -8547,8 +8556,7 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
@@ -9032,6 +9040,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -9135,6 +9144,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10560,6 +10570,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.2"
}
@@ -10608,6 +10619,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -12766,6 +12778,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -12932,6 +12945,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13204,6 +13218,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -13276,6 +13291,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -13324,11 +13340,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-error-boundary": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-hook-form": {
"version": "7.63.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
"integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -13920,6 +13949,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -14802,6 +14832,7 @@
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -15333,6 +15364,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15745,6 +15777,7 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -16308,6 +16341,7 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
@@ -16689,6 +16723,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",

View File

@@ -60,6 +60,7 @@
"react-chartjs-2": "^5.3.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.48.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^13.5.0",

View File

@@ -102,6 +102,9 @@ class ApiClient {
// Only add auth token for non-public endpoints
if (this.authToken && !isPublicEndpoint) {
config.headers.Authorization = `Bearer ${this.authToken}`;
console.log('🔑 [API Client] Adding Authorization header for:', config.url);
} else if (!isPublicEndpoint) {
console.warn('⚠️ [API Client] No auth token available for:', config.url, 'authToken:', this.authToken ? 'exists' : 'missing');
}
// Add tenant ID only for endpoints that require it
@@ -343,7 +346,9 @@ class ApiClient {
// Configuration methods
setAuthToken(token: string | null) {
console.log('🔧 [API Client] setAuthToken called:', token ? `${token.substring(0, 20)}...` : 'null');
this.authToken = token;
console.log('✅ [API Client] authToken is now:', this.authToken ? 'set' : 'null');
}
setRefreshToken(token: string | null) {

View File

@@ -0,0 +1,89 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { enterpriseService, NetworkSummary, ChildPerformance, DistributionOverview, ForecastSummary, NetworkPerformance } from '../services/enterprise';
import { ApiError } from '../client';
// Query Keys
export const enterpriseKeys = {
all: ['enterprise'] as const,
networkSummary: (tenantId: string) => [...enterpriseKeys.all, 'network-summary', tenantId] as const,
childrenPerformance: (tenantId: string, metric: string, period: number) =>
[...enterpriseKeys.all, 'children-performance', tenantId, metric, period] as const,
distributionOverview: (tenantId: string, date?: string) =>
[...enterpriseKeys.all, 'distribution-overview', tenantId, date] as const,
forecastSummary: (tenantId: string, days: number) =>
[...enterpriseKeys.all, 'forecast-summary', tenantId, days] as const,
networkPerformance: (tenantId: string, startDate?: string, endDate?: string) =>
[...enterpriseKeys.all, 'network-performance', tenantId, startDate, endDate] as const,
} as const;
// Hooks
export const useNetworkSummary = (
tenantId: string,
options?: Omit<UseQueryOptions<NetworkSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<NetworkSummary, ApiError>({
queryKey: enterpriseKeys.networkSummary(tenantId),
queryFn: () => enterpriseService.getNetworkSummary(tenantId),
enabled: !!tenantId,
staleTime: 30000, // 30 seconds
...options,
});
};
export const useChildrenPerformance = (
tenantId: string,
metric: string,
period: number,
options?: Omit<UseQueryOptions<ChildPerformance, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ChildPerformance, ApiError>({
queryKey: enterpriseKeys.childrenPerformance(tenantId, metric, period),
queryFn: () => enterpriseService.getChildrenPerformance(tenantId, metric, period),
enabled: !!tenantId,
staleTime: 60000, // 1 minute
...options,
});
};
export const useDistributionOverview = (
tenantId: string,
targetDate?: string,
options?: Omit<UseQueryOptions<DistributionOverview, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<DistributionOverview, ApiError>({
queryKey: enterpriseKeys.distributionOverview(tenantId, targetDate),
queryFn: () => enterpriseService.getDistributionOverview(tenantId, targetDate),
enabled: !!tenantId,
staleTime: 30000, // 30 seconds
...options,
});
};
export const useForecastSummary = (
tenantId: string,
daysAhead: number = 7,
options?: Omit<UseQueryOptions<ForecastSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ForecastSummary, ApiError>({
queryKey: enterpriseKeys.forecastSummary(tenantId, daysAhead),
queryFn: () => enterpriseService.getForecastSummary(tenantId, daysAhead),
enabled: !!tenantId,
staleTime: 120000, // 2 minutes
...options,
});
};
export const useNetworkPerformance = (
tenantId: string,
startDate?: string,
endDate?: string,
options?: Omit<UseQueryOptions<NetworkPerformance, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<NetworkPerformance, ApiError>({
queryKey: enterpriseKeys.networkPerformance(tenantId, startDate, endDate),
queryFn: () => enterpriseService.getNetworkPerformance(tenantId, startDate, endDate),
enabled: !!tenantId,
...options,
});
};

View File

@@ -2,7 +2,7 @@
* Subscription hook for checking plan features and limits
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { subscriptionService } from '../services/subscription';
import {
SUBSCRIPTION_TIERS,
@@ -41,7 +41,7 @@ export const useSubscription = () => {
loading: true,
});
const currentTenant = useCurrentTenant();
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id;
const { notifySubscriptionChanged, subscriptionVersion } = useSubscriptionEvents();
@@ -72,7 +72,7 @@ export const useSubscription = () => {
error: 'Failed to load subscription data'
}));
}
}, [tenantId]); // Removed notifySubscriptionChanged - it's now stable from context
}, [tenantId]);
useEffect(() => {
loadSubscriptionData();
@@ -99,7 +99,7 @@ export const useSubscription = () => {
// Check analytics access level
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
const { plan } = subscriptionInfo;
const plan = subscriptionInfo.plan;
// Convert plan string to typed SubscriptionTier
let tierKey: SubscriptionTier | undefined;

View File

@@ -0,0 +1,104 @@
import { apiClient } from '../client';
export interface NetworkSummary {
parent_tenant_id: string;
total_tenants: number;
child_tenant_count: number;
total_revenue: number;
network_sales_30d: number;
active_alerts: number;
efficiency_score: number;
growth_rate: number;
production_volume_30d: number;
pending_internal_transfers_count: number;
active_shipments_count: number;
last_updated: string;
}
export interface ChildPerformance {
rankings: Array<{
tenant_id: string;
name: string;
anonymized_name: string;
metric_value: number;
rank: number;
}>;
}
export interface DistributionOverview {
route_sequences: any[];
status_counts: {
pending: number;
in_transit: number;
delivered: number;
failed: number;
[key: string]: number;
};
}
export interface ForecastSummary {
aggregated_forecasts: Record<string, any>;
days_forecast: number;
last_updated: string;
}
export interface NetworkPerformance {
metrics: Record<string, any>;
}
export class EnterpriseService {
private readonly baseUrl = '/tenants';
async getNetworkSummary(tenantId: string): Promise<NetworkSummary> {
return apiClient.get<NetworkSummary>(`${this.baseUrl}/${tenantId}/enterprise/network-summary`);
}
async getChildrenPerformance(
tenantId: string,
metric: string = 'sales',
periodDays: number = 30
): Promise<ChildPerformance> {
const queryParams = new URLSearchParams({
metric,
period_days: periodDays.toString()
});
return apiClient.get<ChildPerformance>(
`${this.baseUrl}/${tenantId}/enterprise/children-performance?${queryParams.toString()}`
);
}
async getDistributionOverview(tenantId: string, targetDate?: string): Promise<DistributionOverview> {
const queryParams = new URLSearchParams();
if (targetDate) {
queryParams.append('target_date', targetDate);
}
return apiClient.get<DistributionOverview>(
`${this.baseUrl}/${tenantId}/enterprise/distribution-overview?${queryParams.toString()}`
);
}
async getForecastSummary(tenantId: string, daysAhead: number = 7): Promise<ForecastSummary> {
const queryParams = new URLSearchParams({
days_ahead: daysAhead.toString()
});
return apiClient.get<ForecastSummary>(
`${this.baseUrl}/${tenantId}/enterprise/forecast-summary?${queryParams.toString()}`
);
}
async getNetworkPerformance(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<NetworkPerformance> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
return apiClient.get<NetworkPerformance>(
`${this.baseUrl}/${tenantId}/enterprise/network-performance?${queryParams.toString()}`
);
}
}
export const enterpriseService = new EnterpriseService();

View File

@@ -23,10 +23,11 @@ import {
} from '../types/subscription';
// Map plan tiers to analytics levels based on backend data
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier, AnalyticsLevel> = {
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier | string, AnalyticsLevel> = {
[SUBSCRIPTION_TIERS.STARTER]: ANALYTICS_LEVELS.BASIC,
[SUBSCRIPTION_TIERS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE,
'demo': ANALYTICS_LEVELS.ADVANCED, // Treat demo tier same as professional for analytics access
};
// Cache for available plans

View File

@@ -0,0 +1,148 @@
/*
* Performance Chart Component for Enterprise Dashboard
* Shows anonymized performance ranking of child outlets
*/
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { BarChart3, TrendingUp, TrendingDown, ArrowUp, ArrowDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface PerformanceDataPoint {
rank: number;
tenant_id: string;
anonymized_name: string; // "Outlet 1", "Outlet 2", etc.
metric_value: number;
original_name?: string; // Only for internal use, not displayed
}
interface PerformanceChartProps {
data: PerformanceDataPoint[];
metric: string;
period: number;
}
const PerformanceChart: React.FC<PerformanceChartProps> = ({
data = [],
metric,
period
}) => {
const { t } = useTranslation('dashboard');
// Get metric info
const getMetricInfo = () => {
switch (metric) {
case 'sales':
return {
icon: <TrendingUp className="w-4 h-4" />,
label: t('enterprise.metrics.sales'),
unit: '€',
format: (val: number) => val.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
};
case 'inventory_value':
return {
icon: <Package className="w-4 h-4" />,
label: t('enterprise.metrics.inventory_value'),
unit: '€',
format: (val: number) => val.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
};
case 'order_frequency':
return {
icon: <ShoppingCart className="w-4 h-4" />,
label: t('enterprise.metrics.order_frequency'),
unit: '',
format: (val: number) => Math.round(val).toString()
};
default:
return {
icon: <BarChart3 className="w-4 h-4" />,
label: metric,
unit: '',
format: (val: number) => val.toString()
};
}
};
const metricInfo = getMetricInfo();
// Calculate max value for bar scaling
const maxValue = data.length > 0 ? Math.max(...data.map(item => item.metric_value), 1) : 1;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-blue-600" />
<CardTitle>{t('enterprise.outlet_performance')}</CardTitle>
</div>
<div className="text-sm text-gray-500">
{t('enterprise.performance_based_on_period', {
metric: t(`enterprise.metrics.${metric}`) || metric,
period
})}
</div>
</CardHeader>
<CardContent>
{data.length > 0 ? (
<div className="space-y-4">
{data.map((item, index) => {
const percentage = (item.metric_value / maxValue) * 100;
const isTopPerformer = index === 0;
return (
<div key={item.tenant_id} className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
isTopPerformer
? 'bg-yellow-100 text-yellow-800 border border-yellow-300'
: 'bg-gray-100 text-gray-700'
}`}>
{item.rank}
</div>
<span className="font-medium">{item.anonymized_name}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">
{metricInfo.unit}{metricInfo.format(item.metric_value)}
</span>
{isTopPerformer && (
<Badge variant="secondary" className="bg-yellow-50 text-yellow-800">
{t('enterprise.top_performer')}
</Badge>
)}
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-500 ${
isTopPerformer
? 'bg-gradient-to-r from-blue-500 to-purple-500'
: 'bg-blue-400'
}`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<BarChart3 className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p>{t('enterprise.no_performance_data')}</p>
<p className="text-sm text-gray-400 mt-1">
{t('enterprise.performance_based_on_period', {
metric: t(`enterprise.metrics.${metric}`) || metric,
period
})}
</p>
</div>
)}
</CardContent>
</Card>
);
};
export default PerformanceChart;

View File

@@ -0,0 +1,158 @@
/*
* Delivery Routes Map Component
* Visualizes delivery routes and shipment status
*/
import React from 'react';
import { Card, CardContent } from '../ui/Card';
import { useTranslation } from 'react-i18next';
interface Route {
route_id: string;
route_number: string;
status: string;
total_distance_km: number;
stops: any[]; // Simplified for now
estimated_duration_minutes: number;
}
interface DeliveryRoutesMapProps {
routes?: Route[];
shipments?: Record<string, number>;
}
export const DeliveryRoutesMap: React.FC<DeliveryRoutesMapProps> = ({ routes, shipments }) => {
const { t } = useTranslation('dashboard');
// Calculate summary stats for display
const totalRoutes = routes?.length || 0;
const totalDistance = routes?.reduce((sum, route) => sum + (route.total_distance_km || 0), 0) || 0;
// Calculate shipment status counts
const pendingShipments = shipments?.pending || 0;
const inTransitShipments = shipments?.in_transit || 0;
const deliveredShipments = shipments?.delivered || 0;
const totalShipments = pendingShipments + inTransitShipments + deliveredShipments;
return (
<div className="space-y-4">
{/* Route Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-600">{t('enterprise.total_routes')}</p>
<p className="text-xl font-bold text-blue-900">{totalRoutes}</p>
</div>
<div className="bg-green-50 p-3 rounded-lg">
<p className="text-sm text-green-600">{t('enterprise.total_distance')}</p>
<p className="text-xl font-bold text-green-900">{totalDistance.toFixed(1)} km</p>
</div>
<div className="bg-yellow-50 p-3 rounded-lg">
<p className="text-sm text-yellow-600">{t('enterprise.total_shipments')}</p>
<p className="text-xl font-bold text-yellow-900">{totalShipments}</p>
</div>
<div className="bg-purple-50 p-3 rounded-lg">
<p className="text-sm text-purple-600">{t('enterprise.active_routes')}</p>
<p className="text-xl font-bold text-purple-900">
{routes?.filter(r => r.status === 'in_progress').length || 0}
</p>
</div>
</div>
{/* Route Status Legend */}
<div className="flex flex-wrap gap-3 mb-4">
<div className="flex items-center">
<div className="w-3 h-3 bg-gray-300 rounded-full mr-2"></div>
<span className="text-xs">{t('enterprise.planned')}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-blue-500 rounded-full mr-2"></div>
<span className="text-xs">{t('enterprise.pending')}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-yellow-500 rounded-full mr-2"></div>
<span className="text-xs">{t('enterprise.in_transit')}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span className="text-xs">{t('enterprise.delivered')}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
<span className="text-xs">{t('enterprise.failed')}</span>
</div>
</div>
{/* Simplified Map Visualization */}
<div className="border rounded-lg p-4 bg-gray-50 min-h-[400px] relative">
<h3 className="text-lg font-semibold mb-4">{t('enterprise.distribution_routes')}</h3>
{routes && routes.length > 0 ? (
<div className="space-y-3">
{/* For each route, show a simplified representation */}
{routes.map((route, index) => {
let statusColor = 'bg-gray-300'; // planned
if (route.status === 'in_progress') statusColor = 'bg-yellow-500';
else if (route.status === 'completed') statusColor = 'bg-green-500';
else if (route.status === 'cancelled') statusColor = 'bg-red-500';
return (
<div key={route.route_id} className="border rounded p-3 bg-white">
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium">{t('enterprise.route')} {route.route_number}</h4>
<span className={`px-2 py-1 rounded-full text-xs ${statusColor} text-white`}>
{t(`enterprise.route_status.${route.status}`) || route.status}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-gray-500">{t('enterprise.distance')}:</span>
<span className="ml-1">{route.total_distance_km?.toFixed(1)} km</span>
</div>
<div>
<span className="text-gray-500">{t('enterprise.duration')}:</span>
<span className="ml-1">{Math.round(route.estimated_duration_minutes || 0)} min</span>
</div>
<div>
<span className="text-gray-500">{t('enterprise.stops')}:</span>
<span className="ml-1">{route.stops?.length || 0}</span>
</div>
</div>
{/* Route stops visualization */}
<div className="mt-2 pt-2 border-t">
<div className="flex items-center space-x-2">
{route.stops && route.stops.length > 0 ? (
route.stops.map((stop, stopIndex) => (
<React.Fragment key={stopIndex}>
<div className="flex flex-col items-center">
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs">
{stopIndex + 1}
</div>
<span className="text-xs mt-1 text-center max-w-[60px] truncate">
{stop.location?.name || `${t('enterprise.stop')} ${stopIndex + 1}`}
</span>
</div>
{stopIndex < route.stops.length - 1 && (
<div className="flex-1 h-0.5 bg-gray-300"></div>
)}
</React.Fragment>
))
) : (
<span className="text-gray-500 text-sm">{t('enterprise.no_stops')}</span>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center justify-center h-64 text-gray-500">
{t('enterprise.no_routes_available')}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,160 @@
/*
* Network Summary Cards Component for Enterprise Dashboard
*/
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
import { Badge } from '../../components/ui/Badge';
import {
Store as StoreIcon,
DollarSign,
Package,
ShoppingCart,
Truck,
Users
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatCurrency } from '../../utils/format';
interface NetworkSummaryData {
parent_tenant_id: string;
child_tenant_count: number;
network_sales_30d: number;
production_volume_30d: number;
pending_internal_transfers_count: number;
active_shipments_count: number;
last_updated: string;
}
interface NetworkSummaryCardsProps {
data?: NetworkSummaryData;
isLoading: boolean;
}
const NetworkSummaryCards: React.FC<NetworkSummaryCardsProps> = ({
data,
isLoading
}) => {
const { t } = useTranslation('dashboard');
if (isLoading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
{[...Array(5)].map((_, index) => (
<Card key={index} className="animate-pulse">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-500 h-4 bg-gray-200 rounded w-3/4"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
</CardContent>
</Card>
))}
</div>
);
}
if (!data) {
return (
<div className="text-center py-8 text-gray-500">
{t('enterprise.no_network_data')}
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
{/* Network Outlets Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-500">
{t('enterprise.network_outlets')}
</CardTitle>
<StoreIcon className="w-4 h-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">
{data.child_tenant_count}
</div>
<p className="text-xs text-gray-500 mt-1">
{t('enterprise.outlets_in_network')}
</p>
</CardContent>
</Card>
{/* Network Sales Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-500">
{t('enterprise.network_sales')}
</CardTitle>
<DollarSign className="w-4 h-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">
{formatCurrency(data.network_sales_30d, 'EUR')}
</div>
<p className="text-xs text-gray-500 mt-1">
{t('enterprise.last_30_days')}
</p>
</CardContent>
</Card>
{/* Production Volume Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-500">
{t('enterprise.production_volume')}
</CardTitle>
<Package className="w-4 h-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">
{new Intl.NumberFormat('es-ES').format(data.production_volume_30d)} kg
</div>
<p className="text-xs text-gray-500 mt-1">
{t('enterprise.last_30_days')}
</p>
</CardContent>
</Card>
{/* Pending Internal Transfers Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-500">
{t('enterprise.pending_orders')}
</CardTitle>
<ShoppingCart className="w-4 h-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">
{data.pending_internal_transfers_count}
</div>
<p className="text-xs text-gray-500 mt-1">
{t('enterprise.internal_transfers')}
</p>
</CardContent>
</Card>
{/* Active Shipments Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-500">
{t('enterprise.active_shipments')}
</CardTitle>
<Truck className="w-4 h-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">
{data.active_shipments_count}
</div>
<p className="text-xs text-gray-500 mt-1">
{t('enterprise.today')}
</p>
</CardContent>
</Card>
</div>
);
};
export default NetworkSummaryCards;

View File

@@ -0,0 +1,155 @@
/*
* Performance Chart Component
* Shows anonymized ranking of outlets based on selected metric
*/
import React from 'react';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Card, CardContent } from '../ui/Card';
import { useTranslation } from 'react-i18next';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
interface PerformanceData {
rank: number;
tenant_id: string;
anonymized_name: string;
metric_value: number;
}
interface PerformanceChartProps {
data?: PerformanceData[];
metric: string;
period: number;
}
export const PerformanceChart: React.FC<PerformanceChartProps> = ({ data, metric, period }) => {
const { t } = useTranslation('dashboard');
// Prepare chart data
const chartData = {
labels: data?.map(item => item.anonymized_name) || [],
datasets: [
{
label: t(`enterprise.metric_labels.${metric}`) || metric,
data: data?.map(item => item.metric_value) || [],
backgroundColor: 'rgba(75, 192, 192, 0.6)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1,
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
},
title: {
display: true,
text: t('enterprise.outlet_performance_chart_title'),
},
tooltip: {
callbacks: {
label: function(context: any) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
if (metric === 'sales') {
label += `${context.parsed.y.toFixed(2)}`;
} else {
label += context.parsed.y;
}
}
return label;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: t('enterprise.outlet'),
},
},
y: {
title: {
display: true,
text: t(`enterprise.metric_labels.${metric}`) || metric,
},
beginAtZero: true,
},
},
};
return (
<div className="space-y-4">
<div className="text-sm text-gray-600">
{t('enterprise.performance_based_on', {
metric: t(`enterprise.metrics.${metric}`) || metric,
period
})}
</div>
{data && data.length > 0 ? (
<div className="h-80">
<Bar data={chartData} options={options} />
</div>
) : (
<div className="h-80 flex items-center justify-center text-gray-500">
{t('enterprise.no_performance_data')}
</div>
)}
{/* Performance ranking table */}
<div className="mt-4">
<h4 className="font-medium mb-2">{t('enterprise.ranking')}</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left">{t('enterprise.rank')}</th>
<th className="px-3 py-2 text-left">{t('enterprise.outlet')}</th>
<th className="px-3 py-2 text-right">
{t(`enterprise.metric_labels.${metric}`) || metric}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data?.map((item, index) => (
<tr key={item.tenant_id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-3 py-2">{item.rank}</td>
<td className="px-3 py-2 font-medium">{item.anonymized_name}</td>
<td className="px-3 py-2 text-right">
{metric === 'sales' ? `${item.metric_value.toFixed(2)}` : item.metric_value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@@ -255,6 +255,23 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const allUserRoles = [...globalUserRoles, ...tenantRoles];
const tenantPermissions = currentTenantAccess?.permissions || [];
// Debug logging for analytics route
if (item.path === '/app/analytics') {
console.log('🔍 [Sidebar] Checking analytics menu item:', {
path: item.path,
requiredRoles: item.requiredRoles,
requiredPermissions: item.requiredPermissions,
globalUserRoles,
tenantRoles,
allUserRoles,
tenantPermissions,
isAuthenticated,
hasAccess,
user,
currentTenantAccess
});
}
// If no specific permissions/roles required, allow access
if (!item.requiredPermissions && !item.requiredRoles) {
return true;
@@ -272,6 +289,10 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
tenantPermissions
);
if (item.path === '/app/analytics') {
console.log('🔍 [Sidebar] Analytics canAccessRoute result:', canAccessItem);
}
return canAccessItem;
});
};

View File

@@ -0,0 +1,336 @@
/*
* Distribution Map Component for Enterprise Dashboard
* Shows delivery routes and shipment status across the network
*/
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import {
MapPin,
Truck,
CheckCircle,
Clock,
AlertTriangle,
Package,
Eye,
Info,
Route,
Navigation,
Map as MapIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface RoutePoint {
tenant_id: string;
name: string;
address: string;
latitude: number;
longitude: number;
status: 'pending' | 'in_transit' | 'delivered' | 'failed';
estimated_arrival?: string;
actual_arrival?: string;
sequence: number;
}
interface RouteData {
id: string;
route_number: string;
total_distance_km: number;
estimated_duration_minutes: number;
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
route_points: RoutePoint[];
}
interface ShipmentStatusData {
pending: number;
in_transit: number;
delivered: number;
failed: number;
}
interface DistributionMapProps {
routes?: RouteData[];
shipments?: ShipmentStatusData;
}
const DistributionMap: React.FC<DistributionMapProps> = ({
routes = [],
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
}) => {
const { t } = useTranslation('dashboard');
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
const [showAllRoutes, setShowAllRoutes] = useState(true);
const renderMapVisualization = () => {
if (!routes || routes.length === 0) {
return (
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium text-gray-900">{t('enterprise.no_active_routes')}</p>
<p className="text-gray-500">{t('enterprise.no_shipments_today')}</p>
</div>
</div>
);
}
// Find active routes (in_progress or planned for today)
const activeRoutes = routes.filter(route =>
route.status === 'in_progress' || route.status === 'planned'
);
if (activeRoutes.length === 0) {
return (
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-400" />
<p className="text-lg font-medium text-gray-900">{t('enterprise.all_routes_completed')}</p>
<p className="text-gray-500">{t('enterprise.no_active_deliveries')}</p>
</div>
</div>
);
}
// This would normally render an interactive map, but we'll create a visual representation
return (
<div className="h-96 bg-gradient-to-b from-blue-50 to-indigo-50 rounded-lg border border-gray-200 relative">
{/* Map visualization placeholder with route indicators */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<MapIcon className="w-16 h-16 mx-auto text-blue-400 mb-2" />
<div className="text-lg font-medium text-gray-700">{t('enterprise.distribution_map')}</div>
<div className="text-sm text-gray-500 mt-1">
{activeRoutes.length} {t('enterprise.active_routes')}
</div>
</div>
</div>
{/* Route visualization elements */}
{activeRoutes.map((route, index) => (
<div key={route.id} className="absolute top-4 left-4 bg-white p-3 rounded-lg shadow-md text-sm max-w-xs">
<div className="font-medium text-gray-900 flex items-center gap-2">
<Route className="w-4 h-4 text-blue-600" />
{t('enterprise.route')} {route.route_number}
</div>
<div className="text-gray-600 mt-1">{route.status.replace('_', ' ')}</div>
<div className="text-gray-500">{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h</div>
</div>
))}
{/* Shipment status indicators */}
<div className="absolute bottom-4 right-4 bg-white p-3 rounded-lg shadow-md space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<span className="text-sm">{t('enterprise.pending')}: {shipments.pending}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-400"></div>
<span className="text-sm">{t('enterprise.in_transit')}: {shipments.in_transit}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-400"></div>
<span className="text-sm">{t('enterprise.delivered')}: {shipments.delivered}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<span className="text-sm">{t('enterprise.failed')}: {shipments.failed}</span>
</div>
</div>
</div>
);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'delivered':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'in_transit':
return <Truck className="w-4 h-4 text-blue-500" />;
case 'pending':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'failed':
return <AlertTriangle className="w-4 h-4 text-red-500" />;
default:
return <Clock className="w-4 h-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'delivered':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_transit':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'failed':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
return (
<div className="space-y-4">
{/* Shipment Status Summary */}
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
<span className="text-sm font-medium text-yellow-800">{t('enterprise.pending')}</span>
</div>
<p className="text-2xl font-bold text-yellow-900">{shipments?.pending || 0}</p>
</div>
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
<div className="flex items-center gap-2">
<Truck className="w-5 h-5 text-blue-600" />
<span className="text-sm font-medium text-blue-800">{t('enterprise.in_transit')}</span>
</div>
<p className="text-2xl font-bold text-blue-900">{shipments?.in_transit || 0}</p>
</div>
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm font-medium text-green-800">{t('enterprise.delivered')}</span>
</div>
<p className="text-2xl font-bold text-green-900">{shipments?.delivered || 0}</p>
</div>
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm font-medium text-red-800">{t('enterprise.failed')}</span>
</div>
<p className="text-2xl font-bold text-red-900">{shipments?.failed || 0}</p>
</div>
</div>
{/* Map Visualization */}
{renderMapVisualization()}
{/* Route Details Panel */}
<div className="mt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-gray-700">{t('enterprise.active_routes')}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllRoutes(!showAllRoutes)}
>
{showAllRoutes ? t('enterprise.hide_routes') : t('enterprise.show_routes')}
</Button>
</div>
{showAllRoutes && routes.length > 0 ? (
<div className="space-y-3">
{routes
.filter(route => route.status === 'in_progress' || route.status === 'planned')
.map(route => (
<Card key={route.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-sm">
{t('enterprise.route')} {route.route_number}
</CardTitle>
<p className="text-xs text-gray-500 mt-1">
{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h
</p>
</div>
<Badge className={getStatusColor(route.status)}>
{getStatusIcon(route.status)}
<span className="ml-1 capitalize">
{t(`enterprise.route_status.${route.status}`) || route.status}
</span>
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{route.route_points.map((point, index) => (
<div key={index} className="flex items-center gap-3 text-sm">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
point.status === 'delivered' ? 'bg-green-500 text-white' :
point.status === 'in_transit' ? 'bg-blue-500 text-white' :
point.status === 'failed' ? 'bg-red-500 text-white' :
'bg-yellow-500 text-white'
}`}>
{point.sequence}
</div>
<span className="flex-1 truncate">{point.name}</span>
<Badge variant="outline" className={getStatusColor(point.status)}>
{getStatusIcon(point.status)}
<span className="ml-1 text-xs">
{t(`enterprise.stop_status.${point.status}`) || point.status}
</span>
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
{routes.length === 0 ?
t('enterprise.no_routes_planned') :
t('enterprise.no_active_routes')}
</p>
)}
</div>
{/* Selected Route Detail Panel (would be modal in real implementation) */}
{selectedRoute && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{t('enterprise.route_details')}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedRoute(null)}
>
×
</Button>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span>{t('enterprise.route_number')}</span>
<span className="font-medium">{selectedRoute.route_number}</span>
</div>
<div className="flex justify-between">
<span>{t('enterprise.total_distance')}</span>
<span>{selectedRoute.total_distance_km.toFixed(1)} km</span>
</div>
<div className="flex justify-between">
<span>{t('enterprise.estimated_duration')}</span>
<span>{Math.ceil(selectedRoute.estimated_duration_minutes / 60)}h {selectedRoute.estimated_duration_minutes % 60}m</span>
</div>
<div className="flex justify-between">
<span>{t('enterprise.status')}</span>
<Badge className={getStatusColor(selectedRoute.status)}>
{getStatusIcon(selectedRoute.status)}
<span className="ml-1 capitalize">
{t(`enterprise.route_status.${selectedRoute.status}`) || selectedRoute.status}
</span>
</Badge>
</div>
</div>
<Button
className="w-full mt-4"
onClick={() => setSelectedRoute(null)}
>
{t('common.close')}
</Button>
</div>
</div>
)}
</div>
);
};
export default DistributionMap;

View File

@@ -329,5 +329,73 @@
"celebration": "Great news! AI prevented {count} issue(s) before they became problems.",
"ai_insight": "AI Insight:",
"orchestration_title": "Latest Orchestration Run"
},
"enterprise": {
"network_dashboard": "Enterprise Network Dashboard",
"network_summary_description": "Overview of your bakery network performance",
"loading": "Loading network data...",
"network_summary": "Network Summary",
"outlets_count": "Network Outlets",
"network_outlets": "outlets in network",
"network_sales": "Network Sales",
"last_30_days": "last 30 days",
"production_volume": "Production Volume",
"pending_orders": "Pending Orders",
"internal_transfers": "internal transfers",
"active_shipments": "Active Shipments",
"today": "today",
"distribution_map": "Distribution Routes",
"outlet_performance": "Outlet Performance",
"sales": "Sales",
"inventory_value": "Inventory Value",
"order_frequency": "Order Frequency",
"last_7_days": "Last 7 days",
"last_30_days": "Last 30 days",
"last_90_days": "Last 90 days",
"network_forecast": "Network Forecast",
"total_demand": "Total Demand",
"days_forecast": "Days Forecast",
"avg_daily_demand": "Avg Daily Demand",
"last_updated": "Last Updated",
"no_forecast_data": "No forecast data available",
"no_performance_data": "No performance data available",
"no_distribution_data": "No distribution data available",
"performance_based_on": "Performance based on {{metric}} over {{period}} days",
"ranking": "Ranking",
"rank": "Rank",
"outlet": "Outlet",
"metric_labels": {
"sales": "Sales (€)",
"inventory_value": "Inventory Value (€)",
"order_frequency": "Order Frequency"
},
"metrics": {
"sales": "sales",
"inventory_value": "inventory value",
"order_frequency": "order frequency"
},
"route": "Route",
"total_routes": "Total Routes",
"total_distance": "Total Distance",
"total_shipments": "Total Shipments",
"active_routes": "Active Routes",
"distance": "Distance",
"duration": "Duration",
"stops": "Stops",
"no_stops": "No stops",
"stop": "Stop",
"no_routes_available": "No routes available",
"route_status": {
"planned": "Planned",
"in_progress": "In Progress",
"completed": "Completed",
"cancelled": "Cancelled"
},
"planned": "Planned",
"pending": "Pending",
"in_transit": "In Transit",
"delivered": "Delivered",
"failed": "Failed",
"distribution_routes": "Distribution Routes"
}
}

View File

@@ -378,5 +378,73 @@
"celebration": "¡Buenas noticias! La IA evitó {count} incidencia(s) antes de que se convirtieran en problemas.",
"ai_insight": "Análisis de IA:",
"orchestration_title": "Última Ejecución de Orquestación"
},
"enterprise": {
"network_dashboard": "Panel de Red Empresarial",
"network_summary_description": "Resumen del rendimiento de tu red de panaderías",
"loading": "Cargando datos de red...",
"network_summary": "Resumen de Red",
"outlets_count": "Tiendas en Red",
"network_outlets": "tiendas en red",
"network_sales": "Ventas de Red",
"last_30_days": "últimos 30 días",
"production_volume": "Volumen de Producción",
"pending_orders": "Órdenes Pendientes",
"internal_transfers": "transferencias internas",
"active_shipments": "Envíos Activos",
"today": "hoy",
"distribution_map": "Rutas de Distribución",
"outlet_performance": "Rendimiento de Tiendas",
"sales": "Ventas",
"inventory_value": "Valor de Inventario",
"order_frequency": "Frecuencia de Pedidos",
"last_7_days": "Últimos 7 días",
"last_30_days": "Últimos 30 días",
"last_90_days": "Últimos 90 días",
"network_forecast": "Pronóstico de Red",
"total_demand": "Demanda Total",
"days_forecast": "Días de Pronóstico",
"avg_daily_demand": "Demanda Diaria Promedio",
"last_updated": "Última Actualización",
"no_forecast_data": "No hay datos de pronóstico disponibles",
"no_performance_data": "No hay datos de rendimiento disponibles",
"no_distribution_data": "No hay datos de distribución disponibles",
"performance_based_on": "Rendimiento basado en {{metric}} durante {{period}} días",
"ranking": "Clasificación",
"rank": "Posición",
"outlet": "Tienda",
"metric_labels": {
"sales": "Ventas (€)",
"inventory_value": "Valor de Inventario (€)",
"order_frequency": "Frecuencia de Pedidos"
},
"metrics": {
"sales": "ventas",
"inventory_value": "valor de inventario",
"order_frequency": "frecuencia de pedidos"
},
"route": "Ruta",
"total_routes": "Rutas Totales",
"total_distance": "Distancia Total",
"total_shipments": "Envíos Totales",
"active_routes": "Rutas Activas",
"distance": "Distancia",
"duration": "Duración",
"stops": "Paradas",
"no_stops": "Sin paradas",
"stop": "Parada",
"no_routes_available": "No hay rutas disponibles",
"route_status": {
"planned": "Planificada",
"in_progress": "En Progreso",
"completed": "Completada",
"cancelled": "Cancelada"
},
"planned": "Planificada",
"pending": "Pendiente",
"in_transit": "En Tránsito",
"delivered": "Entregada",
"failed": "Fallida",
"distribution_routes": "Rutas de Distribución"
}
}

View File

@@ -327,5 +327,73 @@
"celebration": "Albiste onak! IAk {count} arazo saihestatu ditu arazo bihurtu aurretik.",
"ai_insight": "IAren Analisia:",
"orchestration_title": "Azken Orkestraketa-Exekuzioa"
},
"enterprise": {
"network_dashboard": "Enpresa-sarearen Aginte-panela",
"network_summary_description": "Zure okindegi-sarearen errendimenduaren laburpena",
"loading": "Sare-datuak kargatzen...",
"network_summary": "Sarearen Laburpena",
"outlets_count": "Sarea Dendak",
"network_outlets": "sarea dendak",
"network_sales": "Sarea Salmentak",
"last_30_days": "azken 30 egunetan",
"production_volume": "Ekoizpen Bolumena",
"pending_orders": "Aginduak Zain",
"internal_transfers": "transferentzia barneak",
"active_shipments": "Bidalketa Aktiboak",
"today": "gaur",
"distribution_map": "Banaketa Ibilbideak",
"outlet_performance": "Denda Errendimendua",
"sales": "Salmentak",
"inventory_value": "Inbentario Balorea",
"order_frequency": "Agindu Maiztasuna",
"last_7_days": "Azken 7 egun",
"last_30_days": "Azken 30 egun",
"last_90_days": "Azken 90 egun",
"network_forecast": "Sarea Iragarpena",
"total_demand": "Eskari Osoa",
"days_forecast": "Iragarpen Egunak",
"avg_daily_demand": "Eguneko Eskari Batezbestekoa",
"last_updated": "Azken Eguneraketa",
"no_forecast_data": "Ez dago iragarpen daturik erabilgarri",
"no_performance_data": "Ez dago errendimendu daturik erabilgarri",
"no_distribution_data": "Ez dago banaketa daturik erabilgarri",
"performance_based_on": "Errendimendua {{metric}}-n oinarrituta {{period}} egunetan",
"ranking": "Sailkapena",
"rank": "Postua",
"outlet": "Denda",
"metric_labels": {
"sales": "Salmentak (€)",
"inventory_value": "Inbentario Balorea (€)",
"order_frequency": "Agindu Maiztasuna"
},
"metrics": {
"sales": "salmentak",
"inventory_value": "inbentario balorea",
"order_frequency": "agindu maiztasuna"
},
"route": "Ibilbidea",
"total_routes": "Ibilbide Guztiak",
"total_distance": "Distantzia Guztira",
"total_shipments": "Bidalketa Guztiak",
"active_routes": "Ibilbide Aktiboak",
"distance": "Distantzia",
"duration": "Iraupena",
"stops": "Geralekuak",
"no_stops": "Geralekurik ez",
"stop": "Geldo",
"no_routes_available": "Ez dago ibilbirik erabilgarri",
"route_status": {
"planned": "Planifikatua",
"in_progress": "Abian",
"completed": "Osatua",
"cancelled": "Ezeztatua"
},
"planned": "Planifikatua",
"pending": "Zain",
"in_transit": "Bidaiatzen",
"delivered": "Entregatua",
"failed": "Huts egin du",
"distribution_routes": "Banaketa Ibilbideak"
}
}

View File

@@ -0,0 +1,372 @@
/*
* Enterprise Dashboard Page
* Main dashboard for enterprise parent tenants showing network-wide metrics
*/
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueries } from '@tanstack/react-query';
import {
useNetworkSummary,
useChildrenPerformance,
useDistributionOverview,
useForecastSummary
} from '../../api/hooks/enterprise';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
import { Badge } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import {
Users,
ShoppingCart,
TrendingUp,
MapPin,
Truck,
Package,
BarChart3,
Network,
Store,
Activity,
Calendar,
Clock,
CheckCircle,
AlertTriangle,
PackageCheck,
Building2,
DollarSign
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { ErrorBoundary } from 'react-error-boundary';
import { apiClient } from '../../api/client/apiClient';
// Components for enterprise dashboard
const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards'));
const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap'));
const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart'));
const EnterpriseDashboardPage = () => {
const { tenantId } = useParams();
const navigate = useNavigate();
const { t } = useTranslation('dashboard');
const [selectedMetric, setSelectedMetric] = useState('sales');
const [selectedPeriod, setSelectedPeriod] = useState(30);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
// Check if user has enterprise tier access
useEffect(() => {
const checkAccess = async () => {
try {
const response = await apiClient.get<{ tenant_type: string }>(`/tenants/${tenantId}`);
if (response.tenant_type !== 'parent') {
navigate('/unauthorized');
}
} catch (error) {
console.error('Access check failed:', error);
navigate('/unauthorized');
}
};
checkAccess();
}, [tenantId, navigate]);
// Fetch network summary data
const {
data: networkSummary,
isLoading: isNetworkSummaryLoading,
error: networkSummaryError
} = useNetworkSummary(tenantId!, {
refetchInterval: 60000, // Refetch every minute
});
// Fetch children performance data
const {
data: childrenPerformance,
isLoading: isChildrenPerformanceLoading,
error: childrenPerformanceError
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod);
// Fetch distribution overview data
const {
data: distributionOverview,
isLoading: isDistributionLoading,
error: distributionError
} = useDistributionOverview(tenantId!, selectedDate, {
refetchInterval: 60000, // Refetch every minute
});
// Fetch enterprise forecast summary
const {
data: forecastSummary,
isLoading: isForecastLoading,
error: forecastError
} = useForecastSummary(tenantId!);
// Error boundary fallback
const ErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => (
<div className="p-6 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Something went wrong</h3>
<p className="text-gray-500 mb-4">{error.message}</p>
<Button onClick={resetErrorBoundary}>Try again</Button>
</div>
);
if (isNetworkSummaryLoading || isChildrenPerformanceLoading || isDistributionLoading || isForecastLoading) {
return (
<div className="p-6 min-h-screen">
<div className="flex items-center justify-center h-96">
<LoadingSpinner text={t('enterprise.loading')} />
</div>
</div>
);
}
if (networkSummaryError || childrenPerformanceError || distributionError || forecastError) {
return (
<div className="p-6 min-h-screen">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-red-800 mb-2">Error Loading Dashboard</h3>
<p className="text-red-600">
{networkSummaryError?.message ||
childrenPerformanceError?.message ||
distributionError?.message ||
forecastError?.message}
</p>
</div>
</div>
);
}
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className="p-6 min-h-screen bg-gray-50">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Network className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">
{t('enterprise.network_dashboard')}
</h1>
</div>
<p className="text-gray-600">
{t('enterprise.network_summary_description')}
</p>
</div>
{/* Network Summary Cards */}
<div className="mb-8">
<NetworkSummaryCards
data={networkSummary}
isLoading={isNetworkSummaryLoading}
/>
</div>
{/* Distribution Map and Performance Chart Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Distribution Map */}
<div>
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Truck className="w-5 h-5 text-blue-600" />
<CardTitle>{t('enterprise.distribution_map')}</CardTitle>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-500" />
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="border rounded px-2 py-1 text-sm"
/>
</div>
</CardHeader>
<CardContent>
{distributionOverview ? (
<DistributionMap
routes={distributionOverview.route_sequences}
shipments={distributionOverview.status_counts}
/>
) : (
<div className="h-96 flex items-center justify-center text-gray-500">
{t('enterprise.no_distribution_data')}
</div>
)}
</CardContent>
</Card>
</div>
{/* Performance Chart */}
<div>
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-green-600" />
<CardTitle>{t('enterprise.outlet_performance')}</CardTitle>
</div>
<div className="flex gap-2">
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="sales">{t('enterprise.metrics.sales')}</option>
<option value="inventory_value">{t('enterprise.metrics.inventory_value')}</option>
<option value="order_frequency">{t('enterprise.metrics.order_frequency')}</option>
</select>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
className="border rounded px-2 py-1 text-sm"
>
<option value={7}>{t('enterprise.last_7_days')}</option>
<option value={30}>{t('enterprise.last_30_days')}</option>
<option value={90}>{t('enterprise.last_90_days')}</option>
</select>
</div>
</CardHeader>
<CardContent>
{childrenPerformance ? (
<PerformanceChart
data={childrenPerformance.rankings}
metric={selectedMetric}
period={selectedPeriod}
/>
) : (
<div className="h-96 flex items-center justify-center text-gray-500">
{t('enterprise.no_performance_data')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Forecast Summary */}
<div className="mb-8">
<Card>
<CardHeader className="flex flex-row items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
<CardTitle>{t('enterprise.network_forecast')}</CardTitle>
</CardHeader>
<CardContent>
{forecastSummary && forecastSummary.aggregated_forecasts ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-blue-800">{t('enterprise.total_demand')}</h3>
</div>
<p className="text-2xl font-bold text-blue-900">
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0
).toLocaleString()}
</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Calendar className="w-4 h-4 text-green-600" />
<h3 className="font-semibold text-green-800">{t('enterprise.days_forecast')}</h3>
</div>
<p className="text-2xl font-bold text-green-900">
{forecastSummary.days_forecast || 7}
</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-purple-800">{t('enterprise.avg_daily_demand')}</h3>
</div>
<p className="text-2xl font-bold text-purple-900">
{forecastSummary.aggregated_forecasts
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0) /
Object.keys(forecastSummary.aggregated_forecasts).length
).toLocaleString()
: 0}
</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-yellow-600" />
<h3 className="font-semibold text-yellow-800">{t('enterprise.last_updated')}</h3>
</div>
<p className="text-sm text-yellow-900">
{forecastSummary.last_updated ?
new Date(forecastSummary.last_updated).toLocaleTimeString() :
'N/A'}
</p>
</div>
</div>
) : (
<div className="flex items-center justify-center h-48 text-gray-500">
{t('enterprise.no_forecast_data')}
</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Building2 className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold">Agregar Punto de Venta</h3>
</div>
<p className="text-gray-600 mb-4">Añadir un nuevo outlet a la red enterprise</p>
<Button
onClick={() => navigate(`/app/tenants/${tenantId}/settings/organization`)}
className="w-full"
>
Crear Outlet
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<PackageCheck className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-semibold">Transferencias Internas</h3>
</div>
<p className="text-gray-600 mb-4">Gestionar pedidos entre obrador central y outlets</p>
<Button
onClick={() => navigate(`/app/tenants/${tenantId}/procurement/internal-transfers`)}
variant="outline"
className="w-full"
>
Ver Transferencias
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<MapPin className="w-6 h-6 text-red-600" />
<h3 className="text-lg font-semibold">Rutas de Distribución</h3>
</div>
<p className="text-gray-600 mb-4">Optimizar rutas de entrega entre ubicaciones</p>
<Button
onClick={() => navigate(`/app/tenants/${tenantId}/distribution/routes`)}
variant="outline"
className="w-full"
>
Ver Rutas
</Button>
</CardContent>
</Card>
</div>
</div>
</ErrorBoundary>
);
};
export default EnterpriseDashboardPage;

View File

@@ -151,13 +151,17 @@ const DemoPage = () => {
const getLoadingMessage = (tier, progress) => {
if (tier === 'enterprise') {
if (progress < 25) return 'Creando obrador central...';
if (progress < 50) return 'Configurando puntos de venta...';
if (progress < 75) return 'Generando rutas de distribución...';
if (progress < 15) return 'Preparando entorno enterprise...';
if (progress < 35) return 'Creando obrador central en Madrid...';
if (progress < 55) return 'Configurando outlets en Barcelona, Valencia y Bilbao...';
if (progress < 75) return 'Generando rutas de distribución optimizadas...';
if (progress < 90) return 'Configurando red de distribución...';
return 'Finalizando configuración enterprise...';
} else {
if (progress < 50) return 'Configurando tu panadería...';
return 'Cargando datos de demostración...';
if (progress < 30) return 'Preparando tu panadería...';
if (progress < 60) return 'Configurando inventario y recetas...';
if (progress < 85) return 'Generando datos de ventas y producción...';
return 'Finalizando configuración...';
}
};
@@ -380,8 +384,13 @@ const DemoPage = () => {
};
const updateProgressFromBackendStatus = (statusData, tier) => {
// Calculate progress based on the actual status from backend
if (statusData.progress) {
// IMPORTANT: Backend only provides progress AFTER cloning completes
// During cloning (status=PENDING), progress is empty {}
// So we rely on estimated progress for visual feedback
const hasRealProgress = statusData.progress && Object.keys(statusData.progress).length > 0;
if (hasRealProgress) {
if (tier === 'enterprise') {
// Handle enterprise progress structure which may be different
// Enterprise demos may have a different progress structure with parent, children, distribution
@@ -391,12 +400,29 @@ const DemoPage = () => {
handleIndividualProgress(statusData.progress);
}
} else {
// If no detailed progress available, use estimated progress or increment gradually
// No detailed progress available - backend is still cloning
// Use estimated progress for smooth visual feedback
// This is NORMAL during the cloning phase
setCloneProgress(prev => {
const newProgress = Math.max(
estimatedProgress,
Math.min(prev.overall + 2, 95) // Increment by 2% instead of 1%
prev.overall // Never go backward
);
// For enterprise, also update sub-components based on estimated progress
if (tier === 'enterprise') {
return {
parent: Math.min(95, Math.round(estimatedProgress * 0.4)), // 40% weight
children: [
Math.min(95, Math.round(estimatedProgress * 0.35)),
Math.min(95, Math.round(estimatedProgress * 0.35)),
Math.min(95, Math.round(estimatedProgress * 0.35))
],
distribution: Math.min(95, Math.round(estimatedProgress * 0.25)), // 25% weight
overall: newProgress
};
}
return {
...prev,
overall: newProgress

View File

@@ -43,6 +43,8 @@ const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insigh
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
const EventRegistryPage = React.lazy(() => import('../pages/app/analytics/events/EventRegistryPage'));
// Enterprise Dashboard Page
const EnterpriseDashboardPage = React.lazy(() => import('../pages/app/EnterpriseDashboardPage'));
// Settings pages - Unified
const BakerySettingsPage = React.lazy(() => import('../pages/app/settings/bakery/BakerySettingsPage'));
@@ -340,6 +342,17 @@ export const AppRouter: React.FC = () => {
}
/>
{/* Enterprise Dashboard Route - Only for enterprise tier */}
<Route
path="/app/tenants/:tenantId/enterprise"
element={
<ProtectedRoute>
<AppShell>
<EnterpriseDashboardPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Settings Routes */}
{/* NEW: Unified Profile Settings Route */}

View File

@@ -395,6 +395,17 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/tenants/:tenantId/enterprise',
name: 'EnterpriseDashboard',
component: 'EnterpriseDashboardPage',
title: 'Enterprise Dashboard',
icon: 'analytics',
requiresAuth: true,
requiredSubscriptionFeature: 'multi_location_dashboard',
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},

View File

@@ -43,7 +43,8 @@ export interface AuthState {
updateUser: (updates: Partial<User>) => void;
clearError: () => void;
setLoading: (loading: boolean) => void;
setDemoAuth: (token: string, demoUser: Partial<User>) => void;
// Permission helpers
hasPermission: (permission: string) => boolean;
hasRole: (role: string) => boolean;
@@ -234,6 +235,24 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: loading });
},
setDemoAuth: (token: string, demoUser: Partial<User>) => {
console.log('🔧 [Auth Store] setDemoAuth called - demo sessions use X-Demo-Session-Id header, not JWT');
// DO NOT set API client token for demo sessions!
// Demo authentication works via X-Demo-Session-Id header, not JWT
// The demo middleware handles authentication server-side
// Update store state so user is marked as authenticated
set({
token: null, // No JWT token for demo sessions
refreshToken: null,
user: demoUser as User,
isAuthenticated: true, // User is authenticated via demo session
isLoading: false,
error: null,
});
console.log('✅ [Auth Store] Demo auth state updated (no JWT token)');
},
// Permission helpers - Global user permissions only
hasPermission: (_permission: string): boolean => {
const { user } = get();

View File

@@ -50,6 +50,8 @@ export default defineConfig(({ mode }) => {
},
build: {
outDir: 'dist',
// For production builds: ensure assets have correct paths
// Base path should be '/' for root deployment
// In development mode: inline source maps for better debugging
// In production mode: external source maps
sourcemap: isDevelopment ? 'inline' : true,
@@ -66,6 +68,15 @@ export default defineConfig(({ mode }) => {
charts: ['recharts'],
forms: ['react-hook-form', 'zod'],
},
// Ensure assets are placed in assets directory with proper names
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'assets/[name].[hash].[ext]';
}
return 'assets/[name].[hash].[ext]';
},
chunkFileNames: 'assets/[name].[hash].js',
entryFileNames: 'assets/[name].[hash].js',
},
},
},