Improve the frontend modals
This commit is contained in:
@@ -8,11 +8,13 @@ import { useSalesAnalytics } from './sales';
|
||||
import { useOrdersDashboard } from './orders';
|
||||
import { inventoryService } from '../services/inventory';
|
||||
import { getAlertAnalytics } from '../services/alert_analytics';
|
||||
import { getSustainabilityWidgetData } from '../services/sustainability';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import type { InventoryDashboardSummary } from '../types/dashboard';
|
||||
import type { AlertAnalytics } from '../services/alert_analytics';
|
||||
import type { SalesAnalytics } from '../types/sales';
|
||||
import type { OrdersDashboardSummary } from '../types/orders';
|
||||
import type { SustainabilityWidgetData } from '../types/sustainability';
|
||||
|
||||
export interface DashboardStats {
|
||||
// Alert metrics
|
||||
@@ -39,6 +41,10 @@ export interface DashboardStats {
|
||||
productsSoldToday: number;
|
||||
productsSoldTrend: number;
|
||||
|
||||
// Sustainability metrics
|
||||
wasteReductionPercentage?: number;
|
||||
monthlySavingsEur?: number;
|
||||
|
||||
// Data freshness
|
||||
lastUpdated: string;
|
||||
}
|
||||
@@ -48,6 +54,7 @@ interface AggregatedDashboardData {
|
||||
orders?: OrdersDashboardSummary;
|
||||
sales?: SalesAnalytics;
|
||||
inventory?: InventoryDashboardSummary;
|
||||
sustainability?: SustainabilityWidgetData;
|
||||
}
|
||||
|
||||
// Query Keys
|
||||
@@ -175,6 +182,10 @@ function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats
|
||||
productsSoldToday: sales.productsSold,
|
||||
productsSoldTrend: sales.productsTrend,
|
||||
|
||||
// Sustainability
|
||||
wasteReductionPercentage: data.sustainability?.waste_reduction_percentage,
|
||||
monthlySavingsEur: data.sustainability?.financial_savings_eur,
|
||||
|
||||
// Metadata
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
@@ -199,7 +210,7 @@ export const useDashboardStats = (
|
||||
queryKey: dashboardKeys.stats(tenantId),
|
||||
queryFn: async () => {
|
||||
// Fetch all data in parallel
|
||||
const [alertsData, ordersData, salesData, inventoryData] = await Promise.allSettled([
|
||||
const [alertsData, ordersData, salesData, inventoryData, sustainabilityData] = await Promise.allSettled([
|
||||
getAlertAnalytics(tenantId, 7),
|
||||
// Note: OrdersService methods are static
|
||||
import('../services/orders').then(({ OrdersService }) =>
|
||||
@@ -210,6 +221,7 @@ export const useDashboardStats = (
|
||||
salesService.getSalesAnalytics(tenantId, todayStr, todayStr)
|
||||
),
|
||||
inventoryService.getDashboardSummary(tenantId),
|
||||
getSustainabilityWidgetData(tenantId, 30), // 30 days for monthly savings
|
||||
]);
|
||||
|
||||
// Extract data or use undefined for failed requests
|
||||
@@ -218,6 +230,7 @@ export const useDashboardStats = (
|
||||
orders: ordersData.status === 'fulfilled' ? ordersData.value : undefined,
|
||||
sales: salesData.status === 'fulfilled' ? salesData.value : undefined,
|
||||
inventory: inventoryData.status === 'fulfilled' ? inventoryData.value : undefined,
|
||||
sustainability: sustainabilityData.status === 'fulfilled' ? sustainabilityData.value : undefined,
|
||||
};
|
||||
|
||||
// Log any failures for debugging
|
||||
@@ -233,6 +246,9 @@ export const useDashboardStats = (
|
||||
if (inventoryData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch inventory:', inventoryData.reason);
|
||||
}
|
||||
if (sustainabilityData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch sustainability:', sustainabilityData.reason);
|
||||
}
|
||||
|
||||
return aggregateDashboardStats(aggregatedData);
|
||||
},
|
||||
|
||||
@@ -259,14 +259,12 @@ export const useCreateCustomer = (
|
||||
return useMutation<CustomerResponse, ApiError, CustomerCreate>({
|
||||
mutationFn: (customerData: CustomerCreate) => OrdersService.createCustomer(customerData),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate customers list for this tenant
|
||||
// Invalidate all customer list queries for this tenant
|
||||
// This will match any query with ['orders', 'customers', 'list', ...]
|
||||
// refetchType: 'active' forces immediate refetch of mounted queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.customers(),
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey as string[];
|
||||
return queryKey.includes('list') &&
|
||||
JSON.stringify(queryKey).includes(variables.tenant_id);
|
||||
},
|
||||
refetchType: 'active',
|
||||
});
|
||||
|
||||
// Add the new customer to cache
|
||||
@@ -278,6 +276,7 @@ export const useCreateCustomer = (
|
||||
// Invalidate dashboard (for customer metrics)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.dashboard(variables.tenant_id),
|
||||
refetchType: 'active',
|
||||
});
|
||||
},
|
||||
...options,
|
||||
@@ -298,14 +297,18 @@ export const useUpdateCustomer = (
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate customers list for this tenant
|
||||
// Invalidate all customer list queries
|
||||
// This will match any query with ['orders', 'customers', 'list', ...]
|
||||
// refetchType: 'active' forces immediate refetch of mounted queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.customers(),
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey as string[];
|
||||
return queryKey.includes('list') &&
|
||||
JSON.stringify(queryKey).includes(variables.tenantId);
|
||||
},
|
||||
refetchType: 'active',
|
||||
});
|
||||
|
||||
// Invalidate dashboard (for customer metrics)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.dashboard(variables.tenantId),
|
||||
refetchType: 'active',
|
||||
});
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
RecipeDeletionSummary,
|
||||
} from '../types/recipes';
|
||||
|
||||
// Query Keys Factory
|
||||
@@ -225,6 +226,46 @@ export const useDeleteRecipe = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive a recipe (soft delete)
|
||||
*/
|
||||
export const useArchiveRecipe = (
|
||||
tenantId: string,
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, string>({
|
||||
mutationFn: (recipeId: string) => recipesService.archiveRecipe(tenantId, recipeId),
|
||||
onSuccess: (data) => {
|
||||
// Update individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get deletion summary for a recipe
|
||||
*/
|
||||
export const useRecipeDeletionSummary = (
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
options?: Omit<UseQueryOptions<RecipeDeletionSummary, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeDeletionSummary, ApiError>({
|
||||
queryKey: [...recipesKeys.detail(tenantId, recipeId), 'deletion-summary'],
|
||||
queryFn: () => recipesService.getRecipeDeletionSummary(tenantId, recipeId),
|
||||
enabled: !!(tenantId && recipeId),
|
||||
staleTime: 0, // Always fetch fresh data
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate a recipe
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
SupplierApproval,
|
||||
SupplierSearchParams,
|
||||
SupplierStatistics,
|
||||
SupplierDeletionSummary,
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderUpdate,
|
||||
PurchaseOrderResponse,
|
||||
@@ -324,6 +325,41 @@ export const useUpdateSupplier = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveSupplier = (
|
||||
options?: UseMutationOptions<
|
||||
SupplierResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; supplierId: string; approvalData: SupplierApproval }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
SupplierResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; supplierId: string; approvalData: SupplierApproval }
|
||||
>({
|
||||
mutationFn: ({ tenantId, supplierId, approvalData }) =>
|
||||
suppliersService.approveSupplier(tenantId, supplierId, approvalData),
|
||||
onSuccess: (data, { tenantId, supplierId }) => {
|
||||
// Update cache with new supplier status
|
||||
queryClient.setQueryData(
|
||||
suppliersKeys.suppliers.detail(tenantId, supplierId),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate lists and statistics as approval changes counts
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: suppliersKeys.suppliers.lists()
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: suppliersKeys.suppliers.statistics(tenantId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSupplier = (
|
||||
options?: UseMutationOptions<
|
||||
{ message: string },
|
||||
@@ -358,29 +394,28 @@ export const useDeleteSupplier = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveSupplier = (
|
||||
export const useHardDeleteSupplier = (
|
||||
options?: UseMutationOptions<
|
||||
SupplierResponse,
|
||||
SupplierDeletionSummary,
|
||||
ApiError,
|
||||
{ tenantId: string; supplierId: string; approval: SupplierApproval }
|
||||
{ tenantId: string; supplierId: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
SupplierResponse,
|
||||
SupplierDeletionSummary,
|
||||
ApiError,
|
||||
{ tenantId: string; supplierId: string; approval: SupplierApproval }
|
||||
{ tenantId: string; supplierId: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, supplierId, approval }) =>
|
||||
suppliersService.approveSupplier(tenantId, supplierId, approval),
|
||||
onSuccess: (data, { tenantId, supplierId }) => {
|
||||
// Update cache
|
||||
queryClient.setQueryData(
|
||||
suppliersKeys.suppliers.detail(tenantId, supplierId),
|
||||
data
|
||||
);
|
||||
|
||||
mutationFn: ({ tenantId, supplierId }) =>
|
||||
suppliersService.hardDeleteSupplier(tenantId, supplierId),
|
||||
onSuccess: (_, { tenantId, supplierId }) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({
|
||||
queryKey: suppliersKeys.suppliers.detail(tenantId, supplierId)
|
||||
});
|
||||
|
||||
// Invalidate lists and statistics
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: suppliersKeys.suppliers.lists()
|
||||
|
||||
@@ -12,6 +12,7 @@ export const userKeys = {
|
||||
all: ['user'] as const,
|
||||
current: () => [...userKeys.all, 'current'] as const,
|
||||
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
|
||||
activity: (id: string) => [...userKeys.all, 'activity', id] as const,
|
||||
admin: {
|
||||
all: () => [...userKeys.all, 'admin'] as const,
|
||||
list: () => [...userKeys.admin.all(), 'list'] as const,
|
||||
@@ -31,6 +32,19 @@ export const useCurrentUser = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserActivity = (
|
||||
userId: string,
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: userKeys.activity(userId),
|
||||
queryFn: () => userService.getUserActivity(userId),
|
||||
enabled: !!userId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute for activity data
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAllUsers = (
|
||||
options?: Omit<UseQueryOptions<UserResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
@@ -109,4 +123,4 @@ export const useAdminDeleteUser = (
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -77,16 +77,16 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: User Profile
|
||||
// Backend: services/auth/app/api/users.py
|
||||
// User Profile (authenticated)
|
||||
// Backend: services/auth/app/api/auth_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getProfile(): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>('/users/me');
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/me`);
|
||||
}
|
||||
|
||||
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
|
||||
return apiClient.put<UserResponse>('/users/me', updateData);
|
||||
return apiClient.put<UserResponse>(`${this.baseUrl}/me`, updateData);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
@@ -104,6 +104,106 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Account Management (self-service)
|
||||
// Backend: services/auth/app/api/account_deletion.py
|
||||
// ===================================================================
|
||||
|
||||
async deleteAccount(confirmEmail: string, password: string, reason?: string): Promise<{ message: string; deletion_date: string }> {
|
||||
return apiClient.delete(`${this.baseUrl}/me/account`, {
|
||||
data: {
|
||||
confirm_email: confirmEmail,
|
||||
password: password,
|
||||
reason: reason || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAccountDeletionInfo(): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/me/account/deletion-info`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// GDPR Consent Management
|
||||
// Backend: services/auth/app/api/consent.py
|
||||
// ===================================================================
|
||||
|
||||
async recordConsent(consentData: {
|
||||
terms_accepted: boolean;
|
||||
privacy_accepted: boolean;
|
||||
marketing_consent?: boolean;
|
||||
analytics_consent?: boolean;
|
||||
consent_method: string;
|
||||
consent_version?: string;
|
||||
}): Promise<any> {
|
||||
return apiClient.post(`${this.baseUrl}/me/consent`, consentData);
|
||||
}
|
||||
|
||||
async getCurrentConsent(): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/me/consent/current`);
|
||||
}
|
||||
|
||||
async getConsentHistory(): Promise<any[]> {
|
||||
return apiClient.get(`${this.baseUrl}/me/consent/history`);
|
||||
}
|
||||
|
||||
async updateConsent(consentData: {
|
||||
terms_accepted: boolean;
|
||||
privacy_accepted: boolean;
|
||||
marketing_consent?: boolean;
|
||||
analytics_consent?: boolean;
|
||||
consent_method: string;
|
||||
consent_version?: string;
|
||||
}): Promise<any> {
|
||||
return apiClient.put(`${this.baseUrl}/me/consent`, consentData);
|
||||
}
|
||||
|
||||
async withdrawConsent(): Promise<{ message: string; withdrawn_count: number }> {
|
||||
return apiClient.post(`${this.baseUrl}/me/consent/withdraw`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Data Export (GDPR)
|
||||
// Backend: services/auth/app/api/data_export.py
|
||||
// ===================================================================
|
||||
|
||||
async exportMyData(): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/me/export`);
|
||||
}
|
||||
|
||||
async getExportSummary(): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/me/export/summary`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Onboarding Progress
|
||||
// Backend: services/auth/app/api/onboarding_progress.py
|
||||
// ===================================================================
|
||||
|
||||
async getOnboardingProgress(): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/me/onboarding/progress`);
|
||||
}
|
||||
|
||||
async updateOnboardingStep(stepName: string, completed: boolean, data?: any): Promise<any> {
|
||||
return apiClient.put(`${this.baseUrl}/me/onboarding/step`, {
|
||||
step_name: stepName,
|
||||
completed: completed,
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
async getNextOnboardingStep(): Promise<{ step: string }> {
|
||||
return apiClient.get(`${this.baseUrl}/me/onboarding/next-step`);
|
||||
}
|
||||
|
||||
async canAccessOnboardingStep(stepName: string): Promise<{ can_access: boolean }> {
|
||||
return apiClient.get(`${this.baseUrl}/me/onboarding/can-access/${stepName}`);
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post(`${this.baseUrl}/me/onboarding/complete`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health Check
|
||||
// ===================================================================
|
||||
|
||||
@@ -82,9 +82,10 @@ export class OrdersService {
|
||||
* Create a new customer order
|
||||
* POST /tenants/{tenant_id}/orders
|
||||
*/
|
||||
static async createOrder(orderData: OrderCreate): Promise<OrderResponse> {
|
||||
const { tenant_id, ...data } = orderData;
|
||||
return apiClient.post<OrderResponse>(`/tenants/${tenant_id}/orders`, data);
|
||||
static async createOrder(orderData: OrderCreate): Promise<OrderResponse> {
|
||||
const { tenant_id } = orderData;
|
||||
// Note: tenant_id is in both URL path and request body (backend schema requirement)
|
||||
return apiClient.post<OrderResponse>(`/tenants/${tenant_id}/orders`, orderData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,11 +145,11 @@ export class OrdersService {
|
||||
|
||||
/**
|
||||
* Create a new customer
|
||||
* POST /tenants/{tenant_id}/customers
|
||||
* POST /tenants/{tenant_id}/orders/customers
|
||||
*/
|
||||
static async createCustomer(customerData: CustomerCreate): Promise<CustomerResponse> {
|
||||
const { tenant_id, ...data } = customerData;
|
||||
return apiClient.post<CustomerResponse>(`/tenants/${tenant_id}/customers`, data);
|
||||
return apiClient.post<CustomerResponse>(`/tenants/${tenant_id}/orders/customers`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,4 +414,4 @@ export class OrdersService {
|
||||
|
||||
}
|
||||
|
||||
export default OrdersService;
|
||||
export default OrdersService;
|
||||
|
||||
@@ -594,4 +594,4 @@ export class POSService {
|
||||
|
||||
// Export singleton instance
|
||||
export const posService = new POSService();
|
||||
export default posService;
|
||||
export default posService;
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
RecipeCategoriesResponse,
|
||||
RecipeQualityConfiguration,
|
||||
RecipeQualityConfigurationUpdate,
|
||||
RecipeDeletionSummary,
|
||||
} from '../types/recipes';
|
||||
|
||||
export class RecipesService {
|
||||
@@ -94,6 +95,22 @@ export class RecipesService {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a recipe (soft delete by setting status to ARCHIVED)
|
||||
* PATCH /tenants/{tenant_id}/recipes/{recipe_id}/archive
|
||||
*/
|
||||
async archiveRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.patch<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/archive`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deletion summary for a recipe
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}/deletion-summary
|
||||
*/
|
||||
async getRecipeDeletionSummary(tenantId: string, recipeId: string): Promise<RecipeDeletionSummary> {
|
||||
return apiClient.get<RecipeDeletionSummary>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/deletion-summary`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Quality Configuration CRUD
|
||||
// Backend: services/recipes/app/api/recipe_quality_configs.py
|
||||
|
||||
@@ -187,21 +187,48 @@ export class SubscriptionService {
|
||||
/**
|
||||
* Check if tenant can perform an action within quota limits
|
||||
*/
|
||||
async checkQuotaLimit(
|
||||
async checkQuotaLimit(
|
||||
tenantId: string,
|
||||
quotaType: string,
|
||||
requestedAmount?: number
|
||||
): Promise<QuotaCheckResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (requestedAmount !== undefined) {
|
||||
queryParams.append('requested_amount', requestedAmount.toString());
|
||||
// Map quotaType to the existing endpoints in tenant_operations.py
|
||||
let endpoint: string;
|
||||
switch (quotaType) {
|
||||
case 'inventory_items':
|
||||
endpoint = 'can-add-product';
|
||||
break;
|
||||
case 'users':
|
||||
endpoint = 'can-add-user';
|
||||
break;
|
||||
case 'locations':
|
||||
endpoint = 'can-add-location';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported quota type: ${quotaType}`);
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/subscriptions/${tenantId}/quotas/${quotaType}/check?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/subscriptions/${tenantId}/quotas/${quotaType}/check`;
|
||||
|
||||
return apiClient.get<QuotaCheckResponse>(url);
|
||||
const url = `${this.baseUrl}/subscriptions/${tenantId}/${endpoint}`;
|
||||
|
||||
// Get the response from the endpoint (returns different format than expected)
|
||||
const response = await apiClient.get<{
|
||||
can_add: boolean;
|
||||
current_count?: number;
|
||||
max_allowed?: number;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}>(url);
|
||||
|
||||
// Map the response to QuotaCheckResponse format
|
||||
return {
|
||||
allowed: response.can_add,
|
||||
current: response.current_count || 0,
|
||||
limit: response.max_allowed || null,
|
||||
remaining: response.max_allowed !== undefined && response.current_count !== undefined
|
||||
? response.max_allowed - response.current_count
|
||||
: null,
|
||||
message: response.reason || response.message || ''
|
||||
};
|
||||
}
|
||||
|
||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||
@@ -348,4 +375,4 @@ export class SubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SupplierApproval,
|
||||
SupplierQueryParams,
|
||||
SupplierStatistics,
|
||||
SupplierDeletionSummary,
|
||||
TopSuppliersResponse,
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderUpdate,
|
||||
@@ -53,7 +54,7 @@ class SuppliersService {
|
||||
supplierData: SupplierCreate
|
||||
): Promise<SupplierResponse> {
|
||||
return apiClient.post<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers`,
|
||||
`${this.baseUrl}/${tenantId}/suppliers`,
|
||||
supplierData
|
||||
);
|
||||
}
|
||||
@@ -74,13 +75,13 @@ class SuppliersService {
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<PaginatedResponse<SupplierSummary>>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers${queryString}`
|
||||
`${this.baseUrl}/${tenantId}/suppliers${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplier(tenantId: string, supplierId: string): Promise<SupplierResponse> {
|
||||
return apiClient.get<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers/${supplierId}`
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ class SuppliersService {
|
||||
updateData: SupplierUpdate
|
||||
): Promise<SupplierResponse> {
|
||||
return apiClient.put<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers/${supplierId}`,
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
@@ -100,7 +101,16 @@ class SuppliersService {
|
||||
supplierId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers/${supplierId}`
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`
|
||||
);
|
||||
}
|
||||
|
||||
async hardDeleteSupplier(
|
||||
tenantId: string,
|
||||
supplierId: string
|
||||
): Promise<SupplierDeletionSummary> {
|
||||
return apiClient.delete<SupplierDeletionSummary>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/hard`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +123,7 @@ class SuppliersService {
|
||||
params.append('is_active', isActive.toString());
|
||||
|
||||
return apiClient.get<Array<{ inventory_product_id: string }>>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers/${supplierId}/products?${params.toString()}`
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/products?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ export class UserService {
|
||||
async getUserById(userId: string): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/admin/${userId}`);
|
||||
}
|
||||
|
||||
async getUserActivity(userId: string): Promise<any> {
|
||||
return apiClient.get<any>(`/auth/users/${userId}/activity`);
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
export const userService = new UserService();
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface IngredientCreate {
|
||||
package_size?: number | null;
|
||||
|
||||
// Pricing
|
||||
average_cost?: number | null;
|
||||
// Note: average_cost is calculated automatically from purchases (not accepted on create)
|
||||
standard_cost?: number | null;
|
||||
|
||||
// Stock management
|
||||
|
||||
@@ -528,6 +528,16 @@ export interface ProcurementRequirementResponse extends ProcurementRequirementBa
|
||||
shelf_life_days?: number;
|
||||
quality_specifications?: Record<string, any>;
|
||||
procurement_notes?: string;
|
||||
|
||||
// Smart procurement calculation metadata
|
||||
calculation_method?: string;
|
||||
ai_suggested_quantity?: number;
|
||||
adjusted_quantity?: number;
|
||||
adjustment_reason?: string;
|
||||
price_tier_applied?: Record<string, any>;
|
||||
supplier_minimum_applied?: boolean;
|
||||
storage_limit_applied?: boolean;
|
||||
reorder_rule_applied?: boolean;
|
||||
}
|
||||
|
||||
// Procurement Plan Types
|
||||
|
||||
@@ -157,6 +157,10 @@ export interface RecipeQualityConfiguration {
|
||||
stages: Record<string, ProcessStageQualityConfig>;
|
||||
global_parameters?: Record<string, any>;
|
||||
default_templates?: string[];
|
||||
overall_quality_threshold?: number;
|
||||
critical_stage_blocking?: boolean;
|
||||
auto_create_quality_checks?: boolean;
|
||||
quality_manager_approval_required?: boolean;
|
||||
}
|
||||
|
||||
// Filter and query types
|
||||
|
||||
@@ -360,6 +360,23 @@ export interface RecipeStatisticsResponse {
|
||||
category_breakdown: Array<Record<string, any>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of what will be deleted when hard-deleting a recipe
|
||||
* Backend: RecipeDeletionSummary in schemas/recipes.py (lines 235-246)
|
||||
*/
|
||||
export interface RecipeDeletionSummary {
|
||||
recipe_id: string;
|
||||
recipe_name: string;
|
||||
recipe_code: string;
|
||||
production_batches_count: number;
|
||||
recipe_ingredients_count: number;
|
||||
dependent_recipes_count: number;
|
||||
affected_orders_count: number;
|
||||
last_used_date?: string | null;
|
||||
can_delete: boolean;
|
||||
warnings: string[]; // Default: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for recipe categories list
|
||||
* Backend: get_recipe_categories endpoint in api/recipe_operations.py (lines 168-186)
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface ProcurementSettings {
|
||||
safety_stock_percentage: number;
|
||||
po_approval_reminder_hours: number;
|
||||
po_critical_escalation_hours: number;
|
||||
use_reorder_rules: boolean;
|
||||
economic_rounding: boolean;
|
||||
respect_storage_limits: boolean;
|
||||
use_supplier_minimums: boolean;
|
||||
optimize_price_tiers: boolean;
|
||||
}
|
||||
|
||||
export interface InventorySettings {
|
||||
|
||||
@@ -288,6 +288,13 @@ export interface SupplierSummary {
|
||||
phone: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
|
||||
// Business terms - Added for list view
|
||||
payment_terms: PaymentTerms;
|
||||
standard_lead_time: number;
|
||||
minimum_order_amount: number | null;
|
||||
|
||||
// Performance metrics
|
||||
quality_rating: number | null;
|
||||
delivery_rating: number | null;
|
||||
total_orders: number;
|
||||
@@ -945,3 +952,15 @@ export interface ExportDataResponse {
|
||||
status: 'generating' | 'ready' | 'expired' | 'failed';
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
// ===== DELETION =====
|
||||
|
||||
export interface SupplierDeletionSummary {
|
||||
supplier_name: string;
|
||||
deleted_price_lists: number;
|
||||
deleted_quality_reviews: number;
|
||||
deleted_performance_metrics: number;
|
||||
deleted_alerts: number;
|
||||
deleted_scorecards: number;
|
||||
deletion_timestamp: string;
|
||||
}
|
||||
|
||||
@@ -88,12 +88,22 @@ export interface GrantProgramEligibility {
|
||||
eligible: boolean;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
requirements_met: boolean;
|
||||
funding_eur?: number;
|
||||
deadline?: string;
|
||||
program_type?: string;
|
||||
sector_specific?: string;
|
||||
}
|
||||
|
||||
export interface SpainCompliance {
|
||||
law_1_2025: boolean;
|
||||
circular_economy_strategy: boolean;
|
||||
}
|
||||
|
||||
export interface GrantReadiness {
|
||||
overall_readiness_percentage: number;
|
||||
grant_programs: Record<string, GrantProgramEligibility>;
|
||||
recommended_applications: string[];
|
||||
spain_compliance?: SpainCompliance;
|
||||
}
|
||||
|
||||
export interface SustainabilityMetrics {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react';
|
||||
import { EditViewModal, StatusModalSection } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { Equipment } from '../../../api/types/equipment';
|
||||
|
||||
interface EquipmentModalProps {
|
||||
@@ -50,10 +50,18 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
} as Equipment
|
||||
);
|
||||
|
||||
// Sync equipment state with initialEquipment prop changes
|
||||
React.useEffect(() => {
|
||||
if (initialEquipment) {
|
||||
setEquipment(initialEquipment);
|
||||
}
|
||||
}, [initialEquipment]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (equipment) {
|
||||
await onSave(equipment);
|
||||
setCurrentMode('view');
|
||||
// Note: Don't manually switch mode here - EditViewModal will handle it
|
||||
// if waitForRefetch is enabled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +121,7 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getSections = (): StatusModalSection[] => {
|
||||
const getSections = (): EditViewModalSection[] => {
|
||||
if (!equipment) return [];
|
||||
|
||||
const equipmentTypes = [
|
||||
@@ -346,8 +354,8 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
}}
|
||||
mode={currentMode}
|
||||
onModeChange={setCurrentMode}
|
||||
title={isCreating ? t('actions.add_equipment') : equipment?.name || t('common:forms.untitled')}
|
||||
subtitle={isCreating ? t('sections.create_equipment_subtitle') : `${equipment?.model || ''} - ${equipment?.serialNumber || ''}`}
|
||||
title={equipment?.name || t('common:forms.untitled')}
|
||||
subtitle={equipment?.model && equipment?.serialNumber ? `${equipment.model} • ${equipment.serialNumber}` : equipment?.model || equipment?.serialNumber || undefined}
|
||||
statusIndicator={getEquipmentStatusConfig()}
|
||||
size="lg"
|
||||
sections={getSections()}
|
||||
|
||||
@@ -13,6 +13,10 @@ interface AddStockModalProps {
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
onAddStock?: (stockData: StockCreate) => Promise<void>;
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch?: boolean;
|
||||
isRefetching?: boolean;
|
||||
onSaveComplete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,7 +27,10 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
onAddStock
|
||||
onAddStock,
|
||||
waitForRefetch,
|
||||
isRefetching,
|
||||
onSaveComplete
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Partial<StockCreate>>({
|
||||
ingredient_id: ingredient.id,
|
||||
@@ -66,7 +73,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
// Get production stage options using direct i18n
|
||||
const productionStageOptions = Object.values(ProductionStage).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:production_stage.${value}`)
|
||||
label: t(`inventory:enums.production_stage.${value}`)
|
||||
}));
|
||||
|
||||
// Create supplier options for select
|
||||
@@ -78,12 +85,12 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
}))
|
||||
];
|
||||
|
||||
// Create quality status options
|
||||
// Create quality status options (matches backend: good, damaged, expired, quarantined)
|
||||
const qualityStatusOptions = [
|
||||
{ value: 'good', label: 'Bueno' },
|
||||
{ value: 'damaged', label: 'Dañado' },
|
||||
{ value: 'expired', label: 'Vencido' },
|
||||
{ value: 'returned', label: 'Devuelto' }
|
||||
{ value: 'quarantined', label: 'En Cuarentena' }
|
||||
];
|
||||
|
||||
// Create storage location options (predefined common locations)
|
||||
@@ -182,6 +189,33 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
await onAddStock(stockData);
|
||||
}
|
||||
|
||||
// If waitForRefetch is enabled, trigger refetch and wait
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for refetch to complete
|
||||
const startTime = Date.now();
|
||||
const refetchTimeout = 3000;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached for stock addition');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
ingredient_id: ingredient.id,
|
||||
@@ -393,8 +427,8 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
title={`Agregar Stock: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • Stock actual: ${currentStock} ${ingredient.unit_of_measure}`}
|
||||
title={ingredient.name}
|
||||
subtitle={`${ingredient.category} • ${currentStock} ${ingredient.unit_of_measure}`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
actions={actions}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save, ChevronDown, ChevronUp, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockResponse, StockUpdate } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
|
||||
interface BatchModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,9 +14,14 @@ interface BatchModalProps {
|
||||
ingredient: IngredientResponse;
|
||||
batches: StockResponse[];
|
||||
loading?: boolean;
|
||||
tenantId: string;
|
||||
onAddBatch?: () => void;
|
||||
onEditBatch?: (batchId: string, updateData: StockUpdate) => Promise<void>;
|
||||
onMarkAsWaste?: (batchId: string) => Promise<void>;
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch?: boolean;
|
||||
isRefetching?: boolean;
|
||||
onSaveComplete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,13 +34,132 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
ingredient,
|
||||
batches = [],
|
||||
loading = false,
|
||||
tenantId,
|
||||
onAddBatch,
|
||||
onEditBatch,
|
||||
onMarkAsWaste
|
||||
onMarkAsWaste,
|
||||
waitForRefetch,
|
||||
isRefetching,
|
||||
onSaveComplete
|
||||
}) => {
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
const [editingBatch, setEditingBatch] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<StockUpdate>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
||||
|
||||
// Collapsible state - start with all batches collapsed for better UX
|
||||
const [collapsedBatches, setCollapsedBatches] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize all batches as collapsed when batches change or modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && batches.length > 0) {
|
||||
setCollapsedBatches(new Set(batches.map(b => b.id)));
|
||||
}
|
||||
}, [isOpen, batches]);
|
||||
|
||||
// Fetch suppliers for the dropdown
|
||||
const { data: suppliersData } = useSuppliers(tenantId, {
|
||||
limit: 100
|
||||
}, {
|
||||
enabled: !!tenantId && isOpen
|
||||
});
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Get production stage options using direct i18n
|
||||
const productionStageOptions = Object.values(ingredient.product_type === 'finished_product' ? [] : [
|
||||
{ value: 'raw_ingredient', label: t(`enums.production_stage.raw_ingredient`) },
|
||||
{ value: 'par_baked', label: t(`enums.production_stage.par_baked`) },
|
||||
{ value: 'fully_baked', label: t(`enums.production_stage.fully_baked`) },
|
||||
{ value: 'prepared_dough', label: t(`enums.production_stage.prepared_dough`) },
|
||||
{ value: 'frozen_product', label: t(`enums.production_stage.frozen_product`) }
|
||||
]);
|
||||
|
||||
// Create quality status options (matches backend: good, damaged, expired, quarantined)
|
||||
const qualityStatusOptions = [
|
||||
{ value: 'good', label: 'Bueno' },
|
||||
{ value: 'damaged', label: 'Dañado' },
|
||||
{ value: 'expired', label: 'Vencido' },
|
||||
{ value: 'quarantined', label: 'En Cuarentena' }
|
||||
];
|
||||
|
||||
// Create storage location options (predefined common locations)
|
||||
const storageLocationOptions = [
|
||||
{ value: '', label: 'Sin ubicación específica' },
|
||||
{ value: 'estante-a1', label: 'Estante A-1' },
|
||||
{ value: 'estante-a2', label: 'Estante A-2' },
|
||||
{ value: 'estante-a3', label: 'Estante A-3' },
|
||||
{ value: 'estante-b1', label: 'Estante B-1' },
|
||||
{ value: 'estante-b2', label: 'Estante B-2' },
|
||||
{ value: 'frigorifico', label: 'Frigorífico' },
|
||||
{ value: 'congelador', label: 'Congelador' },
|
||||
{ value: 'almacen-principal', label: 'Almacén Principal' },
|
||||
{ value: 'zona-recepcion', label: 'Zona de Recepción' }
|
||||
];
|
||||
|
||||
// Create warehouse zone options
|
||||
const warehouseZoneOptions = [
|
||||
{ value: '', label: 'Sin zona específica' },
|
||||
{ value: 'zona-a', label: 'Zona A' },
|
||||
{ value: 'zona-b', label: 'Zona B' },
|
||||
{ value: 'zona-c', label: 'Zona C' },
|
||||
{ value: 'refrigerado', label: 'Refrigerado' },
|
||||
{ value: 'congelado', label: 'Congelado' },
|
||||
{ value: 'ambiente', label: 'Temperatura Ambiente' }
|
||||
];
|
||||
|
||||
// Create refrigeration requirement options
|
||||
const refrigerationOptions = [
|
||||
{ value: 'no', label: 'No requiere refrigeración' },
|
||||
{ value: 'yes', label: 'Requiere refrigeración' },
|
||||
{ value: 'recommended', label: 'Refrigeración recomendada' }
|
||||
];
|
||||
|
||||
// Create freezing requirement options
|
||||
const freezingOptions = [
|
||||
{ value: 'no', label: 'No requiere congelación' },
|
||||
{ value: 'yes', label: 'Requiere congelación' },
|
||||
{ value: 'recommended', label: 'Congelación recomendada' }
|
||||
];
|
||||
|
||||
// Helper function to get translated category display name
|
||||
const getCategoryDisplayName = (category?: string | null): string => {
|
||||
if (!category) return t('categories.all', 'Sin categoría');
|
||||
|
||||
// Try ingredient category translation first
|
||||
const ingredientTranslation = t(`enums.ingredient_category.${category}`, { defaultValue: '' });
|
||||
if (ingredientTranslation) return ingredientTranslation;
|
||||
|
||||
// Try product category translation
|
||||
const productTranslation = t(`enums.product_category.${category}`, { defaultValue: '' });
|
||||
if (productTranslation) return productTranslation;
|
||||
|
||||
// Fallback to raw category if no translation found
|
||||
return category;
|
||||
};
|
||||
|
||||
// Toggle batch collapse state
|
||||
const toggleBatchCollapse = (batchId: string) => {
|
||||
setCollapsedBatches(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(batchId)) {
|
||||
next.delete(batchId);
|
||||
} else {
|
||||
next.add(batchId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Expand all batches
|
||||
const expandAll = () => {
|
||||
setCollapsedBatches(new Set());
|
||||
};
|
||||
|
||||
// Collapse all batches
|
||||
const collapseAll = () => {
|
||||
setCollapsedBatches(new Set(batches.map(b => b.id)));
|
||||
};
|
||||
|
||||
// Get batch status based on expiration and availability
|
||||
const getBatchStatus = (batch: StockResponse) => {
|
||||
@@ -101,10 +227,33 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
|
||||
const handleEditStart = (batch: StockResponse) => {
|
||||
setEditingBatch(batch.id);
|
||||
// Auto-expand when editing
|
||||
setCollapsedBatches(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(batch.id);
|
||||
return next;
|
||||
});
|
||||
setEditData({
|
||||
supplier_id: batch.supplier_id || '',
|
||||
batch_number: batch.batch_number || '',
|
||||
lot_number: batch.lot_number || '',
|
||||
supplier_batch_ref: batch.supplier_batch_ref || '',
|
||||
production_stage: batch.production_stage,
|
||||
transformation_reference: batch.transformation_reference || '',
|
||||
current_quantity: batch.current_quantity,
|
||||
reserved_quantity: batch.reserved_quantity,
|
||||
received_date: batch.received_date,
|
||||
expiration_date: batch.expiration_date,
|
||||
best_before_date: batch.best_before_date,
|
||||
original_expiration_date: batch.original_expiration_date,
|
||||
transformation_date: batch.transformation_date,
|
||||
final_expiration_date: batch.final_expiration_date,
|
||||
unit_cost: batch.unit_cost !== null ? batch.unit_cost : undefined,
|
||||
storage_location: batch.storage_location || '',
|
||||
warehouse_zone: batch.warehouse_zone || '',
|
||||
shelf_position: batch.shelf_position || '',
|
||||
is_available: batch.is_available,
|
||||
quality_status: batch.quality_status,
|
||||
requires_refrigeration: batch.requires_refrigeration,
|
||||
requires_freezing: batch.requires_freezing,
|
||||
storage_temperature_min: batch.storage_temperature_min,
|
||||
@@ -123,15 +272,62 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
const handleEditSave = async (batchId: string) => {
|
||||
if (!onEditBatch) return;
|
||||
|
||||
// CRITICAL: Capture editData IMMEDIATELY before any async operations
|
||||
const dataToSave = { ...editData };
|
||||
|
||||
// Validate we have data to save
|
||||
if (Object.keys(dataToSave).length === 0) {
|
||||
console.error('BatchModal: No edit data to save for batch', batchId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('BatchModal: Saving batch data:', dataToSave);
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onEditBatch(batchId, editData);
|
||||
// Execute the update mutation
|
||||
await onEditBatch(batchId, dataToSave);
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become false (with timeout)
|
||||
const startTime = Date.now();
|
||||
const refetchTimeout = 3000;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached for batch update');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
|
||||
// Clear editing state after save (and optional refetch) completes
|
||||
setEditingBatch(null);
|
||||
setEditData({});
|
||||
} catch (error) {
|
||||
console.error('Error updating batch:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,8 +372,15 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border-secondary)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left side: clickable area for collapse/expand */}
|
||||
<button
|
||||
onClick={() => !isEditing && toggleBatchCollapse(batch.id)}
|
||||
disabled={isEditing}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity disabled:cursor-default disabled:hover:opacity-100"
|
||||
aria-expanded={!collapsedBatches.has(batch.id)}
|
||||
aria-label={`${collapsedBatches.has(batch.id) ? 'Expandir' : 'Colapsar'} lote ${batch.batch_number || 'sin número'}`}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${status.color}15` }}
|
||||
@@ -187,7 +390,7 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
Lote #{batch.batch_number || 'Sin número'}
|
||||
</h3>
|
||||
@@ -197,10 +400,35 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
>
|
||||
{status.label}
|
||||
</div>
|
||||
{/* Inline summary when collapsed */}
|
||||
{collapsedBatches.has(batch.id) && (
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{batch.current_quantity} {ingredient.unit_of_measure}
|
||||
{batch.expiration_date && (
|
||||
<> • Vence: {new Date(batch.expiration_date).toLocaleDateString('es-ES')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Right side: action buttons */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Collapse/Expand chevron */}
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={() => toggleBatchCollapse(batch.id)}
|
||||
className="p-2 rounded-md hover:bg-[var(--surface-tertiary)] transition-colors"
|
||||
aria-label={collapsedBatches.has(batch.id) ? 'Expandir' : 'Colapsar'}
|
||||
>
|
||||
{collapsedBatches.has(batch.id) ? (
|
||||
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Button
|
||||
@@ -250,9 +478,10 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Basic Info */}
|
||||
{/* Content - Only show when expanded */}
|
||||
{!collapsedBatches.has(batch.id) && (
|
||||
<div className="p-4 space-y-4 transition-all duration-200 ease-in-out">
|
||||
{/* Quantities Section */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
@@ -272,6 +501,52 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Cantidad Reservada
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.reserved_quantity || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, reserved_quantity: Number(e.target.value) }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.reserved_quantity} {ingredient.unit_of_measure}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Cantidad Disponible
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.available_quantity} {ingredient.unit_of_measure}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Costo Unitario
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editData.unit_cost || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, unit_cost: Number(e.target.value) }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.unit_cost ? formatters.currency(Number(batch.unit_cost)) : 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Valor Total
|
||||
@@ -282,8 +557,122 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
{/* Batch Identification */}
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Número de Lote
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.lot_number || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, lot_number: e.target.value }))}
|
||||
placeholder="Número de lote"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.lot_number || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Ref. Proveedor
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.supplier_batch_ref || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, supplier_batch_ref: e.target.value }))}
|
||||
placeholder="Ref. del proveedor"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.supplier_batch_ref || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Estado de Calidad
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.quality_status || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, quality_status: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{qualityStatusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{qualityStatusOptions.find(opt => opt.value === batch.quality_status)?.label || batch.quality_status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier and Dates */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Proveedor
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.supplier_id || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, supplier_id: e.target.value || null }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Sin proveedor</option>
|
||||
{suppliers.map(supplier => (
|
||||
<option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name} {supplier.supplier_code ? `(${supplier.supplier_code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.supplier_id
|
||||
? suppliers.find(s => s.id === batch.supplier_id)?.name || 'Proveedor desconocido'
|
||||
: 'Sin proveedor'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Fecha de Recepción
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
value={editData.received_date ? new Date(editData.received_date).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, received_date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.received_date
|
||||
? new Date(batch.received_date).toLocaleDateString('es-ES')
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Fecha de Vencimiento
|
||||
@@ -307,79 +696,336 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Ubicación
|
||||
Mejor Antes De
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.storage_location || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_location: e.target.value }))}
|
||||
placeholder="Ubicación del lote"
|
||||
type="date"
|
||||
value={editData.best_before_date ? new Date(editData.best_before_date).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, best_before_date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.storage_location || 'No especificada'}
|
||||
{batch.best_before_date
|
||||
? new Date(batch.best_before_date).toLocaleDateString('es-ES')
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Locations */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Ubicación de Almacenamiento
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.storage_location || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_location: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{storageLocationOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{storageLocationOptions.find(opt => opt.value === batch.storage_location)?.label || batch.storage_location || 'No especificada'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Zona de Almacén
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.warehouse_zone || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, warehouse_zone: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{warehouseZoneOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{warehouseZoneOptions.find(opt => opt.value === batch.warehouse_zone)?.label || batch.warehouse_zone || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Posición en Estantería
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.shelf_position || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, shelf_position: e.target.value }))}
|
||||
placeholder="Posición"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.shelf_position || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Stage Information */}
|
||||
{(batch.production_stage !== 'raw_ingredient' || batch.transformation_reference) && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
|
||||
Información de Transformación
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Etapa de Producción
|
||||
</label>
|
||||
{isEditing && productionStageOptions.length > 0 ? (
|
||||
<select
|
||||
value={(editData.production_stage || batch.production_stage) as string}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, production_stage: e.target.value as any }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{productionStageOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t(`enums.production_stage.${batch.production_stage}`, { defaultValue: batch.production_stage })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batch.transformation_reference && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Referencia de Transformación
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.transformation_reference}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.original_expiration_date && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Vencimiento Original
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{new Date(batch.original_expiration_date).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.transformation_date && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Fecha de Transformación
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{new Date(batch.transformation_date).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.final_expiration_date && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Vencimiento Final
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{new Date(batch.final_expiration_date).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Requirements */}
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Thermometer className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-tertiary)]">
|
||||
Almacenamiento
|
||||
Requisitos de Almacenamiento
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Tipo:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{batch.requires_refrigeration ? 'Refrigeración' :
|
||||
batch.requires_freezing ? 'Congelación' : 'Ambiente'}
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Requiere Refrigeración
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.requires_refrigeration ? 'yes' : 'no'}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, requires_refrigeration: e.target.value === 'yes' }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{refrigerationOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.requires_refrigeration ? 'Sí' : 'No'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(batch.storage_temperature_min || batch.storage_temperature_max) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Temp:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{batch.storage_temperature_min || '-'}°C a {batch.storage_temperature_max || '-'}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Requiere Congelación
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.requires_freezing ? 'yes' : 'no'}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, requires_freezing: e.target.value === 'yes' }))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{freezingOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.requires_freezing ? 'Sí' : 'No'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batch.storage_humidity_max && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Humedad:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
≤{batch.storage_humidity_max}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Vida Útil (días)
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.shelf_life_days || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, shelf_life_days: Number(e.target.value) || null }))}
|
||||
placeholder="Días"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.shelf_life_days ? `${batch.shelf_life_days} días` : 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batch.shelf_life_days && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Vida útil:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{batch.shelf_life_days} días
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Temperatura Mínima (°C)
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editData.storage_temperature_min || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_temperature_min: Number(e.target.value) || null }))}
|
||||
placeholder="°C"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.storage_temperature_min !== null ? `${batch.storage_temperature_min}°C` : 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Temperatura Máxima (°C)
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editData.storage_temperature_max || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_temperature_max: Number(e.target.value) || null }))}
|
||||
placeholder="°C"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.storage_temperature_max !== null ? `${batch.storage_temperature_max}°C` : 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Humedad Máxima (%)
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editData.storage_humidity_max || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_humidity_max: Number(e.target.value) || null }))}
|
||||
placeholder="%"
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.storage_humidity_max !== null ? `≤${batch.storage_humidity_max}%` : 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batch.storage_instructions && (
|
||||
<div className="mt-2 p-2 bg-[var(--surface-tertiary)] rounded-md">
|
||||
<div className="text-xs text-[var(--text-secondary)] italic">
|
||||
"{batch.storage_instructions}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Instrucciones de Almacenamiento
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.storage_instructions || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_instructions: e.target.value }))}
|
||||
placeholder="Instrucciones especiales..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
batch.storage_instructions ? (
|
||||
<div className="p-2 bg-[var(--surface-tertiary)] rounded-md">
|
||||
<div className="text-xs text-[var(--text-secondary)] italic">
|
||||
"{batch.storage_instructions}"
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Sin instrucciones especiales
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -421,6 +1067,8 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
];
|
||||
|
||||
const actions = [];
|
||||
|
||||
// Only show "Agregar Lote" button
|
||||
if (onAddBatch && batches.length > 0) {
|
||||
actions.push({
|
||||
label: 'Agregar Lote',
|
||||
@@ -435,12 +1083,12 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={`Lotes de Stock: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • ${batches.length} lotes registrados`}
|
||||
title={ingredient.name}
|
||||
subtitle={`${getCategoryDisplayName(ingredient.category)} • ${batches.length} lotes`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
loading={loading || isSubmitting || isWaitingForRefetch}
|
||||
showDefaultActions={false}
|
||||
actions={actions}
|
||||
/>
|
||||
|
||||
@@ -26,12 +26,12 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
// Get enum options using direct i18n implementation
|
||||
const ingredientCategoryOptions = Object.values(IngredientCategory).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:ingredient_category.${value}`)
|
||||
label: t(`inventory:enums.ingredient_category.${value}`)
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const productCategoryOptions = Object.values(ProductCategory).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:product_category.${value}`)
|
||||
label: t(`inventory:enums.product_category.${value}`)
|
||||
}));
|
||||
|
||||
const categoryOptions = [
|
||||
@@ -41,22 +41,27 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
|
||||
const unitOptions = Object.values(UnitOfMeasure).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:unit_of_measure.${value}`)
|
||||
label: t(`inventory:enums.unit_of_measure.${value}`)
|
||||
}));
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Transform form data to IngredientCreate format
|
||||
const ingredientData: IngredientCreate = {
|
||||
name: formData.name,
|
||||
sku: formData.sku || null,
|
||||
barcode: formData.barcode || null,
|
||||
brand: formData.brand || null,
|
||||
description: formData.description || '',
|
||||
category: formData.category,
|
||||
unit_of_measure: formData.unit_of_measure,
|
||||
package_size: formData.package_size ? Number(formData.package_size) : null,
|
||||
standard_cost: formData.standard_cost ? Number(formData.standard_cost) : null,
|
||||
low_stock_threshold: Number(formData.low_stock_threshold),
|
||||
reorder_point: Number(formData.reorder_point),
|
||||
max_stock_level: Number(formData.max_stock_level),
|
||||
is_seasonal: false,
|
||||
average_cost: Number(formData.average_cost) || 0,
|
||||
notes: formData.notes || ''
|
||||
reorder_quantity: Number(formData.reorder_quantity),
|
||||
max_stock_level: formData.max_stock_level ? Number(formData.max_stock_level) : null,
|
||||
shelf_life_days: formData.shelf_life_days ? Number(formData.shelf_life_days) : null,
|
||||
is_perishable: formData.is_perishable === 'true' || formData.is_perishable === true
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
@@ -90,53 +95,103 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
name: 'name',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
placeholder: 'Ej: Harina de trigo 000',
|
||||
placeholder: t('inventory:validation.name_required', 'Ej: Harina de trigo 000'),
|
||||
validation: (value: string | number) => {
|
||||
const str = String(value).trim();
|
||||
return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null;
|
||||
return str.length < 2 ? t('inventory:validation.name_required', 'El nombre debe tener al menos 2 caracteres') : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.sku', 'Código SKU'),
|
||||
name: 'sku',
|
||||
type: 'text' as const,
|
||||
placeholder: 'SKU-001'
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.barcode', 'Código de Barras'),
|
||||
name: 'barcode',
|
||||
type: 'text' as const,
|
||||
placeholder: '1234567890123'
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.brand', 'Marca'),
|
||||
name: 'brand',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Molinos'
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.description', 'Descripción'),
|
||||
name: 'description',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Descripción opcional del artículo'
|
||||
placeholder: t('inventory:fields.description', 'Descripción opcional del artículo'),
|
||||
span: 2 // Full width
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
label: t('inventory:labels.ingredient_category', 'Categoría'),
|
||||
name: 'category',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: categoryOptions,
|
||||
placeholder: 'Seleccionar categoría...'
|
||||
placeholder: t('inventory:validation.category_required', 'Seleccionar categoría...')
|
||||
},
|
||||
{
|
||||
label: 'Unidad de Medida',
|
||||
label: t('inventory:labels.unit_of_measure', 'Unidad de Medida'),
|
||||
name: 'unit_of_measure',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: unitOptions,
|
||||
defaultValue: 'kg'
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.package_size', 'Tamaño de Paquete'),
|
||||
name: 'package_size',
|
||||
type: 'number' as const,
|
||||
placeholder: '1',
|
||||
helpText: 'Tamaño por paquete/unidad'
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.shelf_life_days', 'Días de Vida Útil'),
|
||||
name: 'shelf_life_days',
|
||||
type: 'number' as const,
|
||||
placeholder: '30',
|
||||
helpText: 'Vida útil predeterminada en días'
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.is_perishable', '¿Es Perecedero?'),
|
||||
name: 'is_perishable',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: 'false', label: 'No' },
|
||||
{ value: 'true', label: 'Sí' }
|
||||
],
|
||||
defaultValue: 'false',
|
||||
span: 2 // Full width
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Costos y Cantidades',
|
||||
title: t('inventory:sections.purchase_costs', 'Costos de Compra'),
|
||||
icon: Calculator,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
name: 'average_cost',
|
||||
label: t('inventory:fields.standard_cost', 'Costo Estándar'),
|
||||
name: 'standard_cost',
|
||||
type: 'currency' as const,
|
||||
placeholder: '0.00',
|
||||
defaultValue: 0,
|
||||
helpText: t('inventory:help.standard_cost', 'Costo objetivo para presupuesto y análisis de variación'),
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El costo no puede ser negativo' : null;
|
||||
return num < 0 ? t('inventory:validation.current_cannot_be_negative', 'El costo no puede ser negativo') : null;
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('inventory:sections.stock_management', 'Gestión de Stock'),
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Umbral Stock Bajo',
|
||||
label: t('inventory:fields.low_stock_threshold', 'Umbral Stock Bajo'),
|
||||
name: 'low_stock_threshold',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
@@ -144,11 +199,11 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
defaultValue: 10,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El umbral debe ser un número positivo' : null;
|
||||
return num < 0 ? t('inventory:validation.min_greater_than_zero', 'El umbral debe ser un número positivo') : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
label: t('inventory:fields.reorder_point', 'Punto de Reorden'),
|
||||
name: 'reorder_point',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
@@ -156,43 +211,41 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
defaultValue: 20,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El punto de reorden debe ser un número positivo' : null;
|
||||
return num < 0 ? t('inventory:validation.min_greater_than_zero', 'El punto de reorden debe ser un número positivo') : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
label: t('inventory:fields.reorder_quantity', 'Cantidad de Reorden'),
|
||||
name: 'reorder_quantity',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
placeholder: '50',
|
||||
defaultValue: 50,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? t('inventory:validation.min_greater_than_zero', 'La cantidad de reorden debe ser mayor a cero') : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.max_stock_level', 'Stock Máximo'),
|
||||
name: 'max_stock_level',
|
||||
type: 'number' as const,
|
||||
placeholder: '100',
|
||||
defaultValue: 100,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El stock máximo debe ser un número positivo' : null;
|
||||
return num < 0 ? t('inventory:validation.min_greater_than_zero', 'El stock máximo debe ser un número positivo') : null;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Adicional',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas',
|
||||
name: 'notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Notas adicionales',
|
||||
span: 2 // Full width
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Crear Nuevo Artículo"
|
||||
subtitle="Agregar un nuevo artículo al inventario"
|
||||
title={t('inventory:forms.add_item', 'Crear Nuevo Artículo')}
|
||||
subtitle={t('inventory:subtitle', 'Agregar un nuevo artículo al inventario')}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse } from '../../../api/types/inventory';
|
||||
import { IngredientResponse, IngredientCategory, ProductCategory, ProductType, UnitOfMeasure } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
@@ -10,6 +11,10 @@ interface ShowInfoModalProps {
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
onSave?: (updatedData: Partial<IngredientResponse>) => Promise<void>;
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch?: boolean;
|
||||
isRefetching?: boolean;
|
||||
onSaveComplete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,8 +26,12 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
onSave
|
||||
onSave,
|
||||
waitForRefetch,
|
||||
isRefetching,
|
||||
onSaveComplete
|
||||
}) => {
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<IngredientResponse>>({});
|
||||
|
||||
@@ -38,29 +47,97 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave(editData);
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
// CRITICAL: Capture editData IMMEDIATELY before any async operations
|
||||
// This prevents race conditions where editData might be cleared by React state updates
|
||||
const dataToSave = { ...editData };
|
||||
|
||||
// Validate we have data to save
|
||||
if (Object.keys(dataToSave).length === 0) {
|
||||
console.error('ShowInfoModal: No edit data to save');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('ShowInfoModal: Saving data:', dataToSave);
|
||||
await onSave(dataToSave);
|
||||
|
||||
// Note: Don't clear edit state here - let EditViewModal handle mode switching
|
||||
// after refetch completes if waitForRefetch is enabled
|
||||
if (!waitForRefetch) {
|
||||
setIsEditing(false);
|
||||
setEditData({});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reset edit state when mode changes to view (after refetch completes)
|
||||
React.useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditData({});
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Status configuration based on item status (not stock)
|
||||
const statusConfig = {
|
||||
color: ingredient.is_active ? statusColors.normal.primary : statusColors.cancelled.primary,
|
||||
text: ingredient.is_active ? 'Activo' : 'Inactivo',
|
||||
text: ingredient.is_active ? t('common:status.active') : t('common:status.inactive'),
|
||||
icon: ingredient.is_active ? CheckCircle : AlertTriangle,
|
||||
isCritical: !ingredient.is_active
|
||||
};
|
||||
|
||||
const currentData = isEditing ? editData : ingredient;
|
||||
|
||||
// Helper function to translate enum values
|
||||
const translateEnum = (enumType: string, value: string | undefined) => {
|
||||
if (!value) return '';
|
||||
return t(`enums.${enumType}.${value}`, { defaultValue: value });
|
||||
};
|
||||
|
||||
// Helper to get translated category (falls back to common if not in inventory)
|
||||
const getTranslatedCategory = (category: string | undefined) => {
|
||||
if (!category) return '';
|
||||
// Try inventory namespace first, then common namespace
|
||||
const translated = t(`enums.ingredient_category.${category}`, { defaultValue: '' });
|
||||
return translated || t(`common:categories.${category}`, { defaultValue: category });
|
||||
};
|
||||
|
||||
// Get category options (combining ingredient and product categories)
|
||||
const getCategoryOptions = () => {
|
||||
const ingredientCategories = Object.values(IngredientCategory).map(value => ({
|
||||
value,
|
||||
label: t(`enums.ingredient_category.${value}`)
|
||||
}));
|
||||
|
||||
const productCategories = Object.values(ProductCategory).map(value => ({
|
||||
value,
|
||||
label: t(`enums.product_category.${value}`)
|
||||
}));
|
||||
|
||||
return [...ingredientCategories, ...productCategories].sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
// Get product type options
|
||||
const getProductTypeOptions = () => {
|
||||
return Object.values(ProductType).map(value => ({
|
||||
value,
|
||||
label: t(`enums.product_type.${value}`)
|
||||
}));
|
||||
};
|
||||
|
||||
// Get unit of measure options
|
||||
const getUnitOfMeasureOptions = () => {
|
||||
return Object.values(UnitOfMeasure).map(value => ({
|
||||
value,
|
||||
label: t(`enums.unit_of_measure.${value}`)
|
||||
}));
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
title: t('fields.basic_info', { defaultValue: 'Información Básica' }),
|
||||
icon: Info,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
label: t('common:fields.name'),
|
||||
value: currentData.name || '',
|
||||
highlight: true,
|
||||
span: 2 as const,
|
||||
@@ -68,92 +145,99 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
label: t('common:fields.description'),
|
||||
value: currentData.description || '',
|
||||
span: 2 as const,
|
||||
editable: true,
|
||||
placeholder: 'Descripción del producto'
|
||||
placeholder: t('common:fields.description')
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: currentData.category || '',
|
||||
label: t('labels.ingredient_category'),
|
||||
value: isEditing ? currentData.category || '' : getTranslatedCategory(currentData.category),
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
required: true
|
||||
required: true,
|
||||
type: 'select' as const,
|
||||
options: getCategoryOptions(),
|
||||
placeholder: t('labels.ingredient_category')
|
||||
},
|
||||
{
|
||||
label: 'Subcategoría',
|
||||
label: t('fields.subcategory', { defaultValue: 'Subcategoría' }),
|
||||
value: currentData.subcategory || '',
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
placeholder: 'Subcategoría'
|
||||
placeholder: t('fields.subcategory', { defaultValue: 'Subcategoría' })
|
||||
},
|
||||
{
|
||||
label: 'Marca',
|
||||
label: t('fields.brand', { defaultValue: 'Marca' }),
|
||||
value: currentData.brand || '',
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
placeholder: 'Marca del producto'
|
||||
placeholder: t('fields.brand', { defaultValue: 'Marca del producto' })
|
||||
},
|
||||
{
|
||||
label: 'Tipo de Producto',
|
||||
value: currentData.product_type || '',
|
||||
label: t('labels.product_type'),
|
||||
value: isEditing ? currentData.product_type || '' : translateEnum('product_type', currentData.product_type),
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
placeholder: 'Tipo de producto'
|
||||
type: 'select' as const,
|
||||
options: getProductTypeOptions(),
|
||||
placeholder: t('labels.product_type')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Especificaciones',
|
||||
title: t('fields.specifications', { defaultValue: 'Especificaciones' }),
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Unidad de Medida',
|
||||
value: currentData.unit_of_measure || '',
|
||||
label: t('labels.unit_of_measure'),
|
||||
value: isEditing ? currentData.unit_of_measure || '' : translateEnum('unit_of_measure', currentData.unit_of_measure),
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'kg, litros, unidades, etc.'
|
||||
type: 'select' as const,
|
||||
options: getUnitOfMeasureOptions(),
|
||||
placeholder: t('labels.unit_of_measure')
|
||||
},
|
||||
{
|
||||
label: 'Tamaño del Paquete',
|
||||
label: t('fields.package_size', { defaultValue: 'Tamaño del Paquete' }),
|
||||
value: currentData.package_size || '',
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'number' as const,
|
||||
placeholder: 'Tamaño del paquete'
|
||||
placeholder: t('fields.package_size', { defaultValue: 'Tamaño del paquete' })
|
||||
},
|
||||
{
|
||||
label: 'Es Perecedero',
|
||||
value: currentData.is_perishable ? 'Sí' : 'No',
|
||||
label: t('fields.is_perishable', { defaultValue: 'Es Perecedero' }),
|
||||
value: isEditing ? String(currentData.is_perishable) : (currentData.is_perishable ? t('common:modals.actions.yes') : t('common:modals.actions.no')),
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
{ label: t('common:modals.actions.yes'), value: 'true' },
|
||||
{ label: t('common:modals.actions.no'), value: 'false' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Es de Temporada',
|
||||
value: currentData.is_seasonal ? 'Sí' : 'No',
|
||||
label: t('fields.is_seasonal', { defaultValue: 'Es de Temporada' }),
|
||||
value: isEditing ? String(currentData.is_seasonal) : (currentData.is_seasonal ? t('common:modals.actions.yes') : t('common:modals.actions.no')),
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
{ label: t('common:modals.actions.yes'), value: 'true' },
|
||||
{ label: t('common:modals.actions.no'), value: 'false' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Costos y Precios',
|
||||
title: t('fields.costs_and_pricing', { defaultValue: 'Costos y Precios' }),
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
label: t('fields.average_cost', { defaultValue: 'Costo Promedio' }),
|
||||
value: Number(currentData.average_cost) || 0,
|
||||
type: 'currency' as const,
|
||||
span: 1 as const,
|
||||
@@ -161,7 +245,7 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: 'Último Precio de Compra',
|
||||
label: t('fields.last_purchase_price', { defaultValue: 'Último Precio de Compra' }),
|
||||
value: Number(currentData.last_purchase_price) || 0,
|
||||
type: 'currency' as const,
|
||||
span: 1 as const,
|
||||
@@ -169,7 +253,7 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: 'Costo Estándar',
|
||||
label: t('fields.standard_cost', { defaultValue: 'Costo Estándar' }),
|
||||
value: Number(currentData.standard_cost) || 0,
|
||||
type: 'currency' as const,
|
||||
span: 2 as const,
|
||||
@@ -179,70 +263,47 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Parámetros de Inventario',
|
||||
title: t('fields.inventory_parameters', { defaultValue: 'Parámetros de Inventario' }),
|
||||
icon: TrendingUp,
|
||||
fields: [
|
||||
{
|
||||
label: 'Umbral Stock Bajo',
|
||||
label: t('fields.low_stock_threshold', { defaultValue: 'Umbral Stock Bajo' }),
|
||||
value: currentData.low_stock_threshold || 0,
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'number' as const,
|
||||
placeholder: 'Cantidad mínima antes de alerta'
|
||||
placeholder: t('fields.low_stock_threshold', { defaultValue: 'Cantidad mínima antes de alerta' })
|
||||
},
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
label: t('fields.reorder_point', { defaultValue: 'Punto de Reorden' }),
|
||||
value: currentData.reorder_point || 0,
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'number' as const,
|
||||
placeholder: 'Punto para reordenar'
|
||||
placeholder: t('fields.reorder_point', { defaultValue: 'Punto para reordenar' })
|
||||
},
|
||||
{
|
||||
label: 'Cantidad de Reorden',
|
||||
label: t('fields.reorder_quantity', { defaultValue: 'Cantidad de Reorden' }),
|
||||
value: currentData.reorder_quantity || 0,
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'number' as const,
|
||||
placeholder: 'Cantidad a reordenar'
|
||||
placeholder: t('fields.reorder_quantity', { defaultValue: 'Cantidad a reordenar' })
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
label: t('fields.max_stock_level', { defaultValue: 'Stock Máximo' }),
|
||||
value: currentData.max_stock_level || '',
|
||||
span: 1 as const,
|
||||
editable: true,
|
||||
type: 'number' as const,
|
||||
placeholder: 'Cantidad máxima permitida'
|
||||
placeholder: t('fields.max_stock_level', { defaultValue: 'Cantidad máxima permitida' })
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Actions based on edit mode
|
||||
const actions = [];
|
||||
if (isEditing) {
|
||||
actions.push(
|
||||
{
|
||||
label: 'Cancelar',
|
||||
icon: X,
|
||||
variant: 'outline' as const,
|
||||
onClick: handleCancel
|
||||
},
|
||||
{
|
||||
label: 'Guardar',
|
||||
icon: Save,
|
||||
variant: 'primary' as const,
|
||||
onClick: handleSave
|
||||
}
|
||||
);
|
||||
} else if (onSave) {
|
||||
actions.push({
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
variant: 'primary' as const,
|
||||
onClick: handleEdit
|
||||
});
|
||||
}
|
||||
// Note: We'll let EditViewModal handle default actions (Edit/Save/Cancel)
|
||||
// by setting showDefaultActions=true instead of providing custom actions
|
||||
|
||||
// Handle field changes
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
@@ -263,15 +324,14 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex];
|
||||
if (!fieldName) return;
|
||||
|
||||
let processedValue = value;
|
||||
let processedValue: string | number | boolean = value;
|
||||
|
||||
// Handle boolean fields
|
||||
if (fieldName === 'is_perishable' || fieldName === 'is_seasonal') {
|
||||
processedValue = value === 'true';
|
||||
}
|
||||
|
||||
// Handle numeric fields
|
||||
if (fieldName === 'package_size' || fieldName.includes('cost') || fieldName.includes('price') ||
|
||||
else if (fieldName === 'package_size' || fieldName.includes('cost') || fieldName.includes('price') ||
|
||||
fieldName.includes('threshold') || fieldName.includes('point') || fieldName.includes('quantity') ||
|
||||
fieldName.includes('level')) {
|
||||
processedValue = Number(value) || 0;
|
||||
@@ -288,14 +348,25 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={isEditing ? "edit" : "view"}
|
||||
title={`${isEditing ? 'Editar' : 'Detalles'}: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • Información del artículo`}
|
||||
onModeChange={(mode) => {
|
||||
const isEditMode = mode === 'edit';
|
||||
setIsEditing(isEditMode);
|
||||
if (isEditMode) {
|
||||
setEditData(ingredient); // Populate editData when entering edit mode
|
||||
}
|
||||
}}
|
||||
title={ingredient.name}
|
||||
subtitle={getTranslatedCategory(ingredient.category)}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
showDefaultActions={false}
|
||||
actions={actions}
|
||||
showDefaultActions={true}
|
||||
onFieldChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
waitForRefetch={waitForRefetch}
|
||||
isRefetching={isRefetching}
|
||||
onSaveComplete={onSaveComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Clock, TrendingDown, Package, AlertCircle, RotateCcw, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
@@ -24,10 +25,55 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
movements = [],
|
||||
loading = false
|
||||
}) => {
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
|
||||
// Helper function to get translated category display name
|
||||
const getCategoryDisplayName = (category?: string | null): string => {
|
||||
if (!category) return t('categories.all', 'Sin categoría');
|
||||
|
||||
// Try ingredient category translation first
|
||||
const ingredientTranslation = t(`enums.ingredient_category.${category}`, { defaultValue: '' });
|
||||
if (ingredientTranslation) return ingredientTranslation;
|
||||
|
||||
// Try product category translation
|
||||
const productTranslation = t(`enums.product_category.${category}`, { defaultValue: '' });
|
||||
if (productTranslation) return productTranslation;
|
||||
|
||||
// Fallback to raw category if no translation found
|
||||
return category;
|
||||
};
|
||||
|
||||
// Get movement type display info
|
||||
const getMovementTypeInfo = (type: string, quantity: number) => {
|
||||
const isPositive = quantity > 0;
|
||||
const absQuantity = Math.abs(quantity);
|
||||
|
||||
// Determine if movement should be positive or negative based on type
|
||||
// Some movement types have fixed direction regardless of quantity sign
|
||||
let isPositive: boolean;
|
||||
let displayQuantity: string;
|
||||
|
||||
switch (type) {
|
||||
case 'PURCHASE':
|
||||
case 'INITIAL_STOCK':
|
||||
isPositive = true;
|
||||
displayQuantity = `+${absQuantity}`;
|
||||
break;
|
||||
case 'PRODUCTION_USE':
|
||||
case 'WASTE':
|
||||
isPositive = false;
|
||||
displayQuantity = `-${absQuantity}`;
|
||||
break;
|
||||
case 'ADJUSTMENT':
|
||||
case 'TRANSFORMATION':
|
||||
// For these types, follow the actual quantity direction
|
||||
isPositive = quantity > 0;
|
||||
displayQuantity = quantity > 0 ? `+${absQuantity}` : `-${absQuantity}`;
|
||||
break;
|
||||
default:
|
||||
// For any other types, follow the quantity direction
|
||||
isPositive = quantity > 0;
|
||||
displayQuantity = quantity > 0 ? `+${absQuantity}` : `-${absQuantity}`;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'PURCHASE':
|
||||
@@ -36,7 +82,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
icon: Package,
|
||||
color: statusColors.completed.primary,
|
||||
isPositive: true,
|
||||
quantity: `+${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
case 'PRODUCTION_USE':
|
||||
return {
|
||||
@@ -44,7 +90,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
icon: TrendingDown,
|
||||
color: statusColors.pending.primary,
|
||||
isPositive: false,
|
||||
quantity: `-${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
case 'ADJUSTMENT':
|
||||
return {
|
||||
@@ -52,7 +98,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
icon: AlertCircle,
|
||||
color: statusColors.inProgress.primary,
|
||||
isPositive,
|
||||
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
case 'WASTE':
|
||||
return {
|
||||
@@ -60,7 +106,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
icon: X,
|
||||
color: statusColors.out.primary,
|
||||
isPositive: false,
|
||||
quantity: `-${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
case 'TRANSFORMATION':
|
||||
return {
|
||||
@@ -68,7 +114,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
icon: RotateCcw,
|
||||
color: statusColors.low.primary,
|
||||
isPositive,
|
||||
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
case 'INITIAL_STOCK':
|
||||
return {
|
||||
@@ -76,15 +122,15 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
icon: Package,
|
||||
color: statusColors.normal.primary,
|
||||
isPositive: true,
|
||||
quantity: `+${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type: 'Otro',
|
||||
type: t('enums.stock_movement_type.OTHER', 'Otro'),
|
||||
icon: Package,
|
||||
color: statusColors.other.primary,
|
||||
isPositive,
|
||||
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
|
||||
quantity: displayQuantity
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -218,8 +264,8 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={`Historial de Stock: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • ${movements.length} movimientos registrados`}
|
||||
title={ingredient.name}
|
||||
subtitle={`${getCategoryDisplayName(ingredient.category)} • ${movements.length} movimientos`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
@@ -229,4 +275,4 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default StockHistoryModal;
|
||||
export default StockHistoryModal;
|
||||
|
||||
@@ -326,8 +326,8 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'edit' ? 'Editar Sistema POS' : 'Agregar Sistema POS'}
|
||||
subtitle={mode === 'edit' ? 'Actualiza la configuración del sistema POS' : 'Configura un nuevo sistema POS para sincronizar ventas e inventario'}
|
||||
title="Configuración de Sistema POS"
|
||||
subtitle={mode === 'edit' ? 'Actualiza la configuración' : 'Configura un nuevo sistema para sincronizar ventas e inventario'}
|
||||
statusIndicator={{
|
||||
color: statusColors.inProgress.primary,
|
||||
text: mode === 'edit' ? 'Edición' : 'Nueva Configuración',
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Package, Clock, Users, AlertCircle, Plus } from 'lucide-react';
|
||||
import { Package, Clock, Users, AlertCircle, Plus, ClipboardCheck } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import {
|
||||
ProductionBatchCreate,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../api/types/production';
|
||||
import { Card } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { ProcessStage } from '../../../api/types/qualityTemplates';
|
||||
import type { RecipeResponse } from '../../../api/types/recipes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { recipesService } from '../../../api/services/recipes';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
@@ -30,11 +35,43 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
|
||||
const [loadingRecipe, setLoadingRecipe] = useState(false);
|
||||
|
||||
// API Data
|
||||
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
|
||||
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
|
||||
|
||||
// Stage labels for display
|
||||
const STAGE_LABELS: Record<ProcessStage, string> = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
[ProcessStage.PROOFING]: 'Fermentación',
|
||||
[ProcessStage.SHAPING]: 'Formado',
|
||||
[ProcessStage.BAKING]: 'Horneado',
|
||||
[ProcessStage.COOLING]: 'Enfriado',
|
||||
[ProcessStage.PACKAGING]: 'Empaquetado',
|
||||
[ProcessStage.FINISHING]: 'Acabado'
|
||||
};
|
||||
|
||||
// Load recipe details when recipe is selected
|
||||
const handleRecipeChange = async (recipeId: string) => {
|
||||
if (!recipeId) {
|
||||
setSelectedRecipe(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRecipe(true);
|
||||
try {
|
||||
const recipe = await recipesService.getRecipe(tenantId, recipeId);
|
||||
setSelectedRecipe(recipe);
|
||||
} catch (error) {
|
||||
console.error('Error loading recipe:', error);
|
||||
setSelectedRecipe(null);
|
||||
} finally {
|
||||
setLoadingRecipe(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter finished products (ingredients that are finished products)
|
||||
const finishedProducts = useMemo(() => ingredients.filter(ing =>
|
||||
ing.type === 'finished_product' ||
|
||||
@@ -141,7 +178,8 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
type: 'select' as const,
|
||||
options: recipeOptions,
|
||||
placeholder: 'Seleccionar receta...',
|
||||
span: 2
|
||||
span: 2,
|
||||
onChange: (value: string) => handleRecipeChange(value)
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
@@ -252,6 +290,62 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
}
|
||||
], [productOptions, recipeOptions, t]);
|
||||
|
||||
// Quality Requirements Preview Component
|
||||
const qualityRequirementsPreview = selectedRecipe && (
|
||||
<Card className="mt-4 p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
||||
<ClipboardCheck className="w-5 h-5 text-blue-600" />
|
||||
Controles de Calidad Requeridos
|
||||
</h4>
|
||||
{selectedRecipe.quality_check_configuration && selectedRecipe.quality_check_configuration.stages ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(selectedRecipe.quality_check_configuration.stages).map(([stage, config]: [string, any]) => {
|
||||
if (!config.template_ids || config.template_ids.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={stage} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="info">{STAGE_LABELS[stage as ProcessStage]}</Badge>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{config.template_ids.length} control{config.template_ids.length > 1 ? 'es' : ''}
|
||||
</span>
|
||||
{config.blocking && (
|
||||
<Badge variant="warning" size="sm">Bloqueante</Badge>
|
||||
)}
|
||||
{config.is_required && (
|
||||
<Badge variant="error" size="sm">Requerido</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-3 pt-3 border-t border-blue-200">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Umbral de calidad mínimo:</span>{' '}
|
||||
{selectedRecipe.quality_check_configuration.overall_quality_threshold || 7.0}/10
|
||||
</p>
|
||||
{selectedRecipe.quality_check_configuration.critical_stage_blocking && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<span className="font-medium text-orange-600">⚠️ Bloqueo crítico activado:</span>{' '}
|
||||
El lote no puede avanzar si fallan checks críticos
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<p className="mb-2">Esta receta no tiene controles de calidad configurados.</p>
|
||||
<a
|
||||
href={`/app/database/recipes`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Configurar controles de calidad →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
@@ -263,6 +357,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
size="xl"
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
additionalContent={qualityRequirementsPreview}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -219,14 +219,14 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
name: 'description',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Describe qué evalúa esta plantilla de calidad',
|
||||
span: 2
|
||||
span: 2 as const
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones para el Personal',
|
||||
name: 'instructions',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones detalladas para realizar este control de calidad',
|
||||
span: 2,
|
||||
span: 2 as const,
|
||||
helpText: 'Pasos específicos que debe seguir el operario'
|
||||
}
|
||||
]
|
||||
@@ -282,7 +282,7 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
type: 'text' as const,
|
||||
placeholder: 'Se seleccionarán las etapas donde aplicar',
|
||||
helpText: 'Las etapas se configuran mediante la selección múltiple',
|
||||
span: 2
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -297,7 +297,7 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
options: recipeOptions,
|
||||
placeholder: 'Seleccionar receta para asociar automáticamente',
|
||||
helpText: 'Si seleccionas una receta, esta plantilla se aplicará automáticamente a sus lotes de producción',
|
||||
span: 2
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -322,20 +322,20 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
name: 'is_active',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: true, label: 'Activa' },
|
||||
{ value: false, label: 'Inactiva' }
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' }
|
||||
],
|
||||
defaultValue: true
|
||||
defaultValue: 'true'
|
||||
},
|
||||
{
|
||||
label: 'Control Requerido',
|
||||
name: 'is_required',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: false, label: 'Opcional' },
|
||||
{ value: true, label: 'Requerido' }
|
||||
{ value: 'false', label: 'Opcional' },
|
||||
{ value: 'true', label: 'Requerido' }
|
||||
],
|
||||
defaultValue: false,
|
||||
defaultValue: 'false',
|
||||
helpText: 'Si es requerido, debe completarse obligatoriamente'
|
||||
},
|
||||
{
|
||||
@@ -343,10 +343,10 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
name: 'is_critical',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: false, label: 'Normal' },
|
||||
{ value: true, label: 'Crítico' }
|
||||
{ value: 'false', label: 'Normal' },
|
||||
{ value: 'true', label: 'Crítico' }
|
||||
],
|
||||
defaultValue: false,
|
||||
defaultValue: 'false',
|
||||
helpText: 'Si es crítico, bloquea la producción si falla'
|
||||
}
|
||||
]
|
||||
@@ -378,4 +378,4 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateQualityTemplateModal;
|
||||
export default CreateQualityTemplateModal;
|
||||
|
||||
@@ -18,25 +18,25 @@ interface EditQualityTemplateModalProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const QUALITY_CHECK_TYPE_OPTIONS = [
|
||||
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
|
||||
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
|
||||
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
|
||||
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
|
||||
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
|
||||
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
|
||||
];
|
||||
|
||||
const PROCESS_STAGE_OPTIONS = [
|
||||
{ value: ProcessStage.MIXING, label: 'Mezclado' },
|
||||
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
|
||||
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
|
||||
{ value: ProcessStage.SHAPING, label: 'Formado' },
|
||||
{ value: ProcessStage.BAKING, label: 'Horneado' },
|
||||
{ value: ProcessStage.COOLING, label: 'Enfriado' },
|
||||
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
|
||||
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
|
||||
{ value: ProcessStage.FINISHING, label: 'Acabado' }
|
||||
];
|
||||
|
||||
const QUALITY_CHECK_TYPE_OPTIONS = [
|
||||
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
|
||||
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
|
||||
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
|
||||
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
|
||||
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
|
||||
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
|
||||
];
|
||||
|
||||
const CATEGORY_OPTIONS_KEYS = [
|
||||
{ value: '', key: '' },
|
||||
{ value: 'appearance', key: 'appearance' },
|
||||
@@ -104,13 +104,13 @@ export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> =
|
||||
);
|
||||
|
||||
// Helper function to get translated category label
|
||||
const getCategoryLabel = (category: string | null | undefined): string => {
|
||||
if (!category) return 'Sin categoría';
|
||||
const getCategoryLabel = (category: string | null | undefined): string => {
|
||||
if (!category) return t('production.quality.categories.appearance', 'Sin categoría');
|
||||
const translationKey = `production.quality.categories.${category}`;
|
||||
const translated = t(translationKey);
|
||||
// If translation is same as key, it means no translation exists, return the original
|
||||
return translated === translationKey ? category : translated;
|
||||
};
|
||||
};
|
||||
|
||||
// Build category options with translations
|
||||
const getCategoryOptions = () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ProductionBatchResponse } from '../../../api/types/production';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useQualityTemplatesForStage, useExecuteQualityCheck } from '../../../api/hooks/qualityTemplates';
|
||||
import { ProcessStage, type QualityCheckTemplate, type QualityCheckExecutionRequest } from '../../../api/types/qualityTemplates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface QualityCheckModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -695,4 +696,4 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityCheckModal;
|
||||
export default QualityCheckModal;
|
||||
|
||||
@@ -51,50 +51,50 @@ interface QualityTemplateManagerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QUALITY_CHECK_TYPE_CONFIG = {
|
||||
const QUALITY_CHECK_TYPE_CONFIG = (t: (key: string) => string) => ({
|
||||
[QualityCheckType.VISUAL]: {
|
||||
icon: Eye,
|
||||
label: 'Visual',
|
||||
label: t('production.quality.check_types.visual', 'Visual'),
|
||||
color: 'bg-blue-500',
|
||||
description: 'Inspección visual'
|
||||
description: t('production.quality.check_types.visual_description', 'Inspección visual')
|
||||
},
|
||||
[QualityCheckType.MEASUREMENT]: {
|
||||
icon: Settings,
|
||||
label: 'Medición',
|
||||
label: t('production.quality.check_types.measurement', 'Medición'),
|
||||
color: 'bg-green-500',
|
||||
description: 'Mediciones precisas'
|
||||
description: t('production.quality.check_types.measurement_description', 'Mediciones precisas')
|
||||
},
|
||||
[QualityCheckType.TEMPERATURE]: {
|
||||
icon: Thermometer,
|
||||
label: 'Temperatura',
|
||||
label: t('production.quality.check_types.temperature', 'Temperatura'),
|
||||
color: 'bg-red-500',
|
||||
description: 'Control de temperatura'
|
||||
description: t('production.quality.check_types.temperature_description', 'Control de temperatura')
|
||||
},
|
||||
[QualityCheckType.WEIGHT]: {
|
||||
icon: Scale,
|
||||
label: 'Peso',
|
||||
label: t('production.quality.check_types.weight', 'Peso'),
|
||||
color: 'bg-purple-500',
|
||||
description: 'Control de peso'
|
||||
description: t('production.quality.check_types.weight_description', 'Control de peso')
|
||||
},
|
||||
[QualityCheckType.BOOLEAN]: {
|
||||
icon: CheckCircle,
|
||||
label: 'Sí/No',
|
||||
label: t('production.quality.check_types.boolean', 'Sí/No'),
|
||||
color: 'bg-gray-500',
|
||||
description: 'Verificación binaria'
|
||||
description: t('production.quality.check_types.boolean_description', 'Verificación binaria')
|
||||
},
|
||||
[QualityCheckType.TIMING]: {
|
||||
icon: Timer,
|
||||
label: 'Tiempo',
|
||||
label: t('production.quality.check_types.timing', 'Tiempo'),
|
||||
color: 'bg-orange-500',
|
||||
description: 'Control de tiempo'
|
||||
description: t('production.quality.check_types.timing_description', 'Control de tiempo')
|
||||
},
|
||||
[QualityCheckType.CHECKLIST]: {
|
||||
icon: FileCheck,
|
||||
label: 'Lista de verificación',
|
||||
label: t('production.quality.check_types.checklist', 'Lista de verificación'),
|
||||
color: 'bg-indigo-500',
|
||||
description: 'Checklist de verificación'
|
||||
description: t('production.quality.check_types.checklist_description', 'Checklist de verificación')
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PROCESS_STAGE_LABELS = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
@@ -166,11 +166,22 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
const templateStats = useMemo(() => {
|
||||
const templates = templatesData?.templates || [];
|
||||
|
||||
// Calculate unique categories
|
||||
const uniqueCategories = new Set(templates.map(t => t.category).filter(Boolean));
|
||||
|
||||
// Calculate average weight
|
||||
const activeTemplates = templates.filter(t => t.is_active);
|
||||
const averageWeight = activeTemplates.length > 0
|
||||
? activeTemplates.reduce((sum, t) => sum + (t.weight || 0), 0) / activeTemplates.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: templates.length,
|
||||
active: templates.filter(t => t.is_active).length,
|
||||
critical: templates.filter(t => t.is_critical).length,
|
||||
required: templates.filter(t => t.is_required).length,
|
||||
categories: uniqueCategories.size,
|
||||
averageWeight: parseFloat(averageWeight.toFixed(1)),
|
||||
byType: Object.values(QualityCheckType).map(type => ({
|
||||
type,
|
||||
count: templates.filter(t => t.check_type === type).length
|
||||
@@ -221,8 +232,9 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
|
||||
const typeConfig = QUALITY_CHECK_TYPE_CONFIG[template.check_type];
|
||||
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
|
||||
const typeConfigs = QUALITY_CHECK_TYPE_CONFIG(t);
|
||||
const typeConfig = typeConfigs[template.check_type];
|
||||
|
||||
return {
|
||||
color: template.is_active ? typeConfig.color : '#6b7280',
|
||||
@@ -298,9 +310,21 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
value: templateStats.required,
|
||||
variant: 'warning',
|
||||
icon: Tag
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
value: templateStats.categories,
|
||||
variant: 'info',
|
||||
icon: Tag
|
||||
},
|
||||
{
|
||||
title: 'Peso Promedio',
|
||||
value: templateStats.averageWeight,
|
||||
variant: 'info',
|
||||
icon: Scale
|
||||
}
|
||||
]}
|
||||
columns={4}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
@@ -316,7 +340,7 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
value: selectedCheckType,
|
||||
onChange: (value) => setSelectedCheckType(value as QualityCheckType | ''),
|
||||
placeholder: 'Todos los tipos',
|
||||
options: Object.entries(QUALITY_CHECK_TYPE_CONFIG).map(([type, config]) => ({
|
||||
options: Object.entries(QUALITY_CHECK_TYPE_CONFIG(t)).map(([type, config]) => ({
|
||||
value: type,
|
||||
label: config.label
|
||||
}))
|
||||
@@ -471,4 +495,4 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityTemplateManager;
|
||||
export default QualityTemplateManager;
|
||||
|
||||
@@ -256,6 +256,13 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Debug: Log the ingredients array to understand what's being submitted
|
||||
console.log('=== Recipe Save Debug ===');
|
||||
console.log('formData.ingredients:', JSON.stringify(formData.ingredients, null, 2));
|
||||
console.log('Type of formData.ingredients:', typeof formData.ingredients);
|
||||
console.log('Is array:', Array.isArray(formData.ingredients));
|
||||
console.log('========================');
|
||||
|
||||
// Validate ingredients
|
||||
const ingredientError = validateIngredients(formData.ingredients || []);
|
||||
if (ingredientError) {
|
||||
@@ -284,6 +291,19 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
(Number(formData.cook_time_minutes) || 0) +
|
||||
(Number(formData.rest_time_minutes) || 0);
|
||||
|
||||
// Filter and validate ingredients before creating the recipe
|
||||
const validIngredients = (formData.ingredients || [])
|
||||
.filter((ing: RecipeIngredientCreate) => ing.ingredient_id && ing.ingredient_id.trim() !== '')
|
||||
.map((ing: RecipeIngredientCreate, index: number) => ({
|
||||
...ing,
|
||||
ingredient_order: index + 1
|
||||
}));
|
||||
|
||||
// Ensure we have at least one valid ingredient
|
||||
if (validIngredients.length === 0) {
|
||||
throw new Error('Debe agregar al menos un ingrediente válido con un ingrediente seleccionado');
|
||||
}
|
||||
|
||||
const recipeData: RecipeCreate = {
|
||||
name: formData.name,
|
||||
recipe_code: recipeCode,
|
||||
@@ -300,13 +320,10 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
total_time_minutes: totalTime,
|
||||
rest_time_minutes: Number(formData.rest_time_minutes) || 0,
|
||||
target_margin_percentage: Number(formData.target_margin_percentage) || 30,
|
||||
instructions: formData.preparation_notes ? { steps: formData.preparation_notes } : null,
|
||||
instructions: formData.instructions_text ? { steps: formData.instructions_text } : null,
|
||||
preparation_notes: formData.preparation_notes || '',
|
||||
storage_instructions: formData.storage_instructions || '',
|
||||
quality_standards: formData.quality_standards || '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: Number(formData.serves_count) || 1,
|
||||
is_seasonal: formData.is_seasonal || false,
|
||||
season_start_month: formData.is_seasonal ? Number(formData.season_start_month) : undefined,
|
||||
@@ -320,14 +337,16 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
allergen_info: formData.allergen_info_text ? { allergens: formData.allergen_info_text.split(',').map((a: string) => a.trim()) } : null,
|
||||
dietary_tags: formData.dietary_tags_text ? { tags: formData.dietary_tags_text.split(',').map((t: string) => t.trim()) } : null,
|
||||
nutritional_info: formData.nutritional_info_text ? { info: formData.nutritional_info_text } : null,
|
||||
// Use the ingredients from form data
|
||||
ingredients: (formData.ingredients || []).filter((ing: RecipeIngredientCreate) => ing.ingredient_id.trim() !== '')
|
||||
.map((ing: RecipeIngredientCreate, index: number) => ({
|
||||
...ing,
|
||||
ingredient_order: index + 1
|
||||
}))
|
||||
// Use the validated ingredients list
|
||||
ingredients: validIngredients
|
||||
};
|
||||
|
||||
// Debug: Log the final payload before sending to API
|
||||
console.log('=== Final Recipe Payload ===');
|
||||
console.log('recipeData:', JSON.stringify(recipeData, null, 2));
|
||||
console.log('ingredients count:', recipeData.ingredients.length);
|
||||
console.log('===========================');
|
||||
|
||||
if (onCreateRecipe) {
|
||||
await onCreateRecipe(recipeData);
|
||||
}
|
||||
@@ -572,7 +591,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Instrucciones y Calidad',
|
||||
title: 'Instrucciones',
|
||||
icon: FileText,
|
||||
fields: [
|
||||
{
|
||||
@@ -590,14 +609,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
placeholder: 'Como conservar el producto terminado...',
|
||||
span: 2,
|
||||
helpText: 'Condiciones de almacenamiento del producto final'
|
||||
},
|
||||
{
|
||||
label: 'Estándares de calidad',
|
||||
name: 'quality_standards',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Criterios de calidad que debe cumplir...',
|
||||
span: 2,
|
||||
helpText: 'Criterios que debe cumplir el producto final'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -621,6 +632,21 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Instrucciones Detalladas',
|
||||
icon: FileText,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Instrucciones de preparación',
|
||||
name: 'instructions_text',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Describir paso a paso el proceso de elaboración...',
|
||||
helpText: 'Instrucciones detalladas para la preparación',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Nutricional',
|
||||
icon: Package,
|
||||
|
||||
376
frontend/src/components/domain/recipes/DeleteRecipeModal.tsx
Normal file
376
frontend/src/components/domain/recipes/DeleteRecipeModal.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import { RecipeResponse, RecipeDeletionSummary } from '../../../api/types/recipes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useRecipeDeletionSummary } from '../../../api/hooks/recipes';
|
||||
|
||||
type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
interface DeleteRecipeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
recipe: RecipeResponse | null;
|
||||
onSoftDelete: (recipeId: string) => Promise<void>;
|
||||
onHardDelete: (recipeId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for recipe deletion with soft/hard delete options
|
||||
* - Soft delete: Archive recipe (reversible)
|
||||
* - Hard delete: Permanent deletion with dependency checking
|
||||
*/
|
||||
export const DeleteRecipeModal: React.FC<DeleteRecipeModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
recipe,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['recipes', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionComplete, setDeletionComplete] = useState(false);
|
||||
|
||||
// Fetch deletion summary when modal opens for hard delete
|
||||
const { data: deletionSummary, isLoading: summaryLoading } = useRecipeDeletionSummary(
|
||||
currentTenant?.id || '',
|
||||
recipe?.id || '',
|
||||
{
|
||||
enabled: isOpen && !!recipe && selectedMode === 'hard' && showConfirmation,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionComplete(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!recipe) return null;
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard') {
|
||||
await onHardDelete(recipe.id);
|
||||
} else {
|
||||
await onSoftDelete(recipe.id);
|
||||
}
|
||||
setDeletionComplete(true);
|
||||
// Auto-close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipe:', error);
|
||||
// Error handling is done by parent component
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionComplete(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR';
|
||||
|
||||
// Show deletion success
|
||||
if (deletionComplete) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{selectedMode === 'hard'
|
||||
? t('recipes:delete.success_hard_title', 'Receta Eliminada')
|
||||
: t('recipes:delete.success_soft_title', 'Receta Archivada')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{selectedMode === 'hard'
|
||||
? t('recipes:delete.recipe_deleted', { name: recipe.name })
|
||||
: t('recipes:delete.recipe_archived', { name: recipe.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
const canDelete = !isHardDelete || (deletionSummary?.can_delete !== false);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete
|
||||
? t('recipes:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
|
||||
: t('recipes:delete.confirm_soft_title', 'Confirmación de Archivo')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<>
|
||||
{summaryLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--text-primary)]"></div>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.checking_dependencies', 'Verificando dependencias...')}
|
||||
</p>
|
||||
</div>
|
||||
) : deletionSummary && !canDelete ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<p className="font-medium text-red-700 dark:text-red-400 mb-2">
|
||||
{t('recipes:delete.cannot_delete', '⚠️ No se puede eliminar esta receta')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
|
||||
{deletionSummary.warnings.map((warning, idx) => (
|
||||
<li key={idx}>• {warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('recipes:delete.hard_warning_1', '• La receta y toda su información')}</li>
|
||||
<li>{t('recipes:delete.hard_warning_2', '• Todos los ingredientes asociados')}</li>
|
||||
{deletionSummary && (
|
||||
<>
|
||||
{deletionSummary.production_batches_count > 0 && (
|
||||
<li>{t('recipes:delete.batches_affected', { count: deletionSummary.production_batches_count }, `• ${deletionSummary.production_batches_count} lotes de producción`)}</li>
|
||||
)}
|
||||
{deletionSummary.affected_orders_count > 0 && (
|
||||
<li>{t('recipes:delete.orders_affected', { count: deletionSummary.affected_orders_count }, `• ${deletionSummary.affected_orders_count} pedidos afectados`)}</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('recipes:delete.soft_info_title', 'ℹ️ Esta acción archivará la receta:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('recipes:delete.soft_info_1', '• La receta cambiará a estado ARCHIVADO')}</li>
|
||||
<li>{t('recipes:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
|
||||
<li>{t('recipes:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
|
||||
<li>{t('recipes:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && canDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('recipes:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
|
||||
placeholder={t('recipes:delete.type_placeholder', 'Escriba ELIMINAR')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common:back', 'Volver')}
|
||||
</Button>
|
||||
{(!isHardDelete || canDelete) && (
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading || summaryLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete
|
||||
? t('recipes:delete.confirm_hard', 'Eliminar Permanentemente')
|
||||
: t('recipes:delete.confirm_soft', 'Archivar Receta')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{t('recipes:delete.title', 'Eliminar Receta')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
{t('recipes:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('recipes:delete.soft_delete', 'Archivar (Recomendado)')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{t('recipes:delete.hard_delete', 'Eliminar Permanentemente')}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common:continue', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -59,7 +59,11 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
return existing || {
|
||||
stages: {},
|
||||
global_parameters: {},
|
||||
default_templates: []
|
||||
default_templates: [],
|
||||
overall_quality_threshold: 7.0,
|
||||
critical_stage_blocking: true,
|
||||
auto_create_quality_checks: true,
|
||||
quality_manager_approval_required: false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,7 +80,11 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
setConfiguration(existing || {
|
||||
stages: {},
|
||||
global_parameters: {},
|
||||
default_templates: []
|
||||
default_templates: [],
|
||||
overall_quality_threshold: 7.0,
|
||||
critical_stage_blocking: true,
|
||||
auto_create_quality_checks: true,
|
||||
quality_manager_approval_required: false
|
||||
});
|
||||
}, [recipe]);
|
||||
|
||||
@@ -149,6 +157,16 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
});
|
||||
};
|
||||
|
||||
const handleGlobalSettingChange = (
|
||||
setting: 'overall_quality_threshold' | 'critical_stage_blocking' | 'auto_create_quality_checks' | 'quality_manager_approval_required',
|
||||
value: number | boolean
|
||||
) => {
|
||||
setConfiguration(prev => ({
|
||||
...prev,
|
||||
[setting]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSaveConfiguration(configuration);
|
||||
@@ -225,6 +243,86 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-4 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Configuración Global
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Umbral de Calidad Mínimo
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={configuration.overall_quality_threshold || 7.0}
|
||||
onChange={(e) => handleGlobalSettingChange('overall_quality_threshold', parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
Puntuación mínima requerida (0-10)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={configuration.critical_stage_blocking || false}
|
||||
onChange={(e) => handleGlobalSettingChange('critical_stage_blocking', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Bloqueo en Etapas Críticas
|
||||
</span>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Bloquear progreso si fallan checks críticos
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={configuration.auto_create_quality_checks || false}
|
||||
onChange={(e) => handleGlobalSettingChange('auto_create_quality_checks', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Auto-crear Controles
|
||||
</span>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Crear automáticamente checks al iniciar lote
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={configuration.quality_manager_approval_required || false}
|
||||
onChange={(e) => handleGlobalSettingChange('quality_manager_approval_required', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Aprobación Requerida
|
||||
</span>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Requiere aprobación del gerente de calidad
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Process Stages Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
export { DeleteRecipeModal } from './DeleteRecipeModal';
|
||||
@@ -6,7 +6,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from '../../ui/Select';
|
||||
import { Button } from '../../ui/Button/Button';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { SupplierType, SupplierStatus, PaymentTerms } from '../../../api/types/suppliers';
|
||||
|
||||
interface CreateSupplierFormProps {
|
||||
|
||||
351
frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx
Normal file
351
frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import { SupplierResponse, SupplierDeletionSummary } from '../../../api/types/suppliers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
interface DeleteSupplierModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
supplier: SupplierResponse | null;
|
||||
onSoftDelete: (supplierId: string) => Promise<void>;
|
||||
onHardDelete: (supplierId: string) => Promise<SupplierDeletionSummary>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for supplier deletion with soft/hard delete options
|
||||
* - Soft delete: Mark as inactive (reversible)
|
||||
* - Hard delete: Permanent deletion with GDPR compliance
|
||||
*/
|
||||
export const DeleteSupplierModal: React.FC<DeleteSupplierModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
supplier,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<SupplierDeletionSummary | null>(null);
|
||||
|
||||
if (!supplier) return null;
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard') {
|
||||
const result = await onHardDelete(supplier.id);
|
||||
setDeletionResult(result);
|
||||
// Close modal immediately after successful hard delete
|
||||
onClose();
|
||||
} else {
|
||||
await onSoftDelete(supplier.id);
|
||||
// Close modal immediately after soft delete
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting supplier:', error);
|
||||
// Error handling could show a toast notification
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR';
|
||||
|
||||
// Show deletion result for hard delete
|
||||
if (deletionResult) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('suppliers:delete.summary_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_deleted', { name: deletionResult.supplier_name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('suppliers:delete.deletion_summary')}:
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_price_lists')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_price_lists}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_quality_reviews')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_quality_reviews}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_performance_metrics')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_performance_metrics}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_alerts')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_alerts}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_scorecards')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_scorecards}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
{t('common:close', 'Entendido')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete
|
||||
? t('suppliers:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
|
||||
: t('suppliers:delete.confirm_soft_title', 'Confirmación de Desactivación')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{supplier.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('suppliers:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('suppliers:delete.hard_warning_1', '• El proveedor y toda su información')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_2', '• Todas las listas de precios asociadas')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_3', '• Todo el historial de calidad y rendimiento')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_4', '• Las alertas y scorecards relacionados')}</li>
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{t('suppliers:delete.irreversible', 'Esta acción NO se puede deshacer')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('suppliers:delete.soft_info_title', 'ℹ️ Esta acción desactivará el proveedor:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('suppliers:delete.soft_info_1', '• El proveedor se marcará como inactivo')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('suppliers:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
|
||||
placeholder={t('suppliers:delete.type_placeholder', 'Escriba ELIMINAR')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common:back', 'Volver')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete
|
||||
? t('suppliers:delete.confirm_hard', 'Eliminar Permanentemente')
|
||||
: t('suppliers:delete.confirm_soft', 'Desactivar Proveedor')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{t('suppliers:delete.title', 'Eliminar Proveedor')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{supplier.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
{t('suppliers:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('suppliers:delete.soft_delete', 'Desactivar (Recomendado)')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.soft_explanation', 'El proveedor se marca como inactivo pero conserva todo su historial. Ideal para proveedores temporalmente fuera del catálogo.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{t('suppliers:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{t('suppliers:delete.hard_delete', 'Eliminar Permanentemente')}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.hard_explanation', 'Elimina completamente el proveedor y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{t('suppliers:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common:continue', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
7
frontend/src/components/domain/suppliers/index.ts
Normal file
7
frontend/src/components/domain/suppliers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Supplier Domain Components
|
||||
* Export all supplier-related components
|
||||
*/
|
||||
|
||||
export { CreateSupplierForm } from './CreateSupplierForm';
|
||||
export { DeleteSupplierModal } from './DeleteSupplierModal';
|
||||
@@ -26,14 +26,14 @@ export const DemoBanner: React.FC = () => {
|
||||
setExpiresAt(expires);
|
||||
|
||||
if (demoMode && expires) {
|
||||
const interval = setInterval(() => {
|
||||
const interval = setInterval(async () => {
|
||||
const now = new Date().getTime();
|
||||
const expiryTime = new Date(expires).getTime();
|
||||
const diff = expiryTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeRemaining('Sesión expirada');
|
||||
handleExpiration();
|
||||
await handleExpiration();
|
||||
} else {
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
@@ -45,13 +45,22 @@ export const DemoBanner: React.FC = () => {
|
||||
}
|
||||
}, [expiresAt]);
|
||||
|
||||
const handleExpiration = () => {
|
||||
const handleExpiration = async () => {
|
||||
// Clear demo-specific localStorage keys
|
||||
localStorage.removeItem('demo_mode');
|
||||
localStorage.removeItem('demo_session_id');
|
||||
localStorage.removeItem('demo_account_type');
|
||||
localStorage.removeItem('demo_expires_at');
|
||||
localStorage.removeItem('demo_tenant_id');
|
||||
|
||||
// Clear API client demo session ID and tenant ID
|
||||
apiClient.setDemoSessionId(null);
|
||||
apiClient.setTenantId(null);
|
||||
|
||||
// Clear tenant store to remove cached demo tenant data
|
||||
const { useTenantStore } = await import('../../../stores/tenant.store');
|
||||
useTenantStore.getState().clearTenants();
|
||||
|
||||
navigate('/demo');
|
||||
};
|
||||
|
||||
@@ -89,7 +98,7 @@ export const DemoBanner: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error('Error destroying session:', error);
|
||||
} finally {
|
||||
handleExpiration();
|
||||
await handleExpiration();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import {
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
Search
|
||||
Search,
|
||||
Leaf,
|
||||
ChefHat,
|
||||
ClipboardCheck,
|
||||
BrainCircuit,
|
||||
Cog
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -109,6 +114,11 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
settings: Settings,
|
||||
user: User,
|
||||
'credit-card': CreditCard,
|
||||
leaf: Leaf,
|
||||
'chef-hat': ChefHat,
|
||||
'clipboard-check': ClipboardCheck,
|
||||
'brain-circuit': BrainCircuit,
|
||||
cog: Cog,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -260,6 +260,13 @@ export interface AddModalProps {
|
||||
|
||||
// Field change callback for dynamic form behavior
|
||||
onFieldChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// Wait-for-refetch support (Option A approach)
|
||||
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
||||
isRefetching?: boolean; // External refetch state (from React Query)
|
||||
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
||||
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
||||
showSuccessState?: boolean; // Show brief success state before closing (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,9 +296,18 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
validationErrors = EMPTY_VALIDATION_ERRORS,
|
||||
onValidationError,
|
||||
onFieldChange,
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch = false,
|
||||
isRefetching = false,
|
||||
onSaveComplete,
|
||||
refetchTimeout = 3000,
|
||||
showSuccessState = true,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track if we've initialized the form data for this modal session
|
||||
@@ -337,6 +353,15 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: string | number) => {
|
||||
// Debug logging for ingredients field
|
||||
if (fieldName === 'ingredients') {
|
||||
console.log('=== AddModal Field Change (ingredients) ===');
|
||||
console.log('New value:', value);
|
||||
console.log('Type:', typeof value);
|
||||
console.log('Is array:', Array.isArray(value));
|
||||
console.log('==========================================');
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
@@ -406,11 +431,62 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Execute the save mutation
|
||||
await onSave(formData);
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become true then false, or timeout
|
||||
const startTime = Date.now();
|
||||
const checkRefetch = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Timeout reached
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached, proceeding anyway');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch completed (was true, now false)
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
await checkRefetch();
|
||||
setIsWaitingForRefetch(false);
|
||||
|
||||
// Show success state briefly
|
||||
if (showSuccessState) {
|
||||
setShowSuccess(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal after save (and optional refetch) completes
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving form:', error);
|
||||
// Don't close modal on error - let the parent handle error display
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
setShowSuccess(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,7 +517,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(value)}
|
||||
value={value}
|
||||
onChange={(newValue) => handleFieldChange(field.name, newValue)}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
@@ -586,14 +662,15 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const StatusIcon = defaultStatusIndicator.icon;
|
||||
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
closeOnOverlayClick={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
@@ -601,8 +678,13 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${defaultStatusIndicator.color}15` }}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
||||
defaultStatusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
||||
} ${defaultStatusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${defaultStatusIndicator.color}15`,
|
||||
...(defaultStatusIndicator.isCritical && { ringColor: defaultStatusIndicator.color })
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
@@ -612,25 +694,13 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
{/* Title and subtitle */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: defaultStatusIndicator.color }}
|
||||
>
|
||||
{defaultStatusIndicator.text}
|
||||
{defaultStatusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{defaultStatusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -642,11 +712,28 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
{(loading || isSaving || isWaitingForRefetch || showSuccess) && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.saving', 'Guardando...')}</span>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{!showSuccess ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{isWaitingForRefetch
|
||||
? t('common:modals.refreshing', 'Actualizando datos...')
|
||||
: isSaving
|
||||
? t('common:modals.saving', 'Guardando...')
|
||||
: t('common:modals.loading', 'Cargando...')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-green-500 text-4xl">✓</div>
|
||||
<span className="text-[var(--text-secondary)] font-medium">
|
||||
{t('common:modals.success', 'Guardado correctamente')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -703,7 +790,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
disabled={loading || isSaving || isWaitingForRefetch}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
@@ -711,10 +798,10 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
disabled={loading || isSaving || isWaitingForRefetch}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{loading ? (
|
||||
{loading || isSaving || isWaitingForRefetch ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
t('common:modals.actions.save', 'Guardar')
|
||||
|
||||
@@ -76,6 +76,12 @@ export interface EditViewModalProps {
|
||||
totalSteps?: number; // Total steps in workflow
|
||||
validationErrors?: Record<string, string>; // Field validation errors
|
||||
onValidationError?: (errors: Record<string, string>) => void; // Validation error handler
|
||||
|
||||
// Wait-for-refetch support (Option A approach)
|
||||
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
||||
isRefetching?: boolean; // External refetch state (from React Query)
|
||||
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
||||
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,9 +345,16 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
totalSteps,
|
||||
validationErrors = {},
|
||||
onValidationError,
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch = false,
|
||||
isRefetching = false,
|
||||
onSaveComplete,
|
||||
refetchTimeout = 3000,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = React.useState(false);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
@@ -352,11 +365,59 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
if (!onSave) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Execute the save mutation
|
||||
await onSave();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become true then false, or timeout
|
||||
const startTime = Date.now();
|
||||
const checkRefetch = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Timeout reached
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached, proceeding anyway');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch completed (was true, now false)
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
await checkRefetch();
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
|
||||
// Switch to view mode after save (and optional refetch) completes
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
// Don't switch mode on error
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -371,30 +432,38 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
|
||||
// Default actions based on mode
|
||||
const defaultActions: EditViewModalAction[] = [];
|
||||
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
||||
|
||||
if (showDefaultActions) {
|
||||
if (mode === 'view') {
|
||||
defaultActions.push({
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
icon: Edit,
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: loading,
|
||||
});
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: onClose,
|
||||
disabled: isProcessing,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: isProcessing,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
disabled: isProcessing,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.save', 'Guardar'),
|
||||
variant: 'primary',
|
||||
onClick: handleSave,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
disabled: isProcessing,
|
||||
loading: isProcessing,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -469,8 +538,8 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
closeOnOverlayClick={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
@@ -479,8 +548,13 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
{/* Status indicator */}
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
||||
statusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
||||
} ${statusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${statusIndicator.color}15`,
|
||||
...(statusIndicator.isCritical && { ringColor: statusIndicator.color })
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
@@ -491,27 +565,13 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
{/* Title and subtitle */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: statusIndicator.color }}
|
||||
>
|
||||
{statusIndicator.text}
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -529,11 +589,17 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
{renderTopActions()}
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
{(loading || isSaving || isWaitingForRefetch) && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.loading', 'Cargando...')}</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{isWaitingForRefetch
|
||||
? t('common:modals.refreshing', 'Actualizando datos...')
|
||||
: isSaving
|
||||
? t('common:modals.saving', 'Guardando...')
|
||||
: t('common:modals.loading', 'Cargando...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,201 +1,120 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button } from '../../ui';
|
||||
import type { ButtonProps } from '../../ui';
|
||||
|
||||
export interface EmptyStateAction {
|
||||
/** Texto del botón */
|
||||
label: string;
|
||||
/** Función al hacer click */
|
||||
onClick: () => void;
|
||||
/** Variante del botón */
|
||||
variant?: ButtonProps['variant'];
|
||||
/** Icono del botón */
|
||||
icon?: React.ReactNode;
|
||||
/** Mostrar loading en el botón */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Icono o ilustración */
|
||||
icon?: React.ReactNode;
|
||||
/** Título del estado vacío */
|
||||
title?: string;
|
||||
/** Descripción del estado vacío */
|
||||
description?: string;
|
||||
/** Variante del estado vacío */
|
||||
variant?: 'no-data' | 'error' | 'search' | 'filter';
|
||||
/** Acción principal */
|
||||
primaryAction?: EmptyStateAction;
|
||||
/** Acción secundaria */
|
||||
secondaryAction?: EmptyStateAction;
|
||||
/** Componente personalizado para ilustración */
|
||||
illustration?: React.ReactNode;
|
||||
/** Clase CSS adicional */
|
||||
/**
|
||||
* Icon component to display (from lucide-react)
|
||||
*/
|
||||
icon: LucideIcon;
|
||||
|
||||
/**
|
||||
* Main title text
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Description text (can be a string or React node for complex content)
|
||||
*/
|
||||
description?: string | React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optional action button label
|
||||
*/
|
||||
actionLabel?: string;
|
||||
|
||||
/**
|
||||
* Optional action button click handler
|
||||
*/
|
||||
onAction?: () => void;
|
||||
|
||||
/**
|
||||
* Optional icon for the action button
|
||||
*/
|
||||
actionIcon?: LucideIcon;
|
||||
|
||||
/**
|
||||
* Optional action button variant
|
||||
*/
|
||||
actionVariant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
|
||||
/**
|
||||
* Optional action button size
|
||||
*/
|
||||
actionSize?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
className?: string;
|
||||
/** Tamaño del componente */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
// Iconos SVG por defecto para cada variante
|
||||
const DefaultIcons = {
|
||||
'no-data': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
'error': (
|
||||
<svg className="w-16 h-16 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
'search': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
'filter': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.707A1 1 0 013 7V4z" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// Mensajes por defecto en español para cada variante
|
||||
const DefaultMessages = {
|
||||
'no-data': {
|
||||
title: 'No hay datos disponibles',
|
||||
description: 'Aún no se han registrado elementos en esta sección. Comience agregando su primer elemento.'
|
||||
},
|
||||
'error': {
|
||||
title: 'Ha ocurrido un error',
|
||||
description: 'No se pudieron cargar los datos. Por favor, inténtelo de nuevo más tarde.'
|
||||
},
|
||||
'search': {
|
||||
title: 'Sin resultados de búsqueda',
|
||||
description: 'No se encontraron elementos que coincidan con su búsqueda. Intente con términos diferentes.'
|
||||
},
|
||||
'filter': {
|
||||
title: 'Sin resultados con estos filtros',
|
||||
description: 'No se encontraron elementos que coincidan con los filtros aplicados. Ajuste los filtros para ver más resultados.'
|
||||
}
|
||||
};
|
||||
|
||||
const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(({
|
||||
icon,
|
||||
/**
|
||||
* EmptyState Component
|
||||
*
|
||||
* A reusable component for displaying empty states across the application
|
||||
* with consistent styling and behavior.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyState
|
||||
* icon={Package}
|
||||
* title="No items found"
|
||||
* description="Try adjusting your search or add a new item"
|
||||
* actionLabel="Add Item"
|
||||
* actionIcon={Plus}
|
||||
* onAction={() => setShowModal(true)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
variant = 'no-data',
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
illustration,
|
||||
className,
|
||||
size = 'md',
|
||||
...props
|
||||
}, ref) => {
|
||||
const defaultMessage = DefaultMessages[variant];
|
||||
const displayTitle = title || defaultMessage.title;
|
||||
const displayDescription = description || defaultMessage.description;
|
||||
const displayIcon = illustration || icon || DefaultIcons[variant];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'py-8 px-4',
|
||||
md: 'py-12 px-6',
|
||||
lg: 'py-20 px-8'
|
||||
};
|
||||
|
||||
const titleSizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-xl',
|
||||
lg: 'text-2xl'
|
||||
};
|
||||
|
||||
const descriptionSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
};
|
||||
|
||||
const iconContainerClasses = {
|
||||
sm: 'mb-4',
|
||||
md: 'mb-6',
|
||||
lg: 'mb-8'
|
||||
};
|
||||
|
||||
const containerClasses = clsx(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
'min-h-[200px] max-w-md mx-auto',
|
||||
sizeClasses[size],
|
||||
className
|
||||
);
|
||||
|
||||
actionLabel,
|
||||
onAction,
|
||||
actionIcon: ActionIcon,
|
||||
actionVariant = 'primary',
|
||||
actionSize = 'md',
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={containerClasses}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
{...props}
|
||||
>
|
||||
{/* Icono o Ilustración */}
|
||||
{displayIcon && (
|
||||
<div className={clsx('flex-shrink-0', iconContainerClasses[size])}>
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className={`text-center py-12 ${className}`}>
|
||||
{/* Icon */}
|
||||
<Icon className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
|
||||
{/* Título */}
|
||||
{displayTitle && (
|
||||
<h3 className={clsx(
|
||||
'font-semibold text-text-primary mb-2',
|
||||
titleSizeClasses[size]
|
||||
)}>
|
||||
{displayTitle}
|
||||
</h3>
|
||||
)}
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Descripción */}
|
||||
{displayDescription && (
|
||||
<p className={clsx(
|
||||
'text-text-secondary mb-6 leading-relaxed',
|
||||
descriptionSizeClasses[size]
|
||||
)}>
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Acciones */}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
{primaryAction && (
|
||||
<Button
|
||||
variant={primaryAction.variant || 'primary'}
|
||||
onClick={primaryAction.onClick}
|
||||
isLoading={primaryAction.isLoading}
|
||||
leftIcon={primaryAction.icon}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
variant={secondaryAction.variant || 'outline'}
|
||||
onClick={secondaryAction.onClick}
|
||||
isLoading={secondaryAction.isLoading}
|
||||
leftIcon={secondaryAction.icon}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="text-[var(--text-secondary)] mb-4">
|
||||
{typeof description === 'string' ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
onClick={onAction}
|
||||
variant={actionVariant}
|
||||
size={actionSize}
|
||||
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
{ActionIcon && (
|
||||
<ActionIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm sm:text-base">{actionLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
|
||||
export default EmptyState;
|
||||
export default EmptyState;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
export { default } from './EmptyState';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { ClipboardCheck, X } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface QualityPromptDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfigureNow: () => void;
|
||||
onConfigureLater: () => void;
|
||||
recipeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QualityPromptDialog - Prompts user to configure quality checks after creating a recipe
|
||||
*/
|
||||
export const QualityPromptDialog: React.FC<QualityPromptDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfigureNow,
|
||||
onConfigureLater,
|
||||
recipeName
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Configurar Control de Calidad"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<ClipboardCheck className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
¡Receta creada exitosamente!
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
La receta <strong>{recipeName}</strong> ha sido creada.
|
||||
Para asegurar la calidad durante la producción, te recomendamos configurar
|
||||
los controles de calidad ahora.
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">
|
||||
¿Qué son los controles de calidad?
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Definir qué verificaciones se deben realizar en cada etapa del proceso de producción
|
||||
(mezclado, fermentación, horneado, etc.) utilizando plantillas de control de calidad
|
||||
reutilizables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onConfigureLater}
|
||||
className="flex-1"
|
||||
>
|
||||
Más Tarde
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfigureNow}
|
||||
className="flex-1"
|
||||
>
|
||||
Configurar Ahora
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityPromptDialog;
|
||||
2
frontend/src/components/ui/QualityPromptDialog/index.ts
Normal file
2
frontend/src/components/ui/QualityPromptDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { QualityPromptDialog } from './QualityPromptDialog';
|
||||
export type { } from './QualityPromptDialog';
|
||||
@@ -320,7 +320,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
];
|
||||
|
||||
const triggerClasses = [
|
||||
'flex items-center justify-between w-full px-3 py-2',
|
||||
'flex items-center justify-between w-full px-3 py-2 gap-2',
|
||||
'bg-[var(--bg-primary,#ffffff)] border border-[var(--border-primary,#e5e7eb)] rounded-lg',
|
||||
'text-[var(--text-primary,#111827)] text-left transition-colors duration-200',
|
||||
'focus:border-[var(--color-primary,#3b82f6)] focus:ring-1 focus:ring-[var(--color-primary,#3b82f6)]',
|
||||
@@ -332,9 +332,9 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 text-sm',
|
||||
md: 'h-10 text-base',
|
||||
lg: 'h-12 text-lg',
|
||||
sm: 'min-h-8 text-sm',
|
||||
md: 'min-h-10 text-base',
|
||||
lg: 'min-h-12 text-lg',
|
||||
};
|
||||
|
||||
const dropdownClasses = [
|
||||
@@ -355,28 +355,28 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
|
||||
if (multiple && Array.isArray(currentValue)) {
|
||||
if (currentValue.length === 0) {
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)] break-words">{placeholder}</span>;
|
||||
}
|
||||
|
||||
|
||||
if (currentValue.length === 1) {
|
||||
const option = selectedOptions[0];
|
||||
return option ? option.label : currentValue[0];
|
||||
return option ? <span className="break-words">{option.label}</span> : <span className="break-words">{currentValue[0]}</span>;
|
||||
}
|
||||
|
||||
return <span>{currentValue.length} elementos seleccionados</span>;
|
||||
|
||||
return <span className="break-words">{currentValue.length} elementos seleccionados</span>;
|
||||
}
|
||||
|
||||
const selectedOption = selectedOptions[0];
|
||||
if (selectedOption) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption.icon && <span>{selectedOption.icon}</span>}
|
||||
<span>{selectedOption.label}</span>
|
||||
{selectedOption.icon && <span className="flex-shrink-0">{selectedOption.icon}</span>}
|
||||
<span className="break-words">{selectedOption.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)] break-words">{placeholder}</span>;
|
||||
};
|
||||
|
||||
const renderMultipleValues = () => {
|
||||
@@ -559,15 +559,15 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
className={clsx(triggerClasses, sizeClasses[size])}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{multiple && Array.isArray(currentValue) && currentValue.length > 0 && currentValue.length <= 3 ? (
|
||||
renderMultipleValues()
|
||||
) : (
|
||||
renderSelectedValue()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{clearable && currentValue && (multiple ? (Array.isArray(currentValue) && currentValue.length > 0) : true) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface StatusCardProps {
|
||||
onClick: () => void;
|
||||
priority?: 'primary' | 'secondary' | 'tertiary';
|
||||
destructive?: boolean;
|
||||
highlighted?: boolean;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
onClick?: () => void;
|
||||
@@ -180,7 +181,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
: statusIndicator.isHighlight
|
||||
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
|
||||
: 'ring-1 shadow-sm'
|
||||
} max-w-[140px] sm:max-w-[160px]`}
|
||||
} max-w-[200px] sm:max-w-[220px]`}
|
||||
style={{
|
||||
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
@@ -201,7 +202,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
className={`${overflowClasses.truncate} flex-1`}
|
||||
title={statusIndicator.text}
|
||||
>
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 14 : 18)}
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 20 : 28)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,7 +337,9 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
: action.highlighted
|
||||
? 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -22,7 +22,18 @@
|
||||
"safety_stock_percentage": "Safety Stock (%)",
|
||||
"workflow": "Approval Workflow",
|
||||
"approval_reminder_hours": "Approval Reminder (hours)",
|
||||
"critical_escalation_hours": "Critical Escalation (hours)"
|
||||
"critical_escalation_hours": "Critical Escalation (hours)",
|
||||
"smart_procurement": "Smart Procurement Calculation",
|
||||
"use_reorder_rules": "Use reorder rules (point & quantity)",
|
||||
"use_reorder_rules_desc": "Respect reorder point and reorder quantity configured in ingredients",
|
||||
"economic_rounding": "Economic rounding",
|
||||
"economic_rounding_desc": "Round quantities to economic multiples (reorder quantity or supplier minimum)",
|
||||
"respect_storage_limits": "Respect storage limits",
|
||||
"respect_storage_limits_desc": "Limit orders to configured maximum stock level",
|
||||
"use_supplier_minimums": "Use supplier minimums",
|
||||
"use_supplier_minimums_desc": "Respect supplier minimum order quantity and minimum order amount",
|
||||
"optimize_price_tiers": "Optimize price tiers",
|
||||
"optimize_price_tiers_desc": "Adjust quantities to capture volume discounts when beneficial"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Inventory Management",
|
||||
|
||||
@@ -189,7 +189,9 @@
|
||||
"name": "Name",
|
||||
"contact_person": "Contact Person",
|
||||
"email": "Email",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Phone",
|
||||
"phone_placeholder": "+34 XXX XXX XXX",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"address": "Address",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"customer_satisfaction": "Customer Satisfaction",
|
||||
"inventory_turnover": "Inventory Turnover",
|
||||
"daily_profit": "Daily Profit",
|
||||
"products_sold": "Products Sold"
|
||||
"products_sold": "Products Sold",
|
||||
"waste_reduction": "Waste Reduction",
|
||||
"monthly_savings": "Monthly Savings"
|
||||
},
|
||||
"trends": {
|
||||
"vs_yesterday": "% vs yesterday",
|
||||
@@ -112,7 +114,14 @@
|
||||
"action_required": "Action required",
|
||||
"manage_organizations": "Manage your organizations",
|
||||
"setup_new_business": "Set up a new business from scratch",
|
||||
"active_organizations": "Active Organizations"
|
||||
"active_organizations": "Active Organizations",
|
||||
"excellent_progress": "Excellent progress!",
|
||||
"keep_improving": "Keep improving",
|
||||
"from_sustainability": "From sustainability",
|
||||
"all_caught_up": "All caught up!",
|
||||
"stock_healthy": "Stock healthy",
|
||||
"same_as_yesterday": "Same as yesterday",
|
||||
"less_than_yesterday": "less than yesterday"
|
||||
},
|
||||
"time_periods": {
|
||||
"today": "Today",
|
||||
@@ -122,5 +131,8 @@
|
||||
"last_7_days": "Last 7 days",
|
||||
"last_30_days": "Last 30 days",
|
||||
"last_90_days": "Last 90 days"
|
||||
},
|
||||
"errors": {
|
||||
"failed_to_load_stats": "Failed to load dashboard statistics. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@
|
||||
"labels": {
|
||||
"total_equipment": "Total Equipment",
|
||||
"operational": "Operational",
|
||||
"warning": "Warning",
|
||||
"maintenance_required": "Maintenance Required",
|
||||
"down": "Down",
|
||||
"avg_efficiency": "Average Efficiency",
|
||||
"active_alerts": "Active Alerts",
|
||||
"maintenance_due": "Maintenance Due",
|
||||
@@ -110,4 +113,4 @@
|
||||
"warning": "Warning",
|
||||
"info": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,118 @@
|
||||
{
|
||||
"product_type": {
|
||||
"raw_material": "Raw Material",
|
||||
"intermediate": "Intermediate Product",
|
||||
"finished_product": "Finished Product",
|
||||
"packaging": "Packaging"
|
||||
"title": "Inventory Management",
|
||||
"subtitle": "Manage stock, costs, batches and ingredient alerts",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"sku": "SKU Code",
|
||||
"barcode": "Barcode",
|
||||
"brand": "Brand",
|
||||
"category": "Category",
|
||||
"current_stock": "Current Stock",
|
||||
"min_stock": "Minimum Stock",
|
||||
"max_stock": "Maximum Stock",
|
||||
"unit": "Unit",
|
||||
"cost": "Cost",
|
||||
"price": "Price",
|
||||
"supplier": "Supplier",
|
||||
"last_restocked": "Last Restocked",
|
||||
"expiration_date": "Expiration Date",
|
||||
"batch_number": "Batch Number",
|
||||
"lot_number": "Lot Number",
|
||||
"supplier_batch_ref": "Supplier Ref.",
|
||||
"location": "Location",
|
||||
"description": "Description",
|
||||
"notes": "Notes",
|
||||
"package_size": "Package Size",
|
||||
"average_cost": "Average Cost",
|
||||
"standard_cost": "Standard Cost",
|
||||
"unit_cost": "Unit Cost",
|
||||
"low_stock_threshold": "Low Stock Threshold",
|
||||
"reorder_point": "Reorder Point",
|
||||
"reorder_quantity": "Reorder Quantity",
|
||||
"max_stock_level": "Maximum Stock",
|
||||
"shelf_life_days": "Shelf Life Days",
|
||||
"is_perishable": "Is Perishable?",
|
||||
"costs_and_pricing": "Costs and Pricing",
|
||||
"reserved_quantity": "Reserved Quantity",
|
||||
"available_quantity": "Available Quantity",
|
||||
"received_date": "Received Date",
|
||||
"best_before_date": "Best Before",
|
||||
"warehouse_zone": "Warehouse Zone",
|
||||
"shelf_position": "Shelf Position",
|
||||
"quality_status": "Quality Status",
|
||||
"storage_instructions": "Storage Instructions",
|
||||
"transformation_reference": "Transformation Reference",
|
||||
"original_expiration_date": "Original Expiration",
|
||||
"transformation_date": "Transformation Date",
|
||||
"final_expiration_date": "Final Expiration"
|
||||
},
|
||||
"production_stage": {
|
||||
"raw": "Raw",
|
||||
"in_process": "In Process",
|
||||
"finished": "Finished",
|
||||
"packaged": "Packaged"
|
||||
"sections": {
|
||||
"purchase_costs": "Purchase Costs",
|
||||
"stock_management": "Stock Management"
|
||||
},
|
||||
"unit_of_measure": {
|
||||
"kg": "Kilograms",
|
||||
"g": "Grams",
|
||||
"l": "Liters",
|
||||
"ml": "Milliliters",
|
||||
"pieces": "Pieces",
|
||||
"units": "Units",
|
||||
"portions": "Portions"
|
||||
"help": {
|
||||
"standard_cost": "Target cost for budgeting and variance analysis",
|
||||
"average_cost": "Automatically calculated from weighted average of purchases"
|
||||
},
|
||||
"ingredient_category": {
|
||||
"flour": "Flour",
|
||||
"dairy": "Dairy",
|
||||
"eggs": "Eggs",
|
||||
"fats": "Fats",
|
||||
"sugar": "Sugar",
|
||||
"yeast": "Yeast",
|
||||
"spices": "Spices",
|
||||
"other": "Other"
|
||||
},
|
||||
"product_category": {
|
||||
"bread": "Bread",
|
||||
"pastry": "Pastry",
|
||||
"cake": "Cake",
|
||||
"cookie": "Cookie",
|
||||
"salted": "Salted",
|
||||
"other": "Other"
|
||||
},
|
||||
"stock_movement_type": {
|
||||
"purchase": "Purchase",
|
||||
"production": "Production",
|
||||
"sale": "Sale",
|
||||
"adjustment": "Adjustment",
|
||||
"waste": "Waste",
|
||||
"transfer": "Transfer"
|
||||
"enums": {
|
||||
"product_type": {
|
||||
"ingredient": "Ingredient",
|
||||
"finished_product": "Finished Product"
|
||||
},
|
||||
"production_stage": {
|
||||
"raw_ingredient": "Raw Ingredient",
|
||||
"par_baked": "Par-Baked",
|
||||
"fully_baked": "Fully Baked",
|
||||
"prepared_dough": "Prepared Dough",
|
||||
"frozen_product": "Frozen Product"
|
||||
},
|
||||
"unit_of_measure": {
|
||||
"kg": "Kilograms",
|
||||
"g": "Grams",
|
||||
"l": "Liters",
|
||||
"ml": "Milliliters",
|
||||
"units": "Units",
|
||||
"pcs": "Pieces",
|
||||
"pkg": "Packages",
|
||||
"bags": "Bags",
|
||||
"boxes": "Boxes"
|
||||
},
|
||||
"ingredient_category": {
|
||||
"flour": "Flour",
|
||||
"yeast": "Yeast",
|
||||
"dairy": "Dairy",
|
||||
"eggs": "Eggs",
|
||||
"sugar": "Sugar",
|
||||
"fats": "Fats",
|
||||
"salt": "Salt",
|
||||
"spices": "Spices",
|
||||
"additives": "Additives",
|
||||
"packaging": "Packaging",
|
||||
"cleaning": "Cleaning",
|
||||
"other": "Other"
|
||||
},
|
||||
"product_category": {
|
||||
"bread": "Bread",
|
||||
"croissants": "Croissants",
|
||||
"pastries": "Pastries",
|
||||
"cakes": "Cakes",
|
||||
"cookies": "Cookies",
|
||||
"muffins": "Muffins",
|
||||
"sandwiches": "Sandwiches",
|
||||
"seasonal": "Seasonal",
|
||||
"beverages": "Beverages",
|
||||
"other_products": "Other Products"
|
||||
},
|
||||
"stock_movement_type": {
|
||||
"PURCHASE": "Purchase",
|
||||
"PRODUCTION_USE": "Production Use",
|
||||
"TRANSFORMATION": "Transformation",
|
||||
"ADJUSTMENT": "Adjustment",
|
||||
"WASTE": "Waste",
|
||||
"TRANSFER": "Transfer",
|
||||
"RETURN": "Return",
|
||||
"INITIAL_STOCK": "Initial Stock",
|
||||
"OTHER": "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,24 +81,85 @@
|
||||
"currency": "Currency",
|
||||
"created_date": "Created Date",
|
||||
"updated_date": "Last Updated",
|
||||
"notes": "Notes"
|
||||
"notes": "Notes",
|
||||
"tax_id": "Tax/VAT ID",
|
||||
"registration_number": "Business Registration",
|
||||
"mobile": "Mobile Phone",
|
||||
"website": "Website",
|
||||
"address_line1": "Address Line 1",
|
||||
"address_line2": "Address Line 2",
|
||||
"state_province": "State/Province",
|
||||
"postal_code": "Postal Code",
|
||||
"delivery_area": "Delivery Area"
|
||||
},
|
||||
"sections": {
|
||||
"contact_info": "Contact Information",
|
||||
"address_info": "Address Information",
|
||||
"commercial_info": "Commercial Information",
|
||||
"additional_info": "Additional Information",
|
||||
"performance": "Performance and Statistics",
|
||||
"notes": "Notes"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "Supplier name",
|
||||
"contact_person": "Contact person name",
|
||||
"supplier_code": "Unique code",
|
||||
"notes": "Notes about the supplier"
|
||||
"supplier_code": "e.g., SUP-001",
|
||||
"notes": "Notes about the supplier",
|
||||
"tax_id": "e.g., ESB12345678",
|
||||
"registration_number": "Business registration number",
|
||||
"mobile": "+34 XXX XXX XXX",
|
||||
"website": "https://example.com",
|
||||
"address_line1": "Street address",
|
||||
"address_line2": "Apartment, suite, etc. (optional)",
|
||||
"state_province": "State or Province",
|
||||
"postal_code": "Postal/ZIP code",
|
||||
"delivery_area": "Delivery coverage area"
|
||||
},
|
||||
"currencies": {
|
||||
"EUR": "Euro (€)",
|
||||
"USD": "US Dollar ($)",
|
||||
"GBP": "British Pound (£)"
|
||||
},
|
||||
"descriptions": {
|
||||
"supplier_type": "Select the type of products or services this supplier offers",
|
||||
"payment_terms": "Payment terms agreed with the supplier",
|
||||
"quality_rating": "1 to 5 star rating based on product quality",
|
||||
"delivery_rating": "1 to 5 star rating based on delivery punctuality and condition"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Approve Supplier",
|
||||
"reject": "Reject Supplier",
|
||||
"delete": "Delete Supplier"
|
||||
},
|
||||
"confirm": {
|
||||
"approve": "Are you sure you want to approve this supplier? This will activate the supplier for use.",
|
||||
"reject": "Are you sure you want to reject this supplier? This action can be undone later."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Supplier",
|
||||
"subtitle": "How would you like to delete {name}?",
|
||||
"supplier_name": "Supplier",
|
||||
"soft_delete": "Mark as Inactive",
|
||||
"hard_delete": "Permanently Delete",
|
||||
"soft_explanation": "Marks the supplier as inactive. Can be reactivated later. All data is preserved.",
|
||||
"hard_explanation": "Permanently deletes all supplier data including price lists, quality reviews, and performance metrics.",
|
||||
"confirm_soft_title": "Confirm Mark as Inactive",
|
||||
"confirm_hard_title": "Confirm Permanent Deletion",
|
||||
"soft_description": "This will mark the supplier as inactive. The supplier can be reactivated later and all data will be preserved.",
|
||||
"hard_description": "This will permanently delete all supplier data. This action cannot be undone.",
|
||||
"warning_irreversible": "Warning: This action is irreversible!",
|
||||
"type_to_confirm": "Type ELIMINAR to confirm",
|
||||
"confirm_instruction": "Type ELIMINAR in capital letters to confirm permanent deletion",
|
||||
"confirm_soft": "Mark as Inactive",
|
||||
"confirm_hard": "Permanently Delete",
|
||||
"summary_title": "Deletion Complete",
|
||||
"supplier_deleted": "Supplier {name} has been permanently deleted",
|
||||
"deletion_summary": "Deletion Summary",
|
||||
"deleted_price_lists": "Price lists deleted",
|
||||
"deleted_quality_reviews": "Quality reviews deleted",
|
||||
"deleted_performance_metrics": "Performance metrics deleted",
|
||||
"deleted_alerts": "Alerts deleted",
|
||||
"deleted_scorecards": "Scorecards deleted",
|
||||
"cannot_delete": "Cannot delete supplier with active purchase orders"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,18 @@
|
||||
"safety_stock_percentage": "Stock de Seguridad (%)",
|
||||
"workflow": "Flujo de Aprobación",
|
||||
"approval_reminder_hours": "Recordatorio de Aprobación (horas)",
|
||||
"critical_escalation_hours": "Escalación Crítica (horas)"
|
||||
"critical_escalation_hours": "Escalación Crítica (horas)",
|
||||
"smart_procurement": "Cálculo Inteligente de Compras",
|
||||
"use_reorder_rules": "Usar reglas de reorden (punto y cantidad)",
|
||||
"use_reorder_rules_desc": "Respetar punto de reorden y cantidad de reorden configurados en ingredientes",
|
||||
"economic_rounding": "Redondeo económico",
|
||||
"economic_rounding_desc": "Redondear cantidades a múltiplos económicos (cantidad de reorden o mínimo del proveedor)",
|
||||
"respect_storage_limits": "Respetar límites de almacenamiento",
|
||||
"respect_storage_limits_desc": "Limitar pedidos al nivel máximo de stock configurado",
|
||||
"use_supplier_minimums": "Usar mínimos del proveedor",
|
||||
"use_supplier_minimums_desc": "Respetar cantidad mínima de pedido y monto mínimo del proveedor",
|
||||
"optimize_price_tiers": "Optimizar niveles de precio",
|
||||
"optimize_price_tiers_desc": "Ajustar cantidades para capturar descuentos por volumen cuando sea beneficioso"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Gestión de Inventario",
|
||||
|
||||
@@ -189,7 +189,9 @@
|
||||
"name": "Nombre",
|
||||
"contact_person": "Persona de Contacto",
|
||||
"email": "Email",
|
||||
"email_placeholder": "email@ejemplo.com",
|
||||
"phone": "Teléfono",
|
||||
"phone_placeholder": "+34 XXX XXX XXX",
|
||||
"city": "Ciudad",
|
||||
"country": "País",
|
||||
"address": "Dirección",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"customer_satisfaction": "Satisfacción del Cliente",
|
||||
"inventory_turnover": "Rotación de Inventario",
|
||||
"daily_profit": "Ganancia Diaria",
|
||||
"products_sold": "Productos Vendidos"
|
||||
"products_sold": "Productos Vendidos",
|
||||
"waste_reduction": "Reducción de Residuos",
|
||||
"monthly_savings": "Ahorro Mensual"
|
||||
},
|
||||
"trends": {
|
||||
"vs_yesterday": "% vs ayer",
|
||||
@@ -147,7 +149,14 @@
|
||||
"action_required": "Acción requerida",
|
||||
"manage_organizations": "Gestiona tus organizaciones",
|
||||
"setup_new_business": "Configurar un nuevo negocio desde cero",
|
||||
"active_organizations": "Organizaciones Activas"
|
||||
"active_organizations": "Organizaciones Activas",
|
||||
"excellent_progress": "¡Excelente progreso!",
|
||||
"keep_improving": "Sigue mejorando",
|
||||
"from_sustainability": "De sostenibilidad",
|
||||
"all_caught_up": "¡Todo al día!",
|
||||
"stock_healthy": "Stock saludable",
|
||||
"same_as_yesterday": "Igual que ayer",
|
||||
"less_than_yesterday": "menos que ayer"
|
||||
},
|
||||
"time_periods": {
|
||||
"today": "Hoy",
|
||||
@@ -157,5 +166,8 @@
|
||||
"last_7_days": "Últimos 7 días",
|
||||
"last_30_days": "Últimos 30 días",
|
||||
"last_90_days": "Últimos 90 días"
|
||||
},
|
||||
"errors": {
|
||||
"failed_to_load_stats": "Error al cargar las estadísticas del panel. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@
|
||||
"labels": {
|
||||
"total_equipment": "Total de Equipos",
|
||||
"operational": "Operacionales",
|
||||
"warning": "Advertencia",
|
||||
"maintenance_required": "Mantenimiento Requerido",
|
||||
"down": "Fuera de Servicio",
|
||||
"avg_efficiency": "Eficiencia Promedio",
|
||||
"active_alerts": "Alertas Activas",
|
||||
"maintenance_due": "Mantenimiento Próximo",
|
||||
@@ -109,4 +112,4 @@
|
||||
"warning": "Advertencia",
|
||||
"info": "Información"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Gestión de Inventario",
|
||||
"subtitle": "Controla el stock de ingredientes y materias primas",
|
||||
"subtitle": "Gestiona stock, costos, lotes y alertas de ingredientes",
|
||||
"overview": {
|
||||
"total_items": "Total Artículos",
|
||||
"low_stock": "Stock Bajo",
|
||||
@@ -24,6 +24,9 @@
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"sku": "Código SKU",
|
||||
"barcode": "Código de Barras",
|
||||
"brand": "Marca",
|
||||
"category": "Categoría",
|
||||
"current_stock": "Stock Actual",
|
||||
"min_stock": "Stock Mínimo",
|
||||
@@ -35,10 +38,42 @@
|
||||
"last_restocked": "Último Reabastecimiento",
|
||||
"expiration_date": "Fecha de Caducidad",
|
||||
"batch_number": "Número de Lote",
|
||||
"lot_number": "Número de Lote",
|
||||
"supplier_batch_ref": "Ref. Proveedor",
|
||||
"location": "Ubicación",
|
||||
"barcode": "Código de Barras",
|
||||
"description": "Descripción",
|
||||
"notes": "Notas"
|
||||
"notes": "Notas",
|
||||
"package_size": "Tamaño de Paquete",
|
||||
"average_cost": "Costo Promedio",
|
||||
"standard_cost": "Costo Estándar",
|
||||
"unit_cost": "Costo Unitario",
|
||||
"low_stock_threshold": "Umbral Stock Bajo",
|
||||
"reorder_point": "Punto de Reorden",
|
||||
"reorder_quantity": "Cantidad de Reorden",
|
||||
"max_stock_level": "Stock Máximo",
|
||||
"shelf_life_days": "Días de Vida Útil",
|
||||
"is_perishable": "¿Es Perecedero?",
|
||||
"costs_and_pricing": "Costos y Precios",
|
||||
"reserved_quantity": "Cantidad Reservada",
|
||||
"available_quantity": "Cantidad Disponible",
|
||||
"received_date": "Fecha de Recepción",
|
||||
"best_before_date": "Mejor Antes De",
|
||||
"warehouse_zone": "Zona de Almacén",
|
||||
"shelf_position": "Posición en Estantería",
|
||||
"quality_status": "Estado de Calidad",
|
||||
"storage_instructions": "Instrucciones de Almacenamiento",
|
||||
"transformation_reference": "Referencia de Transformación",
|
||||
"original_expiration_date": "Vencimiento Original",
|
||||
"transformation_date": "Fecha de Transformación",
|
||||
"final_expiration_date": "Vencimiento Final"
|
||||
},
|
||||
"sections": {
|
||||
"purchase_costs": "Costos de Compra",
|
||||
"stock_management": "Gestión de Stock"
|
||||
},
|
||||
"help": {
|
||||
"standard_cost": "Costo objetivo para presupuesto y análisis de variación",
|
||||
"average_cost": "Calculado automáticamente según el promedio ponderado de compras"
|
||||
},
|
||||
"enums": {
|
||||
"product_type": {
|
||||
@@ -97,7 +132,8 @@
|
||||
"TRANSFER": "Transferencia",
|
||||
"RETURN": "Devolución",
|
||||
"INITIAL_STOCK": "Stock Inicial",
|
||||
"TRANSFORMATION": "Transformación"
|
||||
"TRANSFORMATION": "Transformación",
|
||||
"OTHER": "Otro"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
|
||||
@@ -113,6 +113,22 @@
|
||||
"chemical": "Químico",
|
||||
"hygiene": "Higiene"
|
||||
},
|
||||
"check_types": {
|
||||
"visual": "Visual",
|
||||
"visual_description": "Inspección visual",
|
||||
"measurement": "Medición",
|
||||
"measurement_description": "Mediciones precisas",
|
||||
"temperature": "Temperatura",
|
||||
"temperature_description": "Control de temperatura",
|
||||
"weight": "Peso",
|
||||
"weight_description": "Control de peso",
|
||||
"boolean": "Sí/No",
|
||||
"boolean_description": "Verificación binaria",
|
||||
"timing": "Tiempo",
|
||||
"timing_description": "Control de tiempo",
|
||||
"checklist": "Lista de verificación",
|
||||
"checklist_description": "Checklist de verificación"
|
||||
},
|
||||
"inspection": {
|
||||
"title": "Inspección de Calidad",
|
||||
"notes_placeholder": "Agregar notas para este criterio (opcional)..."
|
||||
@@ -691,4 +707,4 @@
|
||||
"active_alerts": "alertas activas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,24 +81,85 @@
|
||||
"currency": "Moneda",
|
||||
"created_date": "Fecha de Creación",
|
||||
"updated_date": "Última Actualización",
|
||||
"notes": "Observaciones"
|
||||
"notes": "Observaciones",
|
||||
"tax_id": "NIF/CIF",
|
||||
"registration_number": "Registro Mercantil",
|
||||
"mobile": "Teléfono Móvil",
|
||||
"website": "Sitio Web",
|
||||
"address_line1": "Dirección Línea 1",
|
||||
"address_line2": "Dirección Línea 2",
|
||||
"state_province": "Provincia/Estado",
|
||||
"postal_code": "Código Postal",
|
||||
"delivery_area": "Área de Entrega"
|
||||
},
|
||||
"sections": {
|
||||
"contact_info": "Información de Contacto",
|
||||
"address_info": "Información de Dirección",
|
||||
"commercial_info": "Información Comercial",
|
||||
"additional_info": "Información Adicional",
|
||||
"performance": "Rendimiento y Estadísticas",
|
||||
"notes": "Notas"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "Nombre del proveedor",
|
||||
"contact_person": "Nombre del contacto",
|
||||
"supplier_code": "Código único",
|
||||
"notes": "Notas sobre el proveedor"
|
||||
"supplier_code": "ej., PROV-001",
|
||||
"notes": "Notas sobre el proveedor",
|
||||
"tax_id": "ej., ESB12345678",
|
||||
"registration_number": "Número de registro mercantil",
|
||||
"mobile": "+34 XXX XXX XXX",
|
||||
"website": "https://ejemplo.com",
|
||||
"address_line1": "Dirección de la calle",
|
||||
"address_line2": "Apartamento, piso, etc. (opcional)",
|
||||
"state_province": "Provincia o Estado",
|
||||
"postal_code": "Código postal",
|
||||
"delivery_area": "Área de cobertura de entrega"
|
||||
},
|
||||
"currencies": {
|
||||
"EUR": "Euro (€)",
|
||||
"USD": "Dólar estadounidense ($)",
|
||||
"GBP": "Libra esterlina (£)"
|
||||
},
|
||||
"descriptions": {
|
||||
"supplier_type": "Selecciona el tipo de productos o servicios que ofrece este proveedor",
|
||||
"payment_terms": "Términos de pago acordados con el proveedor",
|
||||
"quality_rating": "Calificación de 1 a 5 estrellas basada en la calidad de los productos",
|
||||
"delivery_rating": "Calificación de 1 a 5 estrellas basada en la puntualidad y estado de las entregas"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Aprobar Proveedor",
|
||||
"reject": "Rechazar Proveedor",
|
||||
"delete": "Eliminar Proveedor"
|
||||
},
|
||||
"confirm": {
|
||||
"approve": "¿Estás seguro de que quieres aprobar este proveedor? Esto activará el proveedor para su uso.",
|
||||
"reject": "¿Estás seguro de que quieres rechazar este proveedor? Esta acción se puede deshacer más tarde."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar Proveedor",
|
||||
"subtitle": "¿Cómo te gustaría eliminar {name}?",
|
||||
"supplier_name": "Proveedor",
|
||||
"soft_delete": "Marcar como Inactivo",
|
||||
"hard_delete": "Eliminar Permanentemente",
|
||||
"soft_explanation": "Marca el proveedor como inactivo. Puede reactivarse más tarde. Todos los datos se conservan.",
|
||||
"hard_explanation": "Elimina permanentemente todos los datos del proveedor, incluyendo listas de precios, revisiones de calidad y métricas de rendimiento.",
|
||||
"confirm_soft_title": "Confirmar Marcar como Inactivo",
|
||||
"confirm_hard_title": "Confirmar Eliminación Permanente",
|
||||
"soft_description": "Esto marcará el proveedor como inactivo. El proveedor puede reactivarse más tarde y todos los datos se conservarán.",
|
||||
"hard_description": "Esto eliminará permanentemente todos los datos del proveedor. Esta acción no se puede deshacer.",
|
||||
"warning_irreversible": "Advertencia: ¡Esta acción es irreversible!",
|
||||
"type_to_confirm": "Escribe ELIMINAR para confirmar",
|
||||
"confirm_instruction": "Escribe ELIMINAR en mayúsculas para confirmar la eliminación permanente",
|
||||
"confirm_soft": "Marcar como Inactivo",
|
||||
"confirm_hard": "Eliminar Permanentemente",
|
||||
"summary_title": "Eliminación Completa",
|
||||
"supplier_deleted": "El proveedor {name} ha sido eliminado permanentemente",
|
||||
"deletion_summary": "Resumen de Eliminación",
|
||||
"deleted_price_lists": "Listas de precios eliminadas",
|
||||
"deleted_quality_reviews": "Revisiones de calidad eliminadas",
|
||||
"deleted_performance_metrics": "Métricas de rendimiento eliminadas",
|
||||
"deleted_alerts": "Alertas eliminadas",
|
||||
"deleted_scorecards": "Tarjetas de puntuación eliminadas",
|
||||
"cannot_delete": "No se puede eliminar el proveedor con órdenes de compra activas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,18 @@
|
||||
"safety_stock_percentage": "Segurtasun Stocka (%)",
|
||||
"workflow": "Onespen Fluxua",
|
||||
"approval_reminder_hours": "Onespen Gogorarazpena (orduak)",
|
||||
"critical_escalation_hours": "Eskalazio Kritikoa (orduak)"
|
||||
"critical_escalation_hours": "Eskalazio Kritikoa (orduak)",
|
||||
"smart_procurement": "Erosketa Adimendunaren Kalkulua",
|
||||
"use_reorder_rules": "Erabili berrerozketa arauak (puntua eta kantitatea)",
|
||||
"use_reorder_rules_desc": "Egin erreferentzia osagaietan konfiguratutako berrerozketa puntua eta kantitateari",
|
||||
"economic_rounding": "Biribiltze ekonomikoa",
|
||||
"economic_rounding_desc": "Biribildu kantitateak multiplo ekonomikoetara (berrerozketa kantitatea edo hornitzailearen gutxienekoa)",
|
||||
"respect_storage_limits": "Egin errespetu biltegiratze mugari",
|
||||
"respect_storage_limits_desc": "Mugatu aginduak konfiguratutako gehienezko stock mailara",
|
||||
"use_supplier_minimums": "Erabili hornitzaileen gutxienezkoak",
|
||||
"use_supplier_minimums_desc": "Egin errespetu hornitzaileen gutxieneko erosketa kantitateari eta gutxieneko erosketa zenbatekoari",
|
||||
"optimize_price_tiers": "Optimizatu prezio mailak",
|
||||
"optimize_price_tiers_desc": "Doitu kantitateak bolumeneko deskontuak lortzeko onuragarria denean"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Inbentarioaren Kudeaketa",
|
||||
|
||||
@@ -189,7 +189,9 @@
|
||||
"name": "Izena",
|
||||
"contact_person": "Kontaktu pertsona",
|
||||
"email": "Emaila",
|
||||
"email_placeholder": "email@adibidea.eus",
|
||||
"phone": "Telefonoa",
|
||||
"phone_placeholder": "+34 XXX XXX XXX",
|
||||
"city": "Hiria",
|
||||
"country": "Herrialdea",
|
||||
"address": "Helbidea",
|
||||
|
||||
@@ -1 +1,99 @@
|
||||
{}
|
||||
{
|
||||
"title": "Inbentario Kudeaketa",
|
||||
"subtitle": "Kudeatu stock-a, kostuak, loteak eta osagaien alertak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"sku": "SKU Kodea",
|
||||
"barcode": "Barra Kodea",
|
||||
"brand": "Marka",
|
||||
"category": "Kategoria",
|
||||
"current_stock": "Egungo Stock-a",
|
||||
"min_stock": "Gutxieneko Stock-a",
|
||||
"max_stock": "Gehienezko Stock-a",
|
||||
"unit": "Unitatea",
|
||||
"cost": "Kostua",
|
||||
"price": "Prezioa",
|
||||
"supplier": "Hornitzailea",
|
||||
"last_restocked": "Azken Hornidura",
|
||||
"expiration_date": "Iraungitze Data",
|
||||
"batch_number": "Lote Zenbakia",
|
||||
"lot_number": "Lote Zenbakia",
|
||||
"supplier_batch_ref": "Hornitzailearen Err.",
|
||||
"location": "Kokapena",
|
||||
"description": "Deskribapena",
|
||||
"notes": "Oharrak",
|
||||
"package_size": "Pakete Tamaina",
|
||||
"average_cost": "Batez Besteko Kostua",
|
||||
"standard_cost": "Kostu Estandarra",
|
||||
"unit_cost": "Unitatearen Kostua",
|
||||
"low_stock_threshold": "Stock Baxuaren Muga",
|
||||
"reorder_point": "Berriz Eskatzeko Puntua",
|
||||
"reorder_quantity": "Berriz Eskatzeko Kantitatea",
|
||||
"max_stock_level": "Gehienezko Stock-a",
|
||||
"shelf_life_days": "Bizitza Erabilgarria Egunetan",
|
||||
"is_perishable": "Hondagarria da?",
|
||||
"costs_and_pricing": "Kostuak eta Prezioak",
|
||||
"reserved_quantity": "Erreserbatutako Kantitatea",
|
||||
"available_quantity": "Kantitate Erabilgarria",
|
||||
"received_date": "Jasotze Data",
|
||||
"best_before_date": "Hobe Baino Lehen",
|
||||
"warehouse_zone": "Biltegiaren Zona",
|
||||
"shelf_position": "Apaleko Posizioa",
|
||||
"quality_status": "Kalitatearen Egoera",
|
||||
"storage_instructions": "Biltegiratze Jarraibideak",
|
||||
"transformation_reference": "Transformazio Erreferentzia",
|
||||
"original_expiration_date": "Jatorrizko Iraungipena",
|
||||
"transformation_date": "Transformazio Data",
|
||||
"final_expiration_date": "Azken Iraungipena"
|
||||
},
|
||||
"sections": {
|
||||
"purchase_costs": "Erosketa Kostuak",
|
||||
"stock_management": "Stock Kudeaketa"
|
||||
},
|
||||
"help": {
|
||||
"standard_cost": "Helburuko kostua aurrekonturako eta bariantza analisirako",
|
||||
"average_cost": "Erosketaren batez besteko ponderatutik automatikoki kalkulatuta"
|
||||
},
|
||||
"enums": {
|
||||
"ingredient_category": {
|
||||
"flour": "Irinak",
|
||||
"yeast": "Legamiak",
|
||||
"dairy": "Esnekiak",
|
||||
"eggs": "Arrautzak",
|
||||
"sugar": "Azukrea",
|
||||
"fats": "Gantzak",
|
||||
"salt": "Gatza",
|
||||
"spices": "Espezia",
|
||||
"additives": "Gehigarriak",
|
||||
"packaging": "Ontziak",
|
||||
"cleaning": "Garbiketa",
|
||||
"other": "Besteak"
|
||||
},
|
||||
"stock_movement_type": {
|
||||
"PURCHASE": "Erosketa",
|
||||
"PRODUCTION_USE": "Ekoizpenean Erabilera",
|
||||
"TRANSFORMATION": "Transformazioa",
|
||||
"ADJUSTMENT": "Doikuntza",
|
||||
"WASTE": "Hondakina",
|
||||
"TRANSFER": "Transferentzia",
|
||||
"RETURN": "Itzulera",
|
||||
"INITIAL_STOCK": "Hasierako Stock-a",
|
||||
"OTHER": "Bestea"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"all": "Kategoria guztiak",
|
||||
"flour": "Irinak",
|
||||
"dairy": "Esnekiak",
|
||||
"eggs": "Arrautzak",
|
||||
"fats": "Gantzak",
|
||||
"sugar": "Azukrea",
|
||||
"yeast": "Legamiak",
|
||||
"spices": "Espezia",
|
||||
"additives": "Gehigarriak",
|
||||
"packaging": "Ontziak",
|
||||
"cleaning": "Garbiketa",
|
||||
"equipment": "Ekipoak",
|
||||
"other": "Besteak"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,24 +81,85 @@
|
||||
"currency": "Moneta",
|
||||
"created_date": "Sortze data",
|
||||
"updated_date": "Azken eguneraketa",
|
||||
"notes": "Oharrak"
|
||||
"notes": "Oharrak",
|
||||
"tax_id": "IFK/ZIOA",
|
||||
"registration_number": "Merkataritza erregistroa",
|
||||
"mobile": "Mugikorra",
|
||||
"website": "Webgunea",
|
||||
"address_line1": "Helbide lerroa 1",
|
||||
"address_line2": "Helbide lerroa 2",
|
||||
"state_province": "Probintzia/Estatua",
|
||||
"postal_code": "Posta kodea",
|
||||
"delivery_area": "Entrega eremua"
|
||||
},
|
||||
"sections": {
|
||||
"contact_info": "Kontaktu informazioa",
|
||||
"address_info": "Helbide informazioa",
|
||||
"commercial_info": "Informazio komertziala",
|
||||
"additional_info": "Informazio gehigarria",
|
||||
"performance": "Errendimendua eta estatistikak",
|
||||
"notes": "Oharrak"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "Hornitzailearen izena",
|
||||
"contact_person": "Kontaktuaren izena",
|
||||
"supplier_code": "Kode esklusiboa",
|
||||
"notes": "Oharrak hornitzaileari buruz"
|
||||
"supplier_code": "adib., HORN-001",
|
||||
"notes": "Oharrak hornitzaileari buruz",
|
||||
"tax_id": "adib., ESB12345678",
|
||||
"registration_number": "Merkataritza erregistro zenbakia",
|
||||
"mobile": "+34 XXX XXX XXX",
|
||||
"website": "https://adibidea.eus",
|
||||
"address_line1": "Kalearen helbidea",
|
||||
"address_line2": "Apartamentua, pisua, etab. (aukerakoa)",
|
||||
"state_province": "Probintzia edo Estatua",
|
||||
"postal_code": "Posta kodea",
|
||||
"delivery_area": "Entregaren estaldura eremua"
|
||||
},
|
||||
"currencies": {
|
||||
"EUR": "Euroa (€)",
|
||||
"USD": "AEBetako dolarra ($)",
|
||||
"GBP": "Libera esterlina (£)"
|
||||
},
|
||||
"descriptions": {
|
||||
"supplier_type": "Hautatu hornitzaile honek ematen dituen produktuen edo zerbitzuen mota",
|
||||
"payment_terms": "Hornitzailearekin hitz egindako ordainketa baldintzak",
|
||||
"quality_rating": "1etik 5erako izarra balorazioa produktuaren kalitatean oinarrituta",
|
||||
"delivery_rating": "1etik 5erako izarra balorazioa entrega puntualtasunean eta baldintzetan oinarrituta"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Hornitzailea Onartu",
|
||||
"reject": "Hornitzailea Baztertu",
|
||||
"delete": "Hornitzailea Ezabatu"
|
||||
},
|
||||
"confirm": {
|
||||
"approve": "Ziur zaude hornitzaile hau onartu nahi duzula? Honek hornitzailea erabiltzeko aktibatuko du.",
|
||||
"reject": "Ziur zaude hornitzaile hau baztertu nahi duzula? Ekintza hau geroago desegin daiteke."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Hornitzailea Ezabatu",
|
||||
"subtitle": "Nola ezabatu nahi duzu {name}?",
|
||||
"supplier_name": "Hornitzailea",
|
||||
"soft_delete": "Inaktibo gisa Markatu",
|
||||
"hard_delete": "Betirako Ezabatu",
|
||||
"soft_explanation": "Hornitzailea inaktibo gisa markatzen du. Geroago berriro aktibatu daiteke. Datu guztiak gordetzen dira.",
|
||||
"hard_explanation": "Hornitzailearen datu guztiak betirako ezabatzen ditu, prezio zerrendak, kalitate berrikuspenak eta errendimenduko metrikak barne.",
|
||||
"confirm_soft_title": "Berretsi Inaktibo gisa Markatu",
|
||||
"confirm_hard_title": "Berretsi Betirako Ezabatzea",
|
||||
"soft_description": "Honek hornitzailea inaktibo gisa markatuko du. Hornitzailea geroago berriro aktibatu daiteke eta datu guztiak gordeko dira.",
|
||||
"hard_description": "Honek hornitzailearen datu guztiak betirako ezabatuko ditu. Ekintza hau ezin da desegin.",
|
||||
"warning_irreversible": "Abisua: Ekintza hau itzulezina da!",
|
||||
"type_to_confirm": "Idatzi EZABATU berresteko",
|
||||
"confirm_instruction": "Idatzi EZABATU letra larriz betirako ezabatzea berresteko",
|
||||
"confirm_soft": "Inaktibo gisa Markatu",
|
||||
"confirm_hard": "Betirako Ezabatu",
|
||||
"summary_title": "Ezabatzea Osatua",
|
||||
"supplier_deleted": "{name} hornitzailea betirako ezabatu da",
|
||||
"deletion_summary": "Ezabatze Laburpena",
|
||||
"deleted_price_lists": "Ezabatutako prezio zerrendak",
|
||||
"deleted_quality_reviews": "Ezabatutako kalitate berrikuspenak",
|
||||
"deleted_performance_metrics": "Ezabatutako errendimenduko metrikak",
|
||||
"deleted_alerts": "Ezabatutako alertak",
|
||||
"deleted_scorecards": "Ezabatutako puntuazio txartelak",
|
||||
"cannot_delete": "Ezin da ezabatu erosketa agindu aktiboak dituen hornitzailea"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import productionEs from './es/production.json';
|
||||
import equipmentEs from './es/equipment.json';
|
||||
import landingEs from './es/landing.json';
|
||||
import settingsEs from './es/settings.json';
|
||||
import ajustesEs from './es/ajustes.json';
|
||||
|
||||
// English translations
|
||||
import commonEn from './en/common.json';
|
||||
@@ -27,6 +28,7 @@ import productionEn from './en/production.json';
|
||||
import equipmentEn from './en/equipment.json';
|
||||
import landingEn from './en/landing.json';
|
||||
import settingsEn from './en/settings.json';
|
||||
import ajustesEn from './en/ajustes.json';
|
||||
|
||||
// Basque translations
|
||||
import commonEu from './eu/common.json';
|
||||
@@ -42,10 +44,11 @@ import productionEu from './eu/production.json';
|
||||
import equipmentEu from './eu/equipment.json';
|
||||
import landingEu from './eu/landing.json';
|
||||
import settingsEu from './eu/settings.json';
|
||||
import ajustesEu from './eu/ajustes.json';
|
||||
|
||||
// Translation resources by language
|
||||
export const resources = {
|
||||
es: {
|
||||
es: {
|
||||
common: commonEs,
|
||||
auth: authEs,
|
||||
inventory: inventoryEs,
|
||||
@@ -59,6 +62,7 @@ export const resources = {
|
||||
equipment: equipmentEs,
|
||||
landing: landingEs,
|
||||
settings: settingsEs,
|
||||
ajustes: ajustesEs,
|
||||
},
|
||||
en: {
|
||||
common: commonEn,
|
||||
@@ -74,6 +78,7 @@ export const resources = {
|
||||
equipment: equipmentEn,
|
||||
landing: landingEn,
|
||||
settings: settingsEn,
|
||||
ajustes: ajustesEn,
|
||||
},
|
||||
eu: {
|
||||
common: commonEu,
|
||||
@@ -89,6 +94,7 @@ export const resources = {
|
||||
equipment: equipmentEu,
|
||||
landing: landingEu,
|
||||
settings: settingsEu,
|
||||
ajustes: ajustesEu,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -125,7 +131,7 @@ export const languageConfig = {
|
||||
};
|
||||
|
||||
// Namespaces available in translations
|
||||
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings'] as const;
|
||||
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes'] as const;
|
||||
export type Namespace = typeof namespaces[number];
|
||||
|
||||
// Helper function to get language display name
|
||||
@@ -139,7 +145,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang
|
||||
};
|
||||
|
||||
// Export individual language modules for direct imports
|
||||
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, recipesEs, errorsEs, equipmentEs, landingEs, settingsEs };
|
||||
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, recipesEs, errorsEs, equipmentEs, landingEs, settingsEs, ajustesEs };
|
||||
|
||||
// Default export with all translations
|
||||
export default resources;
|
||||
export default resources;
|
||||
|
||||
@@ -6,7 +6,7 @@ import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
||||
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
||||
import PendingPOApprovals from '../../components/domain/dashboard/PendingPOApprovals';
|
||||
import TodayProduction from '../../components/domain/dashboard/TodayProduction';
|
||||
import SustainabilityWidget from '../../components/domain/sustainability/SustainabilityWidget';
|
||||
// Sustainability widget removed - now using stats in StatsGrid
|
||||
import { EditViewModal } from '../../components/ui';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
X,
|
||||
ShoppingCart,
|
||||
Factory,
|
||||
Timer
|
||||
Timer,
|
||||
TrendingDown,
|
||||
Leaf
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -259,6 +261,28 @@ const DashboardPage: React.FC = () => {
|
||||
subtitle: dashboardStats.criticalStock > 0
|
||||
? t('dashboard:messages.action_required', 'Action required')
|
||||
: t('dashboard:messages.stock_healthy', 'Stock levels healthy')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.waste_reduction', 'Waste Reduction'),
|
||||
value: dashboardStats.wasteReductionPercentage
|
||||
? `${Math.abs(dashboardStats.wasteReductionPercentage).toFixed(1)}%`
|
||||
: '0%',
|
||||
icon: TrendingDown,
|
||||
variant: (dashboardStats.wasteReductionPercentage || 0) >= 15 ? ('success' as const) : ('info' as const),
|
||||
trend: undefined,
|
||||
subtitle: (dashboardStats.wasteReductionPercentage || 0) >= 15
|
||||
? t('dashboard:messages.excellent_progress', 'Excellent progress!')
|
||||
: t('dashboard:messages.keep_improving', 'Keep improving')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.monthly_savings', 'Monthly Savings'),
|
||||
value: dashboardStats.monthlySavingsEur
|
||||
? `€${dashboardStats.monthlySavingsEur.toFixed(0)}`
|
||||
: '€0',
|
||||
icon: Leaf,
|
||||
variant: 'success' as const,
|
||||
trend: undefined,
|
||||
subtitle: t('dashboard:messages.from_sustainability', 'From sustainability')
|
||||
}
|
||||
];
|
||||
}, [dashboardStats, t]);
|
||||
@@ -382,8 +406,8 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Critical Metrics using StatsGrid */}
|
||||
<div data-tour="dashboard-stats">
|
||||
{isLoadingStats ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
|
||||
@@ -399,7 +423,7 @@ const DashboardPage: React.FC = () => {
|
||||
) : (
|
||||
<StatsGrid
|
||||
stats={criticalStats}
|
||||
columns={4}
|
||||
columns={6}
|
||||
gap="lg"
|
||||
className="mb-6"
|
||||
/>
|
||||
@@ -413,19 +437,7 @@ const DashboardPage: React.FC = () => {
|
||||
<RealTimeAlerts />
|
||||
</div>
|
||||
|
||||
{/* 2. Sustainability Impact - NEW! */}
|
||||
<div data-tour="sustainability-widget">
|
||||
<SustainabilityWidget
|
||||
days={30}
|
||||
onViewDetails={() => navigate('/app/analytics/sustainability')}
|
||||
onExportReport={() => {
|
||||
// TODO: Implement export modal
|
||||
console.log('Export sustainability report');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. Pending PO Approvals - What purchase orders need approval? */}
|
||||
{/* 2. Pending PO Approvals - What purchase orders need approval? */}
|
||||
<div data-tour="pending-po-approvals">
|
||||
<PendingPOApprovals
|
||||
onApprovePO={handleApprovePO}
|
||||
@@ -436,7 +448,7 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Today's Production - What needs to be produced today? */}
|
||||
{/* 3. Today's Production - What needs to be produced today? */}
|
||||
<div data-tour="today-production">
|
||||
<TodayProduction
|
||||
onStartBatch={handleStartBatch}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Brain, TrendingUp, AlertTriangle, Lightbulb, Target, Zap, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { Button, Card, Badge, StatsGrid } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
@@ -109,6 +109,8 @@ const AIInsightsPage: React.FC = () => {
|
||||
actionableInsights: insights.filter(i => i.actionable).length,
|
||||
averageConfidence: Math.round(insights.reduce((sum, i) => sum + i.confidence, 0) / insights.length),
|
||||
highPriorityInsights: insights.filter(i => i.priority === 'high').length,
|
||||
mediumPriorityInsights: insights.filter(i => i.priority === 'medium').length,
|
||||
lowPriorityInsights: insights.filter(i => i.priority === 'low').length,
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
@@ -125,10 +127,10 @@ const AIInsightsPage: React.FC = () => {
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'success';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,55 +175,47 @@ const AIInsightsPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* AI Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Insights</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-info)]">{aiMetrics.totalInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Brain className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Accionables</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{aiMetrics.actionableInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Confianza Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{aiMetrics.averageConfidence}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Target className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Alta Prioridad</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-error)]">{aiMetrics.highPriorityInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-[var(--color-error)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: "Total Insights",
|
||||
value: aiMetrics.totalInsights,
|
||||
icon: Brain,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Accionables",
|
||||
value: aiMetrics.actionableInsights,
|
||||
icon: Zap,
|
||||
variant: "success"
|
||||
},
|
||||
{
|
||||
title: "Confianza Promedio",
|
||||
value: `${aiMetrics.averageConfidence}%`,
|
||||
icon: Target,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Alta Prioridad",
|
||||
value: aiMetrics.highPriorityInsights,
|
||||
icon: AlertTriangle,
|
||||
variant: "error"
|
||||
},
|
||||
{
|
||||
title: "Media Prioridad",
|
||||
value: aiMetrics.mediumPriorityInsights,
|
||||
icon: TrendingUp,
|
||||
variant: "warning"
|
||||
},
|
||||
{
|
||||
title: "Baja Prioridad",
|
||||
value: aiMetrics.lowPriorityInsights,
|
||||
icon: Lightbulb,
|
||||
variant: "success"
|
||||
}
|
||||
]}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* Category Filter */}
|
||||
<Card className="p-6">
|
||||
@@ -256,9 +250,9 @@ const AIInsightsPage: React.FC = () => {
|
||||
<Badge variant={getPriorityColor(insight.priority)}>
|
||||
{insight.priority === 'high' ? 'Alta' : insight.priority === 'medium' ? 'Media' : 'Baja'} Prioridad
|
||||
</Badge>
|
||||
<Badge variant="gray">{insight.confidence}% confianza</Badge>
|
||||
<Badge variant="secondary">{insight.confidence}% confianza</Badge>
|
||||
{insight.actionable && (
|
||||
<Badge variant="blue">Accionable</Badge>
|
||||
<Badge variant="primary">Accionable</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,4 +304,4 @@ const AIInsightsPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPage;
|
||||
export default AIInsightsPage;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ShoppingCart, TrendingUp, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { ShoppingCart, TrendingUp, Clock, AlertTriangle, Brain } from 'lucide-react';
|
||||
import { Card, Input } from '../../../../../components/ui';
|
||||
import type { ProcurementSettings } from '../../../../../api/types/settings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProcurementSettingsCardProps {
|
||||
settings: ProcurementSettings;
|
||||
@@ -14,6 +15,8 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('ajustes');
|
||||
|
||||
const handleChange = (field: keyof ProcurementSettings) => (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
@@ -27,7 +30,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6 flex items-center">
|
||||
<ShoppingCart className="w-5 h-5 mr-2 text-[var(--color-primary)]" />
|
||||
Compras y Aprovisionamiento
|
||||
{t('procurement.title')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -35,7 +38,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
Auto-Aprobación de Órdenes de Compra
|
||||
{t('procurement.auto_approval')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 pl-6">
|
||||
<div className="flex items-center gap-2 md:col-span-2 xl:col-span-3">
|
||||
@@ -48,13 +51,13 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
className="rounded border-[var(--border-primary)]"
|
||||
/>
|
||||
<label htmlFor="auto_approve_enabled" className="text-sm text-[var(--text-secondary)]">
|
||||
Habilitar auto-aprobación de órdenes de compra
|
||||
{t('procurement.auto_approve_enabled')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
label="Umbral de Auto-Aprobación (EUR)"
|
||||
label={t('procurement.auto_approve_threshold')}
|
||||
value={settings.auto_approve_threshold_eur}
|
||||
onChange={handleChange('auto_approve_threshold_eur')}
|
||||
disabled={disabled || !settings.auto_approve_enabled}
|
||||
@@ -66,7 +69,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
label="Puntuación Mínima de Proveedor"
|
||||
label={t('procurement.min_supplier_score')}
|
||||
value={settings.auto_approve_min_supplier_score}
|
||||
onChange={handleChange('auto_approve_min_supplier_score')}
|
||||
disabled={disabled || !settings.auto_approve_enabled}
|
||||
@@ -86,7 +89,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
className="rounded border-[var(--border-primary)]"
|
||||
/>
|
||||
<label htmlFor="require_approval_new_suppliers" className="text-sm text-[var(--text-secondary)]">
|
||||
Requiere aprobación para nuevos proveedores
|
||||
{t('procurement.require_approval_new_suppliers')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +103,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
className="rounded border-[var(--border-primary)]"
|
||||
/>
|
||||
<label htmlFor="require_approval_critical_items" className="text-sm text-[var(--text-secondary)]">
|
||||
Requiere aprobación para artículos críticos
|
||||
{t('procurement.require_approval_critical_items')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,12 +113,12 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Planificación y Previsión
|
||||
{t('procurement.planning')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 pl-6">
|
||||
<Input
|
||||
type="number"
|
||||
label="Tiempo de Entrega (días)"
|
||||
label={t('procurement.lead_time_days')}
|
||||
value={settings.procurement_lead_time_days}
|
||||
onChange={handleChange('procurement_lead_time_days')}
|
||||
disabled={disabled}
|
||||
@@ -127,7 +130,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
label="Días de Previsión de Demanda"
|
||||
label={t('procurement.demand_forecast_days')}
|
||||
value={settings.demand_forecast_days}
|
||||
onChange={handleChange('demand_forecast_days')}
|
||||
disabled={disabled}
|
||||
@@ -139,7 +142,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
label="Stock de Seguridad (%)"
|
||||
label={t('procurement.safety_stock_percentage')}
|
||||
value={settings.safety_stock_percentage}
|
||||
onChange={handleChange('safety_stock_percentage')}
|
||||
disabled={disabled}
|
||||
@@ -155,12 +158,12 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Flujo de Aprobación
|
||||
{t('procurement.workflow')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-6">
|
||||
<Input
|
||||
type="number"
|
||||
label="Recordatorio de Aprobación (horas)"
|
||||
label={t('procurement.approval_reminder_hours')}
|
||||
value={settings.po_approval_reminder_hours}
|
||||
onChange={handleChange('po_approval_reminder_hours')}
|
||||
disabled={disabled}
|
||||
@@ -172,7 +175,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
label="Escalación Crítica (horas)"
|
||||
label={t('procurement.critical_escalation_hours')}
|
||||
value={settings.po_critical_escalation_hours}
|
||||
onChange={handleChange('po_critical_escalation_hours')}
|
||||
disabled={disabled}
|
||||
@@ -183,6 +186,110 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Procurement Calculation */}
|
||||
<div className="border-t border-[var(--border-primary)] pt-6">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
{t('procurement.smart_procurement')}
|
||||
</h4>
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="use_reorder_rules"
|
||||
checked={settings.use_reorder_rules}
|
||||
onChange={handleChange('use_reorder_rules')}
|
||||
disabled={disabled}
|
||||
className="rounded border-[var(--border-primary)] mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="use_reorder_rules" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{t('procurement.use_reorder_rules')}
|
||||
</label>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
{t('procurement.use_reorder_rules_desc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="economic_rounding"
|
||||
checked={settings.economic_rounding}
|
||||
onChange={handleChange('economic_rounding')}
|
||||
disabled={disabled}
|
||||
className="rounded border-[var(--border-primary)] mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="economic_rounding" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{t('procurement.economic_rounding')}
|
||||
</label>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
{t('procurement.economic_rounding_desc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="respect_storage_limits"
|
||||
checked={settings.respect_storage_limits}
|
||||
onChange={handleChange('respect_storage_limits')}
|
||||
disabled={disabled}
|
||||
className="rounded border-[var(--border-primary)] mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="respect_storage_limits" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{t('procurement.respect_storage_limits')}
|
||||
</label>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
{t('procurement.respect_storage_limits_desc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="use_supplier_minimums"
|
||||
checked={settings.use_supplier_minimums}
|
||||
onChange={handleChange('use_supplier_minimums')}
|
||||
disabled={disabled}
|
||||
className="rounded border-[var(--border-primary)] mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="use_supplier_minimums" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{t('procurement.use_supplier_minimums')}
|
||||
</label>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
{t('procurement.use_supplier_minimums_desc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="optimize_price_tiers"
|
||||
checked={settings.optimize_price_tiers}
|
||||
onChange={handleChange('optimize_price_tiers')}
|
||||
disabled={disabled}
|
||||
className="rounded border-[var(--border-primary)] mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="optimize_price_tiers" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{t('procurement.optimize_price_tiers')}
|
||||
</label>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
{t('procurement.optimize_price_tiers_desc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
|
||||
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
|
||||
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
@@ -116,7 +116,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
hasModel: !!model,
|
||||
model,
|
||||
isTraining,
|
||||
lastTrainingDate: model?.created_at,
|
||||
lastTrainingDate: model?.created_at || undefined,
|
||||
accuracy: model ?
|
||||
(model.training_metrics?.mape !== undefined ? (100 - model.training_metrics.mape) :
|
||||
(model as any).mape !== undefined ? (100 - (model as any).mape) :
|
||||
@@ -209,13 +209,12 @@ const ModelsConfigPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Configuración de Modelos IA"
|
||||
description="Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente"
|
||||
/>
|
||||
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
@@ -232,39 +231,33 @@ const ModelsConfigPage: React.FC = () => {
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
title: 'Modelos Huérfanos',
|
||||
value: orphanedModels.length,
|
||||
icon: AlertCircle,
|
||||
variant: 'info',
|
||||
title: 'Modelos Activos',
|
||||
value: modelStatuses.filter(s => s.status === 'active').length,
|
||||
icon: CheckCircle,
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
title: 'Precisión Promedio',
|
||||
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${Number(statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||
icon: TrendingUp,
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
title: 'Total Modelos',
|
||||
value: modelStatuses.length,
|
||||
icon: Brain,
|
||||
variant: 'info',
|
||||
},
|
||||
{
|
||||
title: 'Modelos Huérfanos',
|
||||
value: orphanedModels.length,
|
||||
icon: AlertCircle,
|
||||
variant: 'error',
|
||||
},
|
||||
]}
|
||||
columns={4}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* Orphaned Models Warning */}
|
||||
{orphanedModels.length > 0 && (
|
||||
<Card className="p-4 bg-orange-50 border-orange-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-900 mb-1">
|
||||
Modelos Huérfanos Detectados
|
||||
</h4>
|
||||
<p className="text-sm text-orange-700">
|
||||
Se encontraron {orphanedModels.length} modelos entrenados para ingredientes que ya no existen en el inventario.
|
||||
Estos modelos pueden ser eliminados para optimizar el espacio de almacenamiento.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<SearchAndFilter
|
||||
searchValue={searchTerm}
|
||||
@@ -289,18 +282,16 @@ const ModelsConfigPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Models Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 col-span-full">
|
||||
<Brain className="w-12 h-12 text-[var(--color-secondary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron ingredientes
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] text-center">
|
||||
No hay ingredientes que coincidan con los filtros aplicados.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title="No se encontraron ingredientes"
|
||||
description="No hay ingredientes que coincidan con los filtros aplicados."
|
||||
className="col-span-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{(
|
||||
filteredStatuses.map((status) => {
|
||||
// Get status configuration for the StatusCard
|
||||
const statusConfig = {
|
||||
@@ -335,7 +326,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
id={status.ingredient.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={status.ingredient.name}
|
||||
subtitle={status.ingredient.category}
|
||||
subtitle={status.ingredient.category || undefined}
|
||||
primaryValue={status.accuracy ? status.accuracy.toFixed(1) : 'N/A'}
|
||||
primaryValueLabel="Precisión"
|
||||
secondaryInfo={status.lastTrainingDate ? {
|
||||
@@ -371,7 +362,8 @@ const ModelsConfigPage: React.FC = () => {
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Training Modal */}
|
||||
<Modal
|
||||
@@ -463,4 +455,4 @@ const ModelsConfigPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsConfigPage;
|
||||
export default ModelsConfigPage;
|
||||
|
||||
@@ -9,11 +9,7 @@ import { QualityTemplateManager } from '../../../../components/domain/production
|
||||
* that are used during production processes.
|
||||
*/
|
||||
const QualityTemplatesPage: React.FC = () => {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<QualityTemplateManager />
|
||||
</div>
|
||||
);
|
||||
return <QualityTemplateManager />;
|
||||
};
|
||||
|
||||
export default QualityTemplatesPage;
|
||||
@@ -0,0 +1,580 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Leaf,
|
||||
TrendingDown,
|
||||
Euro,
|
||||
Award,
|
||||
Target,
|
||||
Droplets,
|
||||
TreeDeciduous,
|
||||
Calendar,
|
||||
Download,
|
||||
FileText,
|
||||
Info,
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { StatsGrid, Button, Card, Tooltip } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { useSustainabilityMetrics } from '../../../../api/hooks/sustainability';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
const SustainabilityPage: React.FC = () => {
|
||||
const { t } = useTranslation(['sustainability', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Date range state (default to last 30 days)
|
||||
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
|
||||
|
||||
// Fetch sustainability metrics
|
||||
const {
|
||||
data: metrics,
|
||||
isLoading,
|
||||
error
|
||||
} = useSustainabilityMetrics(tenantId, dateRange.start, dateRange.end, {
|
||||
enabled: !!tenantId
|
||||
});
|
||||
|
||||
// Build stats for StatsGrid
|
||||
const sustainabilityStats = useMemo(() => {
|
||||
if (!metrics) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: t('sustainability:stats.total_waste_reduced', 'Total Waste Reduced'),
|
||||
value: `${metrics.waste_metrics.total_waste_kg.toFixed(0)} kg`,
|
||||
icon: TrendingDown,
|
||||
variant: 'success' as const,
|
||||
subtitle: t('sustainability:stats.from_baseline', 'From baseline'),
|
||||
trend: metrics.waste_metrics.waste_percentage < 25 ? {
|
||||
value: Math.abs(25 - metrics.waste_metrics.waste_percentage),
|
||||
direction: 'down' as const,
|
||||
label: t('sustainability:stats.vs_industry', 'vs industry avg')
|
||||
} : undefined
|
||||
},
|
||||
{
|
||||
title: t('sustainability:stats.waste_reduction_percentage', 'Waste Reduction'),
|
||||
value: `${Math.abs(metrics.sdg_compliance.sdg_12_3.reduction_achieved).toFixed(1)}%`,
|
||||
icon: Target,
|
||||
variant: metrics.sdg_compliance.sdg_12_3.reduction_achieved >= 15 ? ('success' as const) : ('info' as const),
|
||||
subtitle: t('sustainability:stats.progress_to_sdg', 'Progress to SDG 12.3'),
|
||||
trend: {
|
||||
value: metrics.sdg_compliance.sdg_12_3.progress_to_target,
|
||||
direction: 'up' as const,
|
||||
label: t('sustainability:stats.to_target', 'to 50% target')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('sustainability:stats.co2_avoided', 'CO₂ Avoided'),
|
||||
value: `${metrics.environmental_impact.co2_emissions.kg.toFixed(0)} kg`,
|
||||
icon: Leaf,
|
||||
variant: 'info' as const,
|
||||
subtitle: `≈ ${metrics.environmental_impact.co2_emissions.trees_to_offset.toFixed(1)} ${t('sustainability:stats.trees', 'trees')}`
|
||||
},
|
||||
{
|
||||
title: t('sustainability:stats.monthly_savings', 'Monthly Savings'),
|
||||
value: `€${metrics.financial_impact.potential_monthly_savings.toFixed(0)}`,
|
||||
icon: Euro,
|
||||
variant: 'success' as const,
|
||||
subtitle: t('sustainability:stats.from_waste_reduction', 'From waste reduction')
|
||||
},
|
||||
{
|
||||
title: t('sustainability:stats.sdg_progress', 'SDG 12.3 Progress'),
|
||||
value: `${Math.round(metrics.sdg_compliance.sdg_12_3.progress_to_target)}%`,
|
||||
icon: Award,
|
||||
variant: metrics.sdg_compliance.sdg_12_3.status === 'sdg_compliant' ? ('success' as const) :
|
||||
metrics.sdg_compliance.sdg_12_3.status === 'on_track' ? ('info' as const) : ('warning' as const),
|
||||
subtitle: metrics.sdg_compliance.sdg_12_3.status_label
|
||||
},
|
||||
{
|
||||
title: t('sustainability:stats.grant_programs', 'Grant Programs'),
|
||||
value: Object.values(metrics.grant_readiness.grant_programs).filter(p => p.eligible).length.toString(),
|
||||
icon: FileText,
|
||||
variant: 'info' as const,
|
||||
subtitle: t('sustainability:stats.eligible', 'Eligible programs')
|
||||
}
|
||||
];
|
||||
}, [metrics, t]);
|
||||
|
||||
// Get SDG status color
|
||||
const getSDGStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sdg_compliant':
|
||||
return 'bg-green-500/10 text-green-600 border-green-500/20';
|
||||
case 'on_track':
|
||||
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
|
||||
case 'progressing':
|
||||
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-600 border-gray-500/20';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title={t('sustainability:page.title', 'Sostenibilidad')}
|
||||
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
|
||||
/>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !metrics) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title={t('sustainability:page.title', 'Sostenibilidad')}
|
||||
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
|
||||
/>
|
||||
<Card className="p-6">
|
||||
<div className="text-center py-8">
|
||||
<Leaf className="w-12 h-12 mx-auto mb-3 text-[var(--text-secondary)] opacity-50" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:errors.load_failed', 'Unable to load sustainability metrics')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title={t('sustainability:page.title', 'Sostenibilidad')}
|
||||
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
|
||||
actions={[
|
||||
{
|
||||
id: "export-report",
|
||||
label: t('sustainability:actions.export_report', 'Exportar Informe'),
|
||||
icon: Download,
|
||||
onClick: () => {
|
||||
// TODO: Implement export
|
||||
console.log('Export sustainability report');
|
||||
},
|
||||
variant: "outline",
|
||||
size: "sm"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={sustainabilityStats}
|
||||
columns={3}
|
||||
gap="lg"
|
||||
/>
|
||||
|
||||
{/* Main Content Sections */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Waste Analytics Section */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.waste_analytics', 'Análisis de Residuos')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.waste_analytics', 'Información detallada sobre los residuos generados en la producción')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.waste_subtitle', 'Desglose de residuos por tipo')}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingDown className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Waste breakdown */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:waste.production', 'Residuos de Producción')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{metrics.waste_metrics.production_waste_kg.toFixed(1)} kg
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:waste.expired', 'Producto Expirado')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{metrics.waste_metrics.expired_waste_kg.toFixed(1)} kg
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:waste.total', 'Total')}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{metrics.waste_metrics.total_waste_kg.toFixed(1)} kg
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:waste.percentage', 'Porcentaje de Residuos')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{metrics.waste_metrics.waste_percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Impact */}
|
||||
<div className="mt-4 p-4 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-medium text-blue-700 dark:text-blue-400">
|
||||
{t('sustainability:ai.impact_title', 'Impacto de IA')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-300">
|
||||
{t('sustainability:ai.waste_avoided', 'Residuos evitados')}: <strong>{metrics.avoided_waste.waste_avoided_kg.toFixed(1)} kg</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600/80 dark:text-blue-300/80 mt-1">
|
||||
{t('sustainability:ai.batches', 'Lotes asistidos por IA')}: {metrics.avoided_waste.ai_assisted_batches}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Environmental Impact Section */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.environmental_impact', 'Impacto Ambiental')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.environmental_impact', 'Métricas de huella ambiental y su equivalencia en términos cotidianos')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.environmental_subtitle', 'Métricas de huella ambiental')}
|
||||
</p>
|
||||
</div>
|
||||
<Leaf className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* CO2 */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Leaf className="w-4 h-4 text-green-600" />
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">CO₂</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">
|
||||
{metrics.environmental_impact.co2_emissions.kg.toFixed(0)} kg
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
≈ {metrics.environmental_impact.co2_emissions.trees_to_offset.toFixed(1)} {t('sustainability:metrics.trees', 'árboles')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Water */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Droplets className="w-4 h-4 text-cyan-600" />
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">{t('sustainability:metrics.water', 'Agua')}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">
|
||||
{metrics.environmental_impact.water_footprint.cubic_meters.toFixed(1)} m³
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{metrics.environmental_impact.water_footprint.liters.toFixed(0)} {t('common:liters', 'litros')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Land Use */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TreeDeciduous className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">{t('sustainability:metrics.land', 'Tierra')}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">
|
||||
{metrics.environmental_impact.land_use.square_meters.toFixed(0)} m²
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{metrics.environmental_impact.land_use.hectares.toFixed(3)} {t('sustainability:metrics.hectares', 'hectáreas')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Human Equivalents */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">{t('sustainability:metrics.equivalents', 'Equivalentes')}</span>
|
||||
</div>
|
||||
<div className="text-xs space-y-1 text-[var(--text-secondary)]">
|
||||
<div>🚗 {metrics.environmental_impact.human_equivalents.car_km_equivalent.toFixed(0)} km</div>
|
||||
<div>📱 {metrics.environmental_impact.human_equivalents.smartphone_charges.toFixed(0)} cargas</div>
|
||||
<div>🚿 {metrics.environmental_impact.human_equivalents.showers_equivalent.toFixed(0)} duchas</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SDG Compliance & Grant Readiness */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* SDG 12.3 Compliance */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.sdg_compliance', 'Cumplimiento SDG 12.3')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.sdg_compliance', 'Progreso hacia el objetivo de desarrollo sostenible de la ONU para reducir residuos alimentarios')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.sdg_subtitle', 'Progreso hacia objetivo ONU')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full border text-xs font-medium ${getSDGStatusColor(metrics.sdg_compliance.sdg_12_3.status)}`}>
|
||||
{metrics.sdg_compliance.sdg_12_3.status_label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t('sustainability:sdg.progress_label', 'Progreso al Objetivo')}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--color-primary)]">
|
||||
{Math.round(metrics.sdg_compliance.sdg_12_3.progress_to_target)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(metrics.sdg_compliance.sdg_12_3.progress_to_target, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-2">
|
||||
{t('sustainability:sdg.target_note', 'Objetivo: 50% reducción de residuos para 2030')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sdg.baseline', 'Línea Base')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{metrics.sdg_compliance.sdg_12_3.baseline_waste_percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sdg.current', 'Actual')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{metrics.sdg_compliance.sdg_12_3.current_waste_percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sdg.reduction', 'Reducción Lograda')}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-green-600">
|
||||
{Math.abs(metrics.sdg_compliance.sdg_12_3.reduction_achieved).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sdg.certification_ready', 'Listo para Certificación')}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${metrics.sdg_compliance.certification_ready ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{metrics.sdg_compliance.certification_ready ? t('common:yes', 'Sí') : t('common:no', 'No')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Grant Readiness */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.grant_readiness', 'Subvenciones Disponibles')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.grant_readiness', 'Programas de financiación disponibles para empresas españolas según la Ley 1/2025 de prevención de residuos')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.grant_subtitle', 'Programas de financiación elegibles')}
|
||||
</p>
|
||||
</div>
|
||||
<Award className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
|
||||
{/* Overall Readiness */}
|
||||
<div className="mb-4 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{t('sustainability:grant.overall_readiness', 'Preparación General')}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-amber-600 dark:text-amber-400">
|
||||
{Math.round(metrics.grant_readiness.overall_readiness_percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grant Programs List */}
|
||||
<div className="space-y-3">
|
||||
{Object.entries(metrics.grant_readiness.grant_programs).map(([key, program]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`p-3 rounded-lg border ${
|
||||
program.eligible
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/20 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${
|
||||
program.eligible ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{key.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{program.eligible && (
|
||||
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-700 dark:text-green-400 rounded-full">
|
||||
{t('sustainability:grant.eligible', 'Elegible')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{program.funding_eur && program.funding_eur > 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('sustainability:grant.funding', 'Financiación')}: €{program.funding_eur.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-1 rounded ${
|
||||
program.confidence === 'high'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: program.confidence === 'medium'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}>
|
||||
{program.confidence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Spain Compliance */}
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('sustainability:grant.spain_compliance', 'Cumplimiento España')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
{metrics.grant_readiness.spain_compliance?.law_1_2025 ? '✅' : '❌'}
|
||||
<span className="text-[var(--text-secondary)]">Ley 1/2025</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{metrics.grant_readiness.spain_compliance?.circular_economy_strategy ? '✅' : '❌'}
|
||||
<span className="text-[var(--text-secondary)]">Economía Circular 2030</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Financial Impact */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.financial_impact', 'Impacto Financiero')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.financial_impact', 'Costes asociados a residuos y ahorros potenciales mediante la reducción de desperdicio')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.financial_subtitle', 'Costes y ahorros de sostenibilidad')}
|
||||
</p>
|
||||
</div>
|
||||
<Euro className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('sustainability:financial.waste_cost', 'Coste de Residuos')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
€{metrics.financial_impact.waste_cost_eur.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
€{metrics.financial_impact.cost_per_kg.toFixed(2)}/kg
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2">
|
||||
{t('sustainability:financial.monthly_savings', 'Ahorro Mensual')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
€{metrics.financial_impact.potential_monthly_savings.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-green-600/80 dark:text-green-400/80 mt-1">
|
||||
{t('sustainability:financial.from_reduction', 'Por reducción')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('sustainability:financial.annual_projection', 'Proyección Anual')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{metrics.financial_impact.annual_projection.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('sustainability:financial.estimated', 'Estimado')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs font-medium text-blue-700 dark:text-blue-400 mb-2">
|
||||
{t('sustainability:financial.roi', 'ROI de IA')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
€{(metrics.avoided_waste.waste_avoided_kg * metrics.financial_impact.cost_per_kg).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-blue-600/80 dark:text-blue-400/80 mt-1">
|
||||
{t('sustainability:financial.ai_savings', 'Ahorrado por IA')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SustainabilityPage;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -14,12 +15,14 @@ import {
|
||||
|
||||
// Import AddStockModal separately since we need it for adding batches
|
||||
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
|
||||
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
||||
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
||||
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
|
||||
import { subscriptionService } from '../../../../api/services/subscription';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
@@ -34,6 +37,7 @@ const InventoryPage: React.FC = () => {
|
||||
const [showAddBatch, setShowAddBatch] = useState(false);
|
||||
|
||||
const tenantId = useTenantId();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Debug tenant ID
|
||||
console.log('🔍 [InventoryPage] Tenant ID from hook:', tenantId);
|
||||
@@ -47,12 +51,14 @@ const InventoryPage: React.FC = () => {
|
||||
const addStockMutation = useAddStock();
|
||||
const consumeStockMutation = useConsumeStock();
|
||||
const updateIngredientMutation = useUpdateIngredient();
|
||||
const updateStockMutation = useUpdateStock();
|
||||
|
||||
// API Data
|
||||
const {
|
||||
data: ingredientsData,
|
||||
isLoading: ingredientsLoading,
|
||||
error: ingredientsError
|
||||
error: ingredientsError,
|
||||
isRefetching: isRefetchingIngredients
|
||||
} = useIngredients(tenantId, { search: searchTerm || undefined });
|
||||
|
||||
|
||||
@@ -85,7 +91,8 @@ const InventoryPage: React.FC = () => {
|
||||
const {
|
||||
data: stockLotsData,
|
||||
isLoading: stockLotsLoading,
|
||||
error: stockLotsError
|
||||
error: stockLotsError,
|
||||
isRefetching: isRefetchingBatches
|
||||
} = useStockByIngredient(
|
||||
tenantId,
|
||||
selectedItem?.id || '',
|
||||
@@ -283,12 +290,30 @@ const InventoryPage: React.FC = () => {
|
||||
});
|
||||
}, [ingredients, searchTerm, statusFilter, categoryFilter]);
|
||||
|
||||
// Helper function to get category display name
|
||||
// Helper function to get translated category display name
|
||||
const getCategoryDisplayName = (category?: string): string => {
|
||||
if (!category) return 'Sin categoría';
|
||||
if (!category) return t('inventory:categories.all', 'Sin categoría');
|
||||
|
||||
// Try ingredient category translation first
|
||||
const ingredientTranslation = t(`inventory:enums.ingredient_category.${category}`, { defaultValue: '' });
|
||||
if (ingredientTranslation) return ingredientTranslation;
|
||||
|
||||
// Try product category translation
|
||||
const productTranslation = t(`inventory:enums.product_category.${category}`, { defaultValue: '' });
|
||||
if (productTranslation) return productTranslation;
|
||||
|
||||
// Fallback to raw category if no translation found
|
||||
return category;
|
||||
};
|
||||
|
||||
// Helper function to get translated unit display name
|
||||
const getUnitDisplayName = (unit?: string): string => {
|
||||
if (!unit) return '';
|
||||
|
||||
// Translate unit of measure
|
||||
return t(`inventory:enums.unit_of_measure.${unit}`, { defaultValue: unit });
|
||||
};
|
||||
|
||||
// Focused action handlers
|
||||
const handleShowInfo = (ingredient: IngredientResponse) => {
|
||||
setSelectedItem(ingredient);
|
||||
@@ -325,7 +350,7 @@ const InventoryPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
// Check subscription limits before creating
|
||||
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'inventory_items', 1);
|
||||
const usageCheck = await subscriptionService.checkQuotaLimit(tenantId, 'inventory_items', 1);
|
||||
|
||||
if (!usageCheck.allowed) {
|
||||
throw new Error(
|
||||
@@ -397,6 +422,22 @@ const InventoryPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Refetch callbacks for wait-for-refetch pattern
|
||||
const handleIngredientSaveComplete = async () => {
|
||||
if (!tenantId) return;
|
||||
// Invalidate ingredients query to trigger refetch
|
||||
await queryClient.invalidateQueries(['ingredients', tenantId]);
|
||||
};
|
||||
|
||||
const handleBatchSaveComplete = async () => {
|
||||
if (!tenantId || !selectedItem?.id) return;
|
||||
// Invalidate both ingredients (for updated stock totals) and stock lots queries
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(['ingredients', tenantId]),
|
||||
queryClient.invalidateQueries(['stock', 'by-ingredient', tenantId, selectedItem.id])
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const inventoryStats = useMemo(() => {
|
||||
@@ -516,7 +557,7 @@ const InventoryPage: React.FC = () => {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Inventario"
|
||||
description="Controla el stock de ingredientes y materias primas"
|
||||
description="Gestiona stock, costos, lotes y alertas de ingredientes"
|
||||
actions={[
|
||||
{
|
||||
id: "add-new-item",
|
||||
@@ -598,7 +639,7 @@ const InventoryPage: React.FC = () => {
|
||||
title={ingredient.name}
|
||||
subtitle={getCategoryDisplayName(ingredient.category)}
|
||||
primaryValue={currentStock}
|
||||
primaryValueLabel={ingredient.unit_of_measure}
|
||||
primaryValueLabel={getUnitDisplayName(ingredient.unit_of_measure)}
|
||||
secondaryInfo={{
|
||||
label: 'Valor',
|
||||
value: formatters.currency(totalValue)
|
||||
@@ -610,7 +651,7 @@ const InventoryPage: React.FC = () => {
|
||||
} : undefined}
|
||||
onClick={() => handleShowInfo(ingredient)}
|
||||
actions={[
|
||||
// Primary action - View item details
|
||||
// Primary action - View item details (left side)
|
||||
{
|
||||
label: 'Ver Detalles',
|
||||
icon: Eye,
|
||||
@@ -618,27 +659,27 @@ const InventoryPage: React.FC = () => {
|
||||
priority: 'primary',
|
||||
onClick: () => handleShowInfo(ingredient)
|
||||
},
|
||||
// Stock history action - Icon button
|
||||
// Delete action - Icon button (right side)
|
||||
{
|
||||
label: 'Eliminar',
|
||||
icon: Trash2,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleDelete(ingredient)
|
||||
},
|
||||
// Stock history action - Icon button (right side)
|
||||
{
|
||||
label: 'Historial',
|
||||
icon: History,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleShowStockHistory(ingredient)
|
||||
},
|
||||
// Batch management action
|
||||
// View stock batches - Highlighted icon button (right side)
|
||||
{
|
||||
label: 'Ver Lotes',
|
||||
icon: Package,
|
||||
priority: 'secondary',
|
||||
highlighted: true,
|
||||
onClick: () => handleShowBatches(ingredient)
|
||||
},
|
||||
// Destructive action
|
||||
{
|
||||
label: 'Eliminar',
|
||||
icon: Trash2,
|
||||
priority: 'secondary',
|
||||
destructive: true,
|
||||
onClick: () => handleDelete(ingredient)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -648,24 +689,14 @@ const InventoryPage: React.FC = () => {
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron artículos
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleNewItem}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="font-medium px-6 py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>Nuevo Artículo</span>
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No se encontraron artículos"
|
||||
description="Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario"
|
||||
actionLabel="Nuevo Artículo"
|
||||
actionIcon={Plus}
|
||||
onAction={handleNewItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Focused Action Modals */}
|
||||
@@ -691,12 +722,23 @@ const InventoryPage: React.FC = () => {
|
||||
throw new Error('Missing tenant ID or selected item');
|
||||
}
|
||||
|
||||
// Validate we have actual data to update
|
||||
if (!updatedData || Object.keys(updatedData).length === 0) {
|
||||
console.error('InventoryPage: No data provided for ingredient update');
|
||||
throw new Error('No data provided for update');
|
||||
}
|
||||
|
||||
console.log('InventoryPage: Updating ingredient with data:', updatedData);
|
||||
|
||||
return updateIngredientMutation.mutateAsync({
|
||||
tenantId,
|
||||
ingredientId: selectedItem.id,
|
||||
updateData: updatedData
|
||||
});
|
||||
}}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingIngredients}
|
||||
onSaveComplete={handleIngredientSaveComplete}
|
||||
/>
|
||||
|
||||
<StockHistoryModal
|
||||
@@ -719,17 +761,36 @@ const InventoryPage: React.FC = () => {
|
||||
ingredient={selectedItem}
|
||||
batches={stockLotsData || []}
|
||||
loading={stockLotsLoading}
|
||||
tenantId={tenantId}
|
||||
onAddBatch={() => {
|
||||
setShowAddBatch(true);
|
||||
}}
|
||||
onEditBatch={async (batchId, updateData) => {
|
||||
// TODO: Implement edit batch functionality
|
||||
console.log('Edit batch:', batchId, updateData);
|
||||
if (!tenantId) {
|
||||
throw new Error('No tenant ID available');
|
||||
}
|
||||
|
||||
// Validate we have actual data to update
|
||||
if (!updateData || Object.keys(updateData).length === 0) {
|
||||
console.error('InventoryPage: No data provided for batch update');
|
||||
throw new Error('No data provided for update');
|
||||
}
|
||||
|
||||
console.log('InventoryPage: Updating batch with data:', updateData);
|
||||
|
||||
return updateStockMutation.mutateAsync({
|
||||
tenantId,
|
||||
stockId: batchId,
|
||||
updateData
|
||||
});
|
||||
}}
|
||||
onMarkAsWaste={async (batchId) => {
|
||||
// TODO: Implement mark as waste functionality
|
||||
console.log('Mark as waste:', batchId);
|
||||
}}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingBatches}
|
||||
onSaveComplete={handleBatchSaveComplete}
|
||||
/>
|
||||
|
||||
<DeleteIngredientModal
|
||||
@@ -751,6 +812,9 @@ const InventoryPage: React.FC = () => {
|
||||
}}
|
||||
ingredient={selectedItem}
|
||||
onAddStock={handleAddStockSubmit}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingBatches || isRefetchingIngredients}
|
||||
onSaveComplete={handleBatchSaveComplete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -758,4 +822,4 @@ const InventoryPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
export default InventoryPage;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Wrench, Thermometer, Activity, Search, Filter, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { Badge } from '../../../../components/ui/Badge';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -164,22 +164,39 @@ const MaquinariaPage: React.FC = () => {
|
||||
{
|
||||
title: t('labels.total_equipment'),
|
||||
value: equipmentStats.total,
|
||||
variant: 'default' as const,
|
||||
icon: Settings,
|
||||
variant: 'default' as const
|
||||
},
|
||||
{
|
||||
title: t('labels.operational'),
|
||||
value: equipmentStats.operational,
|
||||
icon: CheckCircle,
|
||||
variant: 'success' as const,
|
||||
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: t('labels.warning'),
|
||||
value: equipmentStats.warning,
|
||||
variant: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: t('labels.maintenance_required'),
|
||||
value: equipmentStats.maintenance,
|
||||
variant: 'info' as const,
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
title: t('labels.down'),
|
||||
value: equipmentStats.down,
|
||||
variant: 'error' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: t('labels.active_alerts'),
|
||||
value: equipmentStats.totalAlerts,
|
||||
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const,
|
||||
icon: Bell,
|
||||
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const handleShowMaintenanceDetails = (equipment: Equipment) => {
|
||||
@@ -345,24 +362,14 @@ const MaquinariaPage: React.FC = () => {
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredEquipment.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Settings className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('common:forms.no_results')}
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{t('common:forms.empty_state')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleCreateEquipment}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
<span className="text-sm sm:text-base">{t('actions.add_equipment')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Settings}
|
||||
title={t('common:forms.no_results')}
|
||||
description={t('common:forms.empty_state')}
|
||||
actionLabel={t('actions.add_equipment')}
|
||||
actionIcon={Plus}
|
||||
onAction={handleCreateEquipment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Maintenance Details Modal */}
|
||||
@@ -558,4 +565,4 @@ const MaquinariaPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MaquinariaPage;
|
||||
export default MaquinariaPage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Loader, Euro } from 'lucide-react';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import {
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
OrderResponse,
|
||||
CustomerResponse,
|
||||
OrderCreate,
|
||||
CustomerCreate,
|
||||
CustomerUpdate,
|
||||
PaymentStatus,
|
||||
DeliveryMethod,
|
||||
PaymentMethod,
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
CustomerType,
|
||||
CustomerSegment
|
||||
} from '../../../../api/types/orders';
|
||||
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer } from '../../../../api/hooks/orders';
|
||||
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { OrderFormModal } from '../../../../components/domain/orders';
|
||||
@@ -75,6 +77,7 @@ const OrdersPage: React.FC = () => {
|
||||
// Mutations
|
||||
const createOrderMutation = useCreateOrder();
|
||||
const createCustomerMutation = useCreateCustomer();
|
||||
const updateCustomerMutation = useUpdateCustomer();
|
||||
|
||||
const orders = ordersData || [];
|
||||
const customers = customersData || [];
|
||||
@@ -206,12 +209,12 @@ const OrdersPage: React.FC = () => {
|
||||
variant: 'success' as const,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Tasa de Repetición',
|
||||
value: `${(orderStats.repeat_customers_rate * 100).toFixed(1)}%`,
|
||||
variant: 'info' as const,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Tasa de Repetición',
|
||||
value: `${Number(orderStats.repeat_customers_rate).toFixed(1)}%`,
|
||||
variant: 'info' as const,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Clientes Activos',
|
||||
value: customers.filter(c => c.is_active).length,
|
||||
@@ -398,17 +401,6 @@ const OrdersPage: React.FC = () => {
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setSelectedOrder(order);
|
||||
setIsCreating(false);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -455,17 +447,6 @@ const OrdersPage: React.FC = () => {
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setSelectedCustomer(customer);
|
||||
setIsCreating(false);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -476,31 +457,24 @@ const OrdersPage: React.FC = () => {
|
||||
|
||||
{/* Empty State */}
|
||||
{activeTab === 'orders' && filteredOrders.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron pedidos
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o crear un nuevo pedido
|
||||
</p>
|
||||
<Button onClick={() => setShowNewOrderForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No se encontraron pedidos"
|
||||
description="Intenta ajustar la búsqueda o crear un nuevo pedido"
|
||||
actionLabel="Nuevo Pedido"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowNewOrderForm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'customers' && filteredCustomers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron clientes
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o crear un nuevo cliente
|
||||
</p>
|
||||
<Button onClick={() => {
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No se encontraron clientes"
|
||||
description="Intenta ajustar la búsqueda o crear un nuevo cliente"
|
||||
actionLabel="Nuevo Cliente"
|
||||
actionIcon={Plus}
|
||||
onAction={() => {
|
||||
setSelectedCustomer({
|
||||
name: '',
|
||||
business_name: '',
|
||||
@@ -518,11 +492,8 @@ const OrdersPage: React.FC = () => {
|
||||
setIsCreating(true);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Cliente
|
||||
</Button>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Order Details Modal */}
|
||||
@@ -663,7 +634,11 @@ const OrdersPage: React.FC = () => {
|
||||
sections={sections}
|
||||
showDefaultActions={true}
|
||||
onSave={async () => {
|
||||
// TODO: Implement order update functionality
|
||||
// Note: The backend only has updateOrderStatus, not a general update endpoint
|
||||
// For now, orders can be updated via status changes using useUpdateOrderStatus
|
||||
console.log('Saving order:', selectedOrder);
|
||||
console.warn('Order update not yet implemented - only status updates are supported via useUpdateOrderStatus');
|
||||
}}
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
const newOrder = { ...selectedOrder };
|
||||
@@ -739,6 +714,13 @@ const OrdersPage: React.FC = () => {
|
||||
value: selectedCustomer.city || '',
|
||||
type: 'text',
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'País',
|
||||
value: selectedCustomer.country || 'España',
|
||||
type: 'text',
|
||||
editable: isCreating,
|
||||
highlight: false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -829,7 +811,69 @@ const OrdersPage: React.FC = () => {
|
||||
sections={sections}
|
||||
showDefaultActions={true}
|
||||
onSave={async () => {
|
||||
console.log('Saving customer:', selectedCustomer);
|
||||
if (!selectedCustomer || !tenantId) {
|
||||
console.error('Missing required data for customer save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
// Create new customer
|
||||
const customerData: CustomerCreate = {
|
||||
tenant_id: tenantId,
|
||||
customer_code: selectedCustomer.customer_code || `CUST-${Date.now()}`,
|
||||
name: selectedCustomer.name,
|
||||
business_name: selectedCustomer.business_name,
|
||||
customer_type: selectedCustomer.customer_type,
|
||||
email: selectedCustomer.email,
|
||||
phone: selectedCustomer.phone,
|
||||
city: selectedCustomer.city,
|
||||
country: selectedCustomer.country || 'España',
|
||||
is_active: selectedCustomer.is_active,
|
||||
preferred_delivery_method: selectedCustomer.preferred_delivery_method,
|
||||
payment_terms: selectedCustomer.payment_terms,
|
||||
discount_percentage: selectedCustomer.discount_percentage,
|
||||
customer_segment: selectedCustomer.customer_segment,
|
||||
priority_level: selectedCustomer.priority_level,
|
||||
special_instructions: selectedCustomer.special_instructions
|
||||
};
|
||||
|
||||
await createCustomerMutation.mutateAsync(customerData);
|
||||
console.log('Customer created successfully');
|
||||
} else {
|
||||
// Update existing customer
|
||||
const updateData: CustomerUpdate = {
|
||||
name: selectedCustomer.name,
|
||||
business_name: selectedCustomer.business_name,
|
||||
customer_type: selectedCustomer.customer_type,
|
||||
email: selectedCustomer.email,
|
||||
phone: selectedCustomer.phone,
|
||||
city: selectedCustomer.city,
|
||||
preferred_delivery_method: selectedCustomer.preferred_delivery_method,
|
||||
payment_terms: selectedCustomer.payment_terms,
|
||||
discount_percentage: selectedCustomer.discount_percentage,
|
||||
customer_segment: selectedCustomer.customer_segment,
|
||||
is_active: selectedCustomer.is_active,
|
||||
special_instructions: selectedCustomer.special_instructions
|
||||
};
|
||||
|
||||
await updateCustomerMutation.mutateAsync({
|
||||
tenantId,
|
||||
customerId: selectedCustomer.id!,
|
||||
data: updateData
|
||||
});
|
||||
console.log('Customer updated successfully');
|
||||
}
|
||||
|
||||
// Close modal and reset state
|
||||
setShowForm(false);
|
||||
setSelectedCustomer(null);
|
||||
setIsCreating(false);
|
||||
setModalMode('view');
|
||||
} catch (error) {
|
||||
console.error('Error saving customer:', error);
|
||||
throw error; // Let the modal show the error
|
||||
}
|
||||
}}
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
const newCustomer = { ...selectedCustomer };
|
||||
@@ -843,6 +887,7 @@ const OrdersPage: React.FC = () => {
|
||||
'Email': 'email',
|
||||
'Teléfono': 'phone',
|
||||
'Ciudad': 'city',
|
||||
'País': 'country',
|
||||
'Código de Cliente': 'customer_code',
|
||||
'Método de Entrega Preferido': 'preferred_delivery_method',
|
||||
'Términos de Pago': 'payment_terms',
|
||||
@@ -880,4 +925,4 @@ const OrdersPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
export default OrdersPage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package, Eye, X, Send, Building2, Play, FileText, Star, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig } from '../../../../components/ui';
|
||||
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
|
||||
@@ -799,19 +799,14 @@ const ProcurementPage: React.FC = () => {
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : filteredPOs.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-400" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No hay órdenes de compra
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Comienza creando una nueva orden de compra
|
||||
</p>
|
||||
<Button onClick={() => setShowCreatePOModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nueva Orden
|
||||
</Button>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={ShoppingCart}
|
||||
title="No hay órdenes de compra"
|
||||
description="Comienza creando una nueva orden de compra"
|
||||
actionLabel="Nueva Orden"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreatePOModal(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPOs.map((po) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
|
||||
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
@@ -471,22 +471,18 @@ const ProductionPage: React.FC = () => {
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredBatches.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron lotes de producción
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{batches.length === 0
|
||||
<EmptyState
|
||||
icon={ChefHat}
|
||||
title="No se encontraron lotes de producción"
|
||||
description={
|
||||
batches.length === 0
|
||||
? 'No hay lotes de producción activos. Crea el primer lote para comenzar.'
|
||||
: 'Intenta ajustar la búsqueda o crear un nuevo lote de producción'
|
||||
}
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
actionLabel="Nueva Orden de Producción"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
|
||||
@@ -1,16 +1,143 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
|
||||
import { recipesService } from '../../../../api/services/recipes';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import type { RecipeResponse, RecipeCreate, MeasurementUnit } from '../../../../api/types/recipes';
|
||||
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
|
||||
import { MeasurementUnit } from '../../../../api/types/recipes';
|
||||
import { useQualityTemplatesForRecipe } from '../../../../api/hooks/qualityTemplates';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
|
||||
import { CreateRecipeModal } from '../../../../components/domain/recipes';
|
||||
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes';
|
||||
import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { RecipeIngredientResponse } from '../../../../api/types/recipes';
|
||||
|
||||
// Ingredients Edit Component for EditViewModal
|
||||
const IngredientsEditComponent: React.FC<{
|
||||
value: RecipeIngredientResponse[];
|
||||
onChange: (value: RecipeIngredientResponse[]) => void;
|
||||
availableIngredients: Array<{value: string; label: string}>;
|
||||
unitOptions: Array<{value: MeasurementUnit; label: string}>;
|
||||
}> = ({ value, onChange, availableIngredients, unitOptions }) => {
|
||||
const ingredientsArray = Array.isArray(value) ? value : [];
|
||||
|
||||
const addIngredient = () => {
|
||||
const newIngredient: Partial<RecipeIngredientResponse> = {
|
||||
id: `temp-${Date.now()}`, // Temporary ID for new ingredients
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
unit: MeasurementUnit.GRAMS,
|
||||
ingredient_order: ingredientsArray.length + 1,
|
||||
is_optional: false
|
||||
};
|
||||
onChange([...ingredientsArray, newIngredient as RecipeIngredientResponse]);
|
||||
};
|
||||
|
||||
const removeIngredient = (index: number) => {
|
||||
onChange(ingredientsArray.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateIngredient = (index: number, field: keyof RecipeIngredientResponse, newValue: any) => {
|
||||
const updated = ingredientsArray.map((ingredient, i) =>
|
||||
i === index ? { ...ingredient, [field]: newValue } : ingredient
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">Lista de Ingredientes</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addIngredient}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{ingredientsArray.map((ingredient, index) => (
|
||||
<div key={ingredient.id || index} className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">Ingrediente #{index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(index)}
|
||||
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente</label>
|
||||
<select
|
||||
value={ingredient.ingredient_id}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ingredient.quantity}
|
||||
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad</label>
|
||||
<select
|
||||
value={ingredient.unit}
|
||||
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
{unitOptions.map(unit => (
|
||||
<option key={unit.value} value={unit.value}>{unit.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1 flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ingredient.is_optional}
|
||||
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
Opcional
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RecipesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -22,21 +149,29 @@ const RecipesPage: React.FC = () => {
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showQualityConfigModal, setShowQualityConfigModal] = useState(false);
|
||||
const [showQualityPrompt, setShowQualityPrompt] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [recipeToDelete, setRecipeToDelete] = useState<RecipeResponse | null>(null);
|
||||
const [newlyCreatedRecipe, setNewlyCreatedRecipe] = useState<RecipeResponse | null>(null);
|
||||
const [editedRecipe, setEditedRecipe] = useState<Partial<RecipeResponse>>({});
|
||||
const [editedIngredients, setEditedIngredients] = useState<RecipeIngredientResponse[]>([]);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Mutations
|
||||
const createRecipeMutation = useCreateRecipe(tenantId);
|
||||
const updateRecipeMutation = useUpdateRecipe(tenantId);
|
||||
const deleteRecipeMutation = useDeleteRecipe(tenantId);
|
||||
const archiveRecipeMutation = useArchiveRecipe(tenantId);
|
||||
|
||||
// API Data
|
||||
const {
|
||||
data: recipes = [],
|
||||
isLoading: recipesLoading,
|
||||
error: recipesError
|
||||
error: recipesError,
|
||||
isRefetching: isRefetchingRecipes
|
||||
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
@@ -44,6 +179,42 @@ const RecipesPage: React.FC = () => {
|
||||
isLoading: statisticsLoading
|
||||
} = useRecipeStatistics(tenantId);
|
||||
|
||||
// Fetch inventory items for ingredient name lookup
|
||||
const {
|
||||
data: inventoryItems = [],
|
||||
isLoading: inventoryLoading
|
||||
} = useIngredients(tenantId, {});
|
||||
|
||||
// Create ingredient lookup map (UUID -> name)
|
||||
const ingredientLookup = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
inventoryItems.forEach(item => {
|
||||
map[item.id] = item.name;
|
||||
});
|
||||
return map;
|
||||
}, [inventoryItems]);
|
||||
|
||||
// Available ingredients for editing
|
||||
const availableIngredients = useMemo(() =>
|
||||
(inventoryItems || [])
|
||||
.filter(item => item.product_type !== 'finished_product')
|
||||
.map(ingredient => ({
|
||||
value: ingredient.id,
|
||||
label: `${ingredient.name} (${ingredient.category || 'Sin categoría'})`
|
||||
})),
|
||||
[inventoryItems]
|
||||
);
|
||||
|
||||
// Unit options for ingredients
|
||||
const unitOptions = useMemo(() => [
|
||||
{ value: MeasurementUnit.GRAMS, label: 'g' },
|
||||
{ value: MeasurementUnit.KILOGRAMS, label: 'kg' },
|
||||
{ value: MeasurementUnit.MILLILITERS, label: 'ml' },
|
||||
{ value: MeasurementUnit.LITERS, label: 'L' },
|
||||
{ value: MeasurementUnit.UNITS, label: 'unidades' },
|
||||
{ value: MeasurementUnit.TABLESPOONS, label: 'cucharadas' },
|
||||
{ value: MeasurementUnit.TEASPOONS, label: 'cucharaditas' },
|
||||
], []);
|
||||
|
||||
const getRecipeStatusConfig = (recipe: RecipeResponse) => {
|
||||
const category = recipe.category || 'other';
|
||||
@@ -106,6 +277,33 @@ const RecipesPage: React.FC = () => {
|
||||
return `Configurado para ${configuredStages.length} etapas`;
|
||||
};
|
||||
|
||||
const getQualityIndicator = (recipe: RecipeResponse) => {
|
||||
if (!recipe.quality_check_configuration || !recipe.quality_check_configuration.stages) {
|
||||
return '❌ Sin configurar';
|
||||
}
|
||||
|
||||
const stages = recipe.quality_check_configuration.stages;
|
||||
const configuredStages = Object.keys(stages).filter(
|
||||
stage => stages[stage]?.template_ids?.length > 0
|
||||
);
|
||||
|
||||
const totalTemplates = Object.values(stages).reduce(
|
||||
(sum, stage) => sum + (stage.template_ids?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (configuredStages.length === 0) {
|
||||
return '❌ Sin configurar';
|
||||
}
|
||||
|
||||
const totalStages = Object.keys(ProcessStage).length;
|
||||
if (configuredStages.length < totalStages / 2) {
|
||||
return `⚠️ Parcial (${configuredStages.length}/${totalStages} etapas)`;
|
||||
}
|
||||
|
||||
return `✅ Configurado (${totalTemplates} controles)`;
|
||||
};
|
||||
|
||||
const filteredRecipes = useMemo(() => {
|
||||
let filtered = recipes;
|
||||
|
||||
@@ -197,11 +395,30 @@ const RecipesPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
// Handle opening a recipe (fetch full details with ingredients)
|
||||
const handleOpenRecipe = async (recipeId: string) => {
|
||||
try {
|
||||
// Fetch full recipe details including ingredients
|
||||
const fullRecipe = await recipesService.getRecipe(tenantId, recipeId);
|
||||
setSelectedRecipe(fullRecipe);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipe details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle creating a new recipe
|
||||
const handleCreateRecipe = async (recipeData: RecipeCreate) => {
|
||||
try {
|
||||
await createRecipeMutation.mutateAsync(recipeData);
|
||||
const newRecipe = await createRecipeMutation.mutateAsync(recipeData);
|
||||
setShowCreateModal(false);
|
||||
|
||||
// Fetch full recipe details and show quality prompt
|
||||
const fullRecipe = await recipesService.getRecipe(tenantId, newRecipe.id);
|
||||
setNewlyCreatedRecipe(fullRecipe);
|
||||
setShowQualityPrompt(true);
|
||||
|
||||
console.log('Recipe created successfully');
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
@@ -209,20 +426,71 @@ const RecipesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle quality prompt - configure now
|
||||
const handleConfigureQualityNow = async () => {
|
||||
setShowQualityPrompt(false);
|
||||
if (newlyCreatedRecipe) {
|
||||
setSelectedRecipe(newlyCreatedRecipe);
|
||||
setModalMode('edit');
|
||||
setShowQualityConfigModal(true);
|
||||
setShowForm(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle quality prompt - configure later
|
||||
const handleConfigureQualityLater = () => {
|
||||
setShowQualityPrompt(false);
|
||||
setNewlyCreatedRecipe(null);
|
||||
};
|
||||
|
||||
// Handle field changes in edit mode
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
if (!selectedRecipe) return;
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
// Información Básica
|
||||
'Nombre': 'name',
|
||||
'Código de receta': 'recipe_code',
|
||||
'Versión': 'version',
|
||||
'Descripción': 'description',
|
||||
'Categoría': 'category',
|
||||
'Producto terminado': 'finished_product_id',
|
||||
'Tipo de cocina': 'cuisine_type',
|
||||
'Dificultad': 'difficulty_level',
|
||||
'Estado': 'status',
|
||||
'Rendimiento': 'yield_quantity',
|
||||
'Unidad de rendimiento': 'yield_unit',
|
||||
'Porciones': 'serves_count',
|
||||
// Tiempos
|
||||
'Tiempo de preparación': 'prep_time_minutes',
|
||||
'Tiempo de cocción': 'cook_time_minutes',
|
||||
'Tiempo de reposo': 'rest_time_minutes',
|
||||
// Configuración Especial
|
||||
'Receta estacional': 'is_seasonal',
|
||||
'Mes de inicio': 'season_start_month',
|
||||
'Mes de fin': 'season_end_month',
|
||||
'Receta estrella': 'is_signature_item',
|
||||
// Configuración de Producción
|
||||
'Multiplicador de lote': 'batch_size_multiplier',
|
||||
'Tamaño mínimo de lote': 'minimum_batch_size',
|
||||
'Tamaño máximo de lote': 'maximum_batch_size',
|
||||
'Temperatura óptima': 'optimal_production_temperature',
|
||||
'Humedad óptima': 'optimal_humidity',
|
||||
// Análisis Financiero
|
||||
'Costo estimado por unidad': 'estimated_cost_per_unit',
|
||||
'Precio de venta sugerido': 'suggested_selling_price',
|
||||
'Margen objetivo': 'target_margin_percentage'
|
||||
'Margen objetivo': 'target_margin_percentage',
|
||||
// Instrucciones y Calidad
|
||||
'Notas de preparación': 'preparation_notes',
|
||||
'Instrucciones de almacenamiento': 'storage_instructions',
|
||||
'Estándares de calidad': 'quality_standards',
|
||||
'Instrucciones de preparación': 'instructions',
|
||||
'Puntos de control de calidad': 'quality_check_points',
|
||||
'Problemas comunes y soluciones': 'common_issues',
|
||||
// Información Nutricional
|
||||
'Información de alérgenos': 'allergen_info',
|
||||
'Etiquetas dietéticas': 'dietary_tags',
|
||||
'Información nutricional': 'nutritional_info'
|
||||
};
|
||||
|
||||
const sections = getModalSections();
|
||||
@@ -237,12 +505,19 @@ const RecipesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Refetch callback for wait-for-refetch pattern
|
||||
const handleRecipeSaveComplete = async () => {
|
||||
if (!tenantId) return;
|
||||
// Invalidate recipes query to trigger refetch
|
||||
await queryClient.invalidateQueries(['recipes', tenantId]);
|
||||
};
|
||||
|
||||
// Handle saving edited recipe
|
||||
const handleSaveRecipe = async () => {
|
||||
if (!selectedRecipe || !Object.keys(editedRecipe).length) return;
|
||||
if (!selectedRecipe || (!Object.keys(editedRecipe).length && editedIngredients.length === 0)) return;
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
const updateData: any = {
|
||||
...editedRecipe,
|
||||
// Convert time fields from formatted strings back to numbers if needed
|
||||
prep_time_minutes: typeof editedRecipe.prep_time_minutes === 'string'
|
||||
@@ -259,13 +534,33 @@ const RecipesPage: React.FC = () => {
|
||||
: editedRecipe.difficulty_level,
|
||||
};
|
||||
|
||||
// Include ingredient updates if they were edited
|
||||
if (editedIngredients.length > 0) {
|
||||
updateData.ingredients = editedIngredients.map((ing, index) => ({
|
||||
ingredient_id: ing.ingredient_id,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
alternative_quantity: ing.alternative_quantity || null,
|
||||
alternative_unit: ing.alternative_unit || null,
|
||||
preparation_method: ing.preparation_method || null,
|
||||
ingredient_notes: ing.ingredient_notes || null,
|
||||
is_optional: ing.is_optional || false,
|
||||
ingredient_order: index + 1, // Maintain order based on array position
|
||||
ingredient_group: ing.ingredient_group || null,
|
||||
substitution_options: ing.substitution_options || null,
|
||||
substitution_ratio: ing.substitution_ratio || null,
|
||||
}));
|
||||
}
|
||||
|
||||
await updateRecipeMutation.mutateAsync({
|
||||
id: selectedRecipe.id,
|
||||
data: updateData
|
||||
});
|
||||
|
||||
setModalMode('view');
|
||||
// Note: Don't manually switch mode here - EditViewModal will handle it
|
||||
// after refetch completes if waitForRefetch is enabled
|
||||
setEditedRecipe({});
|
||||
setEditedIngredients([]);
|
||||
console.log('Recipe updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
@@ -297,6 +592,28 @@ const RecipesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle soft delete (archive)
|
||||
const handleSoftDelete = async (recipeId: string) => {
|
||||
try {
|
||||
await archiveRecipeMutation.mutateAsync(recipeId);
|
||||
console.log('Recipe archived successfully');
|
||||
} catch (error) {
|
||||
console.error('Error archiving recipe:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle hard delete (permanent)
|
||||
const handleHardDelete = async (recipeId: string) => {
|
||||
try {
|
||||
await deleteRecipeMutation.mutateAsync(recipeId);
|
||||
console.log('Recipe deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipe:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current value for field (edited value or original)
|
||||
const getFieldValue = (originalValue: any, fieldKey: string) => {
|
||||
return editedRecipe[fieldKey as keyof RecipeResponse] !== undefined
|
||||
@@ -304,6 +621,23 @@ const RecipesPage: React.FC = () => {
|
||||
: originalValue;
|
||||
};
|
||||
|
||||
// Helper to display JSON fields in a readable format
|
||||
const formatJsonField = (jsonData: any): string => {
|
||||
if (!jsonData) return 'No especificado';
|
||||
if (typeof jsonData === 'string') return jsonData;
|
||||
if (typeof jsonData === 'object') {
|
||||
// Extract common patterns
|
||||
if (jsonData.steps) return jsonData.steps;
|
||||
if (jsonData.checkpoints) return jsonData.checkpoints;
|
||||
if (jsonData.issues) return jsonData.issues;
|
||||
if (jsonData.allergens) return jsonData.allergens.join(', ');
|
||||
if (jsonData.tags) return jsonData.tags.join(', ');
|
||||
if (jsonData.info) return jsonData.info;
|
||||
return JSON.stringify(jsonData, null, 2);
|
||||
}
|
||||
return String(jsonData);
|
||||
};
|
||||
|
||||
// Get modal sections with editable fields
|
||||
const getModalSections = () => {
|
||||
if (!selectedRecipe) return [];
|
||||
@@ -313,6 +647,32 @@ const RecipesPage: React.FC = () => {
|
||||
title: 'Información Básica',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: getFieldValue(selectedRecipe.name, 'name'),
|
||||
type: modalMode === 'edit' ? 'text' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Código de receta',
|
||||
value: getFieldValue(selectedRecipe.recipe_code || 'Sin código', 'recipe_code'),
|
||||
type: modalMode === 'edit' ? 'text' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Versión',
|
||||
value: getFieldValue(selectedRecipe.version, 'version'),
|
||||
type: modalMode === 'edit' ? 'text' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
value: getFieldValue(selectedRecipe.description || 'Sin descripción', 'description'),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
|
||||
@@ -326,6 +686,12 @@ const RecipesPage: React.FC = () => {
|
||||
] : undefined,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Tipo de cocina',
|
||||
value: getFieldValue(selectedRecipe.cuisine_type || 'No especificado', 'cuisine_type'),
|
||||
type: modalMode === 'edit' ? 'text' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Dificultad',
|
||||
value: modalMode === 'edit'
|
||||
@@ -348,16 +714,28 @@ const RecipesPage: React.FC = () => {
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'draft', label: 'Borrador' },
|
||||
{ value: 'active', label: 'Activo' },
|
||||
{ value: 'archived', label: 'Archivado' }
|
||||
{ value: 'testing', label: 'Testing' },
|
||||
{ value: 'archived', label: 'Archivado' },
|
||||
{ value: 'discontinued', label: 'Discontinuado' }
|
||||
] : undefined,
|
||||
highlight: selectedRecipe.status === 'active',
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')
|
||||
: `${getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')} ${selectedRecipe.yield_unit}`,
|
||||
value: getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Unidad de rendimiento',
|
||||
value: getFieldValue(selectedRecipe.yield_unit, 'yield_unit'),
|
||||
type: modalMode === 'edit' ? 'text' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Porciones',
|
||||
value: getFieldValue(selectedRecipe.serves_count || 1, 'serves_count'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
@@ -385,6 +763,15 @@ const RecipesPage: React.FC = () => {
|
||||
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de reposo',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.rest_time_minutes || 0, 'rest_time_minutes')
|
||||
: formatTime(getFieldValue(selectedRecipe.rest_time_minutes || 0, 'rest_time_minutes')),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo total',
|
||||
value: selectedRecipe.total_time_minutes ? formatTime(selectedRecipe.total_time_minutes) : 'No especificado',
|
||||
@@ -419,16 +806,184 @@ const RecipesPage: React.FC = () => {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración Especial',
|
||||
icon: Star,
|
||||
fields: [
|
||||
{
|
||||
label: 'Receta estacional',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.is_seasonal, 'is_seasonal')
|
||||
: (selectedRecipe.is_seasonal ? 'Sí' : 'No'),
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
] : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Mes de inicio',
|
||||
value: getFieldValue(selectedRecipe.season_start_month || 'No especificado', 'season_start_month'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Mes de fin',
|
||||
value: getFieldValue(selectedRecipe.season_end_month || 'No especificado', 'season_end_month'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Receta estrella',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.is_signature_item, 'is_signature_item')
|
||||
: (selectedRecipe.is_signature_item ? 'Sí' : 'No'),
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
] : undefined,
|
||||
editable: modalMode === 'edit',
|
||||
highlight: selectedRecipe.is_signature_item
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración de Producción',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Multiplicador de lote',
|
||||
value: getFieldValue(selectedRecipe.batch_size_multiplier || 1.0, 'batch_size_multiplier'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tamaño mínimo de lote',
|
||||
value: getFieldValue(selectedRecipe.minimum_batch_size || 'No especificado', 'minimum_batch_size'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tamaño máximo de lote',
|
||||
value: getFieldValue(selectedRecipe.maximum_batch_size || 'No especificado', 'maximum_batch_size'),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Temperatura óptima',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.optimal_production_temperature || '', 'optimal_production_temperature')
|
||||
: `${getFieldValue(selectedRecipe.optimal_production_temperature || 'No especificado', 'optimal_production_temperature')}${selectedRecipe.optimal_production_temperature ? '°C' : ''}`,
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Humedad óptima',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.optimal_humidity || '', 'optimal_humidity')
|
||||
: `${getFieldValue(selectedRecipe.optimal_humidity || 'No especificado', 'optimal_humidity')}${selectedRecipe.optimal_humidity ? '%' : ''}`,
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Instrucciones',
|
||||
icon: FileText,
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas de preparación',
|
||||
value: getFieldValue(selectedRecipe.preparation_notes || 'No especificado', 'preparation_notes'),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones de almacenamiento',
|
||||
value: getFieldValue(selectedRecipe.storage_instructions || 'No especificado', 'storage_instructions'),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones de preparación',
|
||||
value: modalMode === 'edit'
|
||||
? formatJsonField(getFieldValue(selectedRecipe.instructions, 'instructions'))
|
||||
: formatJsonField(selectedRecipe.instructions),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Nutricional',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Información de alérgenos',
|
||||
value: modalMode === 'edit'
|
||||
? formatJsonField(getFieldValue(selectedRecipe.allergen_info, 'allergen_info'))
|
||||
: formatJsonField(selectedRecipe.allergen_info),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Etiquetas dietéticas',
|
||||
value: modalMode === 'edit'
|
||||
? formatJsonField(getFieldValue(selectedRecipe.dietary_tags, 'dietary_tags'))
|
||||
: formatJsonField(selectedRecipe.dietary_tags),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Información nutricional',
|
||||
value: modalMode === 'edit'
|
||||
? formatJsonField(getFieldValue(selectedRecipe.nutritional_info, 'nutritional_info'))
|
||||
: formatJsonField(selectedRecipe.nutritional_info),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
value: selectedRecipe.ingredients?.map(ing => `${ing.quantity} ${ing.unit} - ${ing.ingredient_id}`) || ['No especificados'],
|
||||
type: 'list',
|
||||
value: modalMode === 'edit'
|
||||
? (() => {
|
||||
const val = editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [];
|
||||
console.log('[RecipesPage] Edit mode - Ingredients value:', val, 'editedIngredients.length:', editedIngredients.length);
|
||||
return val;
|
||||
})()
|
||||
: (selectedRecipe.ingredients
|
||||
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
|
||||
?.map(ing => {
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || ing.ingredient_id;
|
||||
const optional = ing.is_optional ? ' (opcional)' : '';
|
||||
const prep = ing.preparation_method ? ` - ${ing.preparation_method}` : '';
|
||||
const notes = ing.ingredient_notes ? ` [${ing.ingredient_notes}]` : '';
|
||||
return `${ing.quantity} ${ing.unit} de ${ingredientName}${optional}${prep}${notes}`;
|
||||
}) || ['No especificados']),
|
||||
type: modalMode === 'edit' ? 'component' as const : 'list' as const,
|
||||
component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
|
||||
componentProps: modalMode === 'edit' ? {
|
||||
availableIngredients,
|
||||
unitOptions,
|
||||
onChange: (newIngredients: RecipeIngredientResponse[]) => {
|
||||
console.log('[RecipesPage] Ingredients onChange called with:', newIngredients);
|
||||
setEditedIngredients(newIngredients);
|
||||
}
|
||||
} : undefined,
|
||||
span: 2,
|
||||
readonly: true // For now, ingredients editing can be complex, so we'll keep it read-only
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -553,13 +1108,9 @@ const RecipesPage: React.FC = () => {
|
||||
metadata={[
|
||||
`Tiempo: ${totalTime}`,
|
||||
`Rendimiento: ${recipe.yield_quantity} ${recipe.yield_unit}`,
|
||||
`${recipe.ingredients?.length || 0} ingredientes principales`
|
||||
`Control de Calidad: ${getQualityIndicator(recipe)}`
|
||||
]}
|
||||
onClick={() => {
|
||||
setSelectedRecipe(recipe);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}}
|
||||
onClick={() => handleOpenRecipe(recipe.id)}
|
||||
actions={[
|
||||
// Primary action - View recipe details
|
||||
{
|
||||
@@ -567,21 +1118,17 @@ const RecipesPage: React.FC = () => {
|
||||
icon: Eye,
|
||||
variant: 'primary',
|
||||
priority: 'primary',
|
||||
onClick: () => {
|
||||
setSelectedRecipe(recipe);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
onClick: () => handleOpenRecipe(recipe.id)
|
||||
},
|
||||
// Secondary action - Edit recipe
|
||||
// Delete action
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
label: 'Eliminar',
|
||||
icon: Trash2,
|
||||
variant: 'danger',
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setSelectedRecipe(recipe);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
setRecipeToDelete(recipe);
|
||||
setShowDeleteModal(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
@@ -592,19 +1139,14 @@ const RecipesPage: React.FC = () => {
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredRecipes.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron recetas
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o crear una nueva receta
|
||||
</p>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Receta
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={ChefHat}
|
||||
title="No se encontraron recetas"
|
||||
description="Intenta ajustar la búsqueda o crear una nueva receta"
|
||||
actionLabel="Nueva Receta"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recipe Details Modal */}
|
||||
@@ -616,12 +1158,17 @@ const RecipesPage: React.FC = () => {
|
||||
setSelectedRecipe(null);
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
setEditedIngredients([]);
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={(newMode) => {
|
||||
setModalMode(newMode);
|
||||
if (newMode === 'view') {
|
||||
setEditedRecipe({});
|
||||
setEditedIngredients([]);
|
||||
} else if (newMode === 'edit' && selectedRecipe) {
|
||||
// Initialize edited ingredients when entering edit mode
|
||||
setEditedIngredients(selectedRecipe.ingredients || []);
|
||||
}
|
||||
}}
|
||||
title={selectedRecipe.name}
|
||||
@@ -632,6 +1179,9 @@ const RecipesPage: React.FC = () => {
|
||||
onFieldChange={handleFieldChange}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSaveRecipe}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingRecipes}
|
||||
onSaveComplete={handleRecipeSaveComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -652,6 +1202,30 @@ const RecipesPage: React.FC = () => {
|
||||
isLoading={updateRecipeMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Recipe Modal */}
|
||||
<DeleteRecipeModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setRecipeToDelete(null);
|
||||
}}
|
||||
recipe={recipeToDelete}
|
||||
onSoftDelete={handleSoftDelete}
|
||||
onHardDelete={handleHardDelete}
|
||||
isLoading={archiveRecipeMutation.isPending || deleteRecipeMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Quality Configuration Prompt */}
|
||||
{newlyCreatedRecipe && (
|
||||
<QualityPromptDialog
|
||||
isOpen={showQualityPrompt}
|
||||
onClose={handleConfigureQualityLater}
|
||||
onConfigureNow={handleConfigureQualityNow}
|
||||
onConfigureLater={handleConfigureQualityLater}
|
||||
recipeName={newlyCreatedRecipe.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader, Trash2 } from 'lucide-react';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, AddModal, SearchAndFilter, DialogModal, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
|
||||
import { useSuppliers, useSupplierStatistics } from '../../../../api/hooks/suppliers';
|
||||
import { useSuppliers, useSupplierStatistics, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier } from '../../../../api/hooks/suppliers';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { DeleteSupplierModal } from '../../../../components/domain/suppliers';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
@@ -17,7 +19,10 @@ const SuppliersPage: React.FC = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [supplierToApprove, setSupplierToApprove] = useState<any>(null);
|
||||
|
||||
// Get tenant ID from tenant store (preferred) or auth user (fallback)
|
||||
const currentTenant = useCurrentTenant();
|
||||
@@ -44,6 +49,22 @@ const SuppliersPage: React.FC = () => {
|
||||
const suppliers = suppliersData || [];
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
|
||||
// Mutation hooks
|
||||
const createSupplierMutation = useCreateSupplier();
|
||||
const updateSupplierMutation = useUpdateSupplier();
|
||||
const approveSupplierMutation = useApproveSupplier();
|
||||
const softDeleteMutation = useDeleteSupplier();
|
||||
const hardDeleteMutation = useHardDeleteSupplier();
|
||||
|
||||
// Delete handlers
|
||||
const handleSoftDelete = async (supplierId: string) => {
|
||||
await softDeleteMutation.mutateAsync({ tenantId, supplierId });
|
||||
};
|
||||
|
||||
const handleHardDelete = async (supplierId: string) => {
|
||||
return await hardDeleteMutation.mutateAsync({ tenantId, supplierId });
|
||||
};
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[SupplierStatus.ACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: CheckCircle },
|
||||
@@ -158,7 +179,7 @@ const SuppliersPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<PageHeader
|
||||
title="Gestión de Proveedores"
|
||||
description="Administra y supervisa todos los proveedores de la panadería"
|
||||
actions={[
|
||||
@@ -167,26 +188,7 @@ const SuppliersPage: React.FC = () => {
|
||||
label: "Nuevo Proveedor",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => {
|
||||
setSelectedSupplier({
|
||||
name: '',
|
||||
contact_person: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
country: '',
|
||||
supplier_code: '',
|
||||
supplier_type: SupplierType.INGREDIENTS,
|
||||
payment_terms: PaymentTerms.NET_30,
|
||||
standard_lead_time: 3,
|
||||
minimum_order_amount: 0,
|
||||
credit_limit: 0,
|
||||
currency: 'EUR'
|
||||
});
|
||||
setIsCreating(true);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
onClick: () => setShowAddModal(true)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -243,7 +245,7 @@ const SuppliersPage: React.FC = () => {
|
||||
title={supplier.name}
|
||||
subtitle={`${getSupplierTypeText(supplier.supplier_type)} • ${supplier.city || 'Sin ubicación'}`}
|
||||
primaryValue={supplier.standard_lead_time || 0}
|
||||
primaryValueLabel="días"
|
||||
primaryValueLabel="días entrega"
|
||||
secondaryInfo={{
|
||||
label: 'Pedido Min.',
|
||||
value: `€${formatters.compact(supplier.minimum_order_amount || 0)}`
|
||||
@@ -256,7 +258,6 @@ const SuppliersPage: React.FC = () => {
|
||||
]}
|
||||
onClick={() => {
|
||||
setSelectedSupplier(supplier);
|
||||
setIsCreating(false);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}}
|
||||
@@ -269,23 +270,41 @@ const SuppliersPage: React.FC = () => {
|
||||
priority: 'primary',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setIsCreating(false);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
// Secondary action - Edit supplier
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setIsCreating(false);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
}
|
||||
// Approval action - Only show for pending suppliers + admin/super_admin
|
||||
...(supplier.status === SupplierStatus.PENDING_APPROVAL &&
|
||||
(user?.role === 'admin' || user?.role === 'super_admin')
|
||||
? [{
|
||||
label: t('suppliers:actions.approve'),
|
||||
icon: CheckCircle,
|
||||
variant: 'primary' as const,
|
||||
priority: 'primary' as const,
|
||||
highlighted: true,
|
||||
onClick: () => {
|
||||
setSupplierToApprove(supplier);
|
||||
setShowApprovalModal(true);
|
||||
}
|
||||
}]
|
||||
: []
|
||||
),
|
||||
// Delete action - Only show for admin/super_admin
|
||||
...(user?.role === 'admin' || user?.role === 'super_admin'
|
||||
? [{
|
||||
label: t('suppliers:actions.delete'),
|
||||
icon: Trash2,
|
||||
variant: 'outline' as const,
|
||||
priority: 'secondary' as const,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setShowDeleteModal(true);
|
||||
}
|
||||
}]
|
||||
: []
|
||||
)
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -294,26 +313,236 @@ const SuppliersPage: React.FC = () => {
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredSuppliers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Building2 className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron proveedores
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o crear un nuevo proveedor
|
||||
</p>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Proveedor
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="No se encontraron proveedores"
|
||||
description="Intenta ajustar la búsqueda o crear un nuevo proveedor"
|
||||
actionLabel="Nuevo Proveedor"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowAddModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Supplier Modal */}
|
||||
<AddModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
title={t('suppliers:actions.new_supplier', 'Nuevo Proveedor')}
|
||||
subtitle={t('suppliers:actions.create_new_supplier', 'Crear nuevo proveedor')}
|
||||
size="lg"
|
||||
sections={[
|
||||
{
|
||||
title: t('suppliers:sections.contact_info'),
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: t('common:fields.name'),
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: t('suppliers:placeholders.name')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.contact_person'),
|
||||
name: 'contact_person',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.contact_person')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.email'),
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
placeholder: t('common:fields.email_placeholder')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.phone'),
|
||||
name: 'phone',
|
||||
type: 'tel',
|
||||
placeholder: t('common:fields.phone_placeholder')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.mobile'),
|
||||
name: 'mobile',
|
||||
type: 'tel',
|
||||
placeholder: t('suppliers:placeholders.mobile')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.website'),
|
||||
name: 'website',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.website')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('suppliers:sections.address_info'),
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.address_line1'),
|
||||
name: 'address_line1',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.address_line1')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.address_line2'),
|
||||
name: 'address_line2',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.address_line2')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.city'),
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
placeholder: t('common:fields.city')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.state_province'),
|
||||
name: 'state_province',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.state_province')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.postal_code'),
|
||||
name: 'postal_code',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.postal_code')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.country'),
|
||||
name: 'country',
|
||||
type: 'text',
|
||||
placeholder: t('common:fields.country')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('suppliers:sections.commercial_info'),
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.supplier_code'),
|
||||
name: 'supplier_code',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.supplier_code')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.supplier_type'),
|
||||
name: 'supplier_type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: SupplierType.INGREDIENTS,
|
||||
options: Object.values(SupplierType).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:types.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.payment_terms'),
|
||||
name: 'payment_terms',
|
||||
type: 'select',
|
||||
defaultValue: PaymentTerms.NET_30,
|
||||
options: Object.values(PaymentTerms).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.currency'),
|
||||
name: 'currency',
|
||||
type: 'select',
|
||||
defaultValue: 'EUR',
|
||||
options: [
|
||||
{ value: 'EUR', label: t('suppliers:currencies.EUR') },
|
||||
{ value: 'USD', label: t('suppliers:currencies.USD') },
|
||||
{ value: 'GBP', label: t('suppliers:currencies.GBP') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.lead_time'),
|
||||
name: 'standard_lead_time',
|
||||
type: 'number',
|
||||
defaultValue: 3,
|
||||
placeholder: '3'
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.minimum_order'),
|
||||
name: 'minimum_order_amount',
|
||||
type: 'currency',
|
||||
defaultValue: 0,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.credit_limit'),
|
||||
name: 'credit_limit',
|
||||
type: 'currency',
|
||||
defaultValue: 0,
|
||||
placeholder: '0.00'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('suppliers:sections.additional_info'),
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.tax_id'),
|
||||
name: 'tax_id',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.tax_id')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.registration_number'),
|
||||
name: 'registration_number',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.registration_number')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.delivery_area'),
|
||||
name: 'delivery_area',
|
||||
type: 'text',
|
||||
placeholder: t('suppliers:placeholders.delivery_area')
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
onSave={async (formData) => {
|
||||
await createSupplierMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierData: {
|
||||
name: formData.name,
|
||||
supplier_code: formData.supplier_code || null,
|
||||
tax_id: formData.tax_id || null,
|
||||
registration_number: formData.registration_number || null,
|
||||
supplier_type: formData.supplier_type || SupplierType.INGREDIENTS,
|
||||
contact_person: formData.contact_person || null,
|
||||
email: formData.email || null,
|
||||
phone: formData.phone || null,
|
||||
mobile: formData.mobile || null,
|
||||
website: formData.website || null,
|
||||
address_line1: formData.address_line1 || null,
|
||||
address_line2: formData.address_line2 || null,
|
||||
city: formData.city || null,
|
||||
state_province: formData.state_province || null,
|
||||
postal_code: formData.postal_code || null,
|
||||
country: formData.country || null,
|
||||
payment_terms: formData.payment_terms || PaymentTerms.NET_30,
|
||||
credit_limit: formData.credit_limit || null,
|
||||
currency: formData.currency || 'EUR',
|
||||
standard_lead_time: formData.standard_lead_time || 3,
|
||||
minimum_order_amount: formData.minimum_order_amount || null,
|
||||
delivery_area: formData.delivery_area || null,
|
||||
notes: formData.notes || null
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Supplier Details Modal */}
|
||||
{showForm && selectedSupplier && (() => {
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información de Contacto',
|
||||
title: t('suppliers:sections.contact_info'),
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
@@ -346,6 +575,40 @@ const SuppliersPage: React.FC = () => {
|
||||
editable: true,
|
||||
placeholder: t('common:fields.phone_placeholder')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.mobile'),
|
||||
value: selectedSupplier.mobile || '',
|
||||
type: 'tel' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.mobile')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.website'),
|
||||
value: selectedSupplier.website || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.website')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('suppliers:sections.address_info'),
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.address_line1'),
|
||||
value: selectedSupplier.address_line1 || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.address_line1')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.address_line2'),
|
||||
value: selectedSupplier.address_line2 || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.address_line2')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.city'),
|
||||
value: selectedSupplier.city || '',
|
||||
@@ -353,6 +616,20 @@ const SuppliersPage: React.FC = () => {
|
||||
editable: true,
|
||||
placeholder: t('common:fields.city')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.state_province'),
|
||||
value: selectedSupplier.state_province || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.state_province')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.postal_code'),
|
||||
value: selectedSupplier.postal_code || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.postal_code')
|
||||
},
|
||||
{
|
||||
label: t('common:fields.country'),
|
||||
value: selectedSupplier.country || '',
|
||||
@@ -363,8 +640,8 @@ const SuppliersPage: React.FC = () => {
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Comercial',
|
||||
icon: Building2,
|
||||
title: t('suppliers:sections.commercial_info'),
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.supplier_code'),
|
||||
@@ -376,11 +653,12 @@ const SuppliersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.supplier_type'),
|
||||
value: modalMode === 'view'
|
||||
value: modalMode === 'view'
|
||||
? getSupplierTypeText(selectedSupplier.supplier_type || SupplierType.INGREDIENTS)
|
||||
: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
|
||||
type: modalMode === 'view' ? 'text' as const : 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: modalMode === 'edit' ? Object.values(SupplierType).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:types.${value.toLowerCase()}`)
|
||||
@@ -388,7 +666,7 @@ const SuppliersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.payment_terms'),
|
||||
value: modalMode === 'view'
|
||||
value: modalMode === 'view'
|
||||
? getPaymentTermsText(selectedSupplier.payment_terms || PaymentTerms.NET_30)
|
||||
: selectedSupplier.payment_terms || PaymentTerms.NET_30,
|
||||
type: modalMode === 'view' ? 'text' as const : 'select' as const,
|
||||
@@ -398,6 +676,19 @@ const SuppliersPage: React.FC = () => {
|
||||
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
|
||||
})) : undefined
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.currency'),
|
||||
value: modalMode === 'view'
|
||||
? t(`suppliers:currencies.${selectedSupplier.currency || 'EUR'}`)
|
||||
: selectedSupplier.currency || 'EUR',
|
||||
type: modalMode === 'view' ? 'text' as const : 'select' as const,
|
||||
editable: true,
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'EUR', label: t('suppliers:currencies.EUR') },
|
||||
{ value: 'USD', label: t('suppliers:currencies.USD') },
|
||||
{ value: 'GBP', label: t('suppliers:currencies.GBP') }
|
||||
] : undefined
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.lead_time'),
|
||||
value: selectedSupplier.standard_lead_time || 3,
|
||||
@@ -422,16 +713,37 @@ const SuppliersPage: React.FC = () => {
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('suppliers:sections.performance'),
|
||||
icon: Euro,
|
||||
title: t('suppliers:sections.additional_info'),
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.currency'),
|
||||
value: selectedSupplier.currency || 'EUR',
|
||||
label: t('suppliers:labels.tax_id'),
|
||||
value: selectedSupplier.tax_id || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'EUR'
|
||||
placeholder: t('suppliers:placeholders.tax_id')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.registration_number'),
|
||||
value: selectedSupplier.registration_number || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.registration_number')
|
||||
},
|
||||
{
|
||||
label: t('suppliers:labels.delivery_area'),
|
||||
value: selectedSupplier.delivery_area || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: t('suppliers:placeholders.delivery_area')
|
||||
}
|
||||
]
|
||||
},
|
||||
// Performance section
|
||||
{
|
||||
title: t('suppliers:sections.performance'),
|
||||
icon: CheckCircle,
|
||||
fields: [
|
||||
{
|
||||
label: t('suppliers:labels.created_date'),
|
||||
value: selectedSupplier.created_at,
|
||||
@@ -467,19 +779,56 @@ const SuppliersPage: React.FC = () => {
|
||||
setShowForm(false);
|
||||
setSelectedSupplier(null);
|
||||
setModalMode('view');
|
||||
setIsCreating(false);
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={isCreating ? 'Nuevo Proveedor' : selectedSupplier.name || 'Proveedor'}
|
||||
subtitle={isCreating ? 'Crear nuevo proveedor' : `Proveedor ${selectedSupplier.supplier_code || ''}`}
|
||||
statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)}
|
||||
title={selectedSupplier.name || 'Proveedor'}
|
||||
subtitle={`Proveedor ${selectedSupplier.supplier_code || ''}`}
|
||||
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
|
||||
size="lg"
|
||||
sections={sections}
|
||||
showDefaultActions={modalMode === 'edit'}
|
||||
showDefaultActions={true}
|
||||
onSave={async () => {
|
||||
// TODO: Implement save functionality
|
||||
console.log('Saving supplier:', selectedSupplier);
|
||||
try {
|
||||
// Update existing supplier
|
||||
await updateSupplierMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: selectedSupplier.id,
|
||||
updateData: {
|
||||
name: selectedSupplier.name,
|
||||
supplier_code: selectedSupplier.supplier_code || null,
|
||||
tax_id: selectedSupplier.tax_id || null,
|
||||
registration_number: selectedSupplier.registration_number || null,
|
||||
supplier_type: selectedSupplier.supplier_type,
|
||||
contact_person: selectedSupplier.contact_person || null,
|
||||
email: selectedSupplier.email || null,
|
||||
phone: selectedSupplier.phone || null,
|
||||
mobile: selectedSupplier.mobile || null,
|
||||
website: selectedSupplier.website || null,
|
||||
address_line1: selectedSupplier.address_line1 || null,
|
||||
address_line2: selectedSupplier.address_line2 || null,
|
||||
city: selectedSupplier.city || null,
|
||||
state_province: selectedSupplier.state_province || null,
|
||||
postal_code: selectedSupplier.postal_code || null,
|
||||
country: selectedSupplier.country || null,
|
||||
payment_terms: selectedSupplier.payment_terms,
|
||||
credit_limit: selectedSupplier.credit_limit || null,
|
||||
currency: selectedSupplier.currency || 'EUR',
|
||||
standard_lead_time: selectedSupplier.standard_lead_time || 3,
|
||||
minimum_order_amount: selectedSupplier.minimum_order_amount || null,
|
||||
delivery_area: selectedSupplier.delivery_area || null,
|
||||
notes: selectedSupplier.notes || null
|
||||
}
|
||||
});
|
||||
// Close modal on success
|
||||
setShowForm(false);
|
||||
setSelectedSupplier(null);
|
||||
setModalMode('view');
|
||||
} catch (error) {
|
||||
console.error('Error saving supplier:', error);
|
||||
// Error will be handled by the modal's error display
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
// Update the selectedSupplier state when fields change
|
||||
@@ -493,15 +842,24 @@ const SuppliersPage: React.FC = () => {
|
||||
[t('common:fields.contact_person')]: 'contact_person',
|
||||
[t('common:fields.email')]: 'email',
|
||||
[t('common:fields.phone')]: 'phone',
|
||||
[t('suppliers:labels.mobile')]: 'mobile',
|
||||
[t('suppliers:labels.website')]: 'website',
|
||||
[t('suppliers:labels.address_line1')]: 'address_line1',
|
||||
[t('suppliers:labels.address_line2')]: 'address_line2',
|
||||
[t('common:fields.city')]: 'city',
|
||||
[t('suppliers:labels.state_province')]: 'state_province',
|
||||
[t('suppliers:labels.postal_code')]: 'postal_code',
|
||||
[t('common:fields.country')]: 'country',
|
||||
[t('suppliers:labels.supplier_code')]: 'supplier_code',
|
||||
[t('suppliers:labels.supplier_type')]: 'supplier_type',
|
||||
[t('suppliers:labels.payment_terms')]: 'payment_terms',
|
||||
[t('suppliers:labels.currency')]: 'currency',
|
||||
[t('suppliers:labels.lead_time')]: 'standard_lead_time',
|
||||
[t('suppliers:labels.minimum_order')]: 'minimum_order_amount',
|
||||
[t('suppliers:labels.credit_limit')]: 'credit_limit',
|
||||
[t('suppliers:labels.currency')]: 'currency',
|
||||
[t('suppliers:labels.tax_id')]: 'tax_id',
|
||||
[t('suppliers:labels.registration_number')]: 'registration_number',
|
||||
[t('suppliers:labels.delivery_area')]: 'delivery_area',
|
||||
[t('suppliers:labels.notes')]: 'notes'
|
||||
};
|
||||
|
||||
@@ -514,6 +872,76 @@ const SuppliersPage: React.FC = () => {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Delete Supplier Modal */}
|
||||
<DeleteSupplierModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedSupplier(null);
|
||||
}}
|
||||
supplier={selectedSupplier}
|
||||
onSoftDelete={handleSoftDelete}
|
||||
onHardDelete={handleHardDelete}
|
||||
isLoading={softDeleteMutation.isPending || hardDeleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Approval Confirmation Modal */}
|
||||
<DialogModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => {
|
||||
setShowApprovalModal(false);
|
||||
setSupplierToApprove(null);
|
||||
}}
|
||||
type="confirm"
|
||||
title={t('suppliers:confirm.approve_title', 'Aprobar Proveedor')}
|
||||
message={
|
||||
supplierToApprove ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[var(--text-primary)]">
|
||||
{t('suppliers:confirm.approve_message', '¿Estás seguro de que deseas aprobar este proveedor?')}
|
||||
</p>
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{supplierToApprove.name}</span>
|
||||
</div>
|
||||
{supplierToApprove.supplier_code && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:labels.supplier_code')}: {supplierToApprove.supplier_code}
|
||||
</p>
|
||||
)}
|
||||
{supplierToApprove.email && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('common:fields.email')}: {supplierToApprove.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:confirm.approve_description', 'Una vez aprobado, el proveedor estará activo y podrá ser utilizado para realizar pedidos.')}
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
confirmLabel={t('suppliers:actions.approve', 'Aprobar')}
|
||||
cancelLabel={t('common:modals.actions.cancel', 'Cancelar')}
|
||||
onConfirm={async () => {
|
||||
if (supplierToApprove) {
|
||||
try {
|
||||
await approveSupplierMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: supplierToApprove.id,
|
||||
approvalData: { action: 'approve' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error approving supplier:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading={approveSupplierMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,10 +23,9 @@ import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
|
||||
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
|
||||
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { subscriptionService } from '../../../../api';
|
||||
|
||||
// Import the communication preferences component
|
||||
import CommunicationPreferences, { type NotificationPreferences } from './CommunicationPreferences';
|
||||
@@ -52,7 +51,6 @@ const NewProfileSettingsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const user = useAuthUser();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const { logout } = useAuthActions();
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
@@ -72,7 +70,6 @@ const NewProfileSettingsPage: React.FC = () => {
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteReason, setDeleteReason] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
|
||||
|
||||
const [profileData, setProfileData] = useState<ProfileFormData>({
|
||||
first_name: '',
|
||||
@@ -106,22 +103,8 @@ const NewProfileSettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
// Load subscription status
|
||||
React.useEffect(() => {
|
||||
const loadSubscriptionStatus = async () => {
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
if (tenantId) {
|
||||
try {
|
||||
const status = await subscriptionService.getSubscriptionStatus(tenantId);
|
||||
setSubscriptionStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscription status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSubscriptionStatus();
|
||||
}, [currentTenant, user]);
|
||||
// Subscription status is not needed on the profile page
|
||||
// It's already shown in the subscription tab of the main ProfilePage
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
@@ -249,17 +232,11 @@ const NewProfileSettingsPage: React.FC = () => {
|
||||
const handleDataExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/export', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const { authService } = await import('../../../../api');
|
||||
const exportData = await authService.exportMyData();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export data');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
// Convert to blob and download
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -290,23 +267,8 @@ const NewProfileSettingsPage: React.FC = () => {
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/delete/request', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirm_email: deleteConfirmEmail,
|
||||
password: deletePassword,
|
||||
reason: deleteReason
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to delete account');
|
||||
}
|
||||
const { authService } = await import('../../../../api');
|
||||
await authService.deleteAccount(deleteConfirmEmail, deletePassword, deleteReason);
|
||||
|
||||
addToast(t('common.success'), { type: 'success' });
|
||||
|
||||
@@ -717,22 +679,6 @@ const NewProfileSettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{subscriptionStatus && subscriptionStatus.status === 'active' && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
|
||||
Suscripción Activa Detectada
|
||||
</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
Tienes una suscripción activa que se cancelará
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Confirma tu email"
|
||||
type="email"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
|
||||
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck, Eye, Activity } from 'lucide-react';
|
||||
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig, EmptyState, EditViewModal } from '../../../../components/ui';
|
||||
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useTeamMembers, useAddTeamMember, useAddTeamMemberWithUserCreation, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
|
||||
import { useUserActivity } from '../../../../api/hooks/user';
|
||||
import { userService } from '../../../../api/services/user';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { TENANT_ROLES } from '../../../../types/roles';
|
||||
import { TENANT_ROLES, type TenantRole } from '../../../../types/roles';
|
||||
import { subscriptionService } from '../../../../api/services/subscription';
|
||||
|
||||
const TeamPage: React.FC = () => {
|
||||
@@ -38,7 +40,18 @@ const TeamPage: React.FC = () => {
|
||||
const [selectedRole, setSelectedRole] = useState('all');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [selectedUserToAdd, setSelectedUserToAdd] = useState('');
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<string>(TENANT_ROLES.MEMBER);
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<TenantRole>(TENANT_ROLES.MEMBER);
|
||||
|
||||
// Modal state for team member details
|
||||
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [memberFormData, setMemberFormData] = useState<any>({});
|
||||
|
||||
// Modal state for activity view
|
||||
const [showActivityModal, setShowActivityModal] = useState(false);
|
||||
const [selectedMemberActivity, setSelectedMemberActivity] = useState<any>(null);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
|
||||
|
||||
// Enhanced team members that includes owner information
|
||||
@@ -96,6 +109,21 @@ const TeamPage: React.FC = () => {
|
||||
owners: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length,
|
||||
admins: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length,
|
||||
members: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length,
|
||||
uniqueRoles: new Set(enhancedTeamMembers.map(m => m.role)).size,
|
||||
averageDaysInTeam: (() => {
|
||||
// Only calculate for non-owner members to avoid skewing the average
|
||||
// Owners are added as placeholders with tenant creation date which skews the average
|
||||
const nonOwnerMembers = enhancedTeamMembers.filter(m => m.role !== TENANT_ROLES.OWNER);
|
||||
if (nonOwnerMembers.length === 0) return 0;
|
||||
|
||||
const totalDays = nonOwnerMembers.reduce((sum, m) => {
|
||||
const joinedDate = m.joined_at ? new Date(m.joined_at) : new Date();
|
||||
const days = Math.floor((Date.now() - joinedDate.getTime()) / (1000 * 60 * 24));
|
||||
return sum + days;
|
||||
}, 0);
|
||||
|
||||
return Math.round(totalDays / nonOwnerMembers.length);
|
||||
})()
|
||||
};
|
||||
|
||||
|
||||
@@ -151,21 +179,24 @@ const TeamPage: React.FC = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const getMemberActions = (member: any) => {
|
||||
const getMemberActions = (member: any) => {
|
||||
const actions = [];
|
||||
|
||||
// Primary action - View details (always available)
|
||||
// This will be implemented in the future to show detailed member info modal
|
||||
// For now, we can comment it out as there's no modal yet
|
||||
// actions.push({
|
||||
// label: 'Ver Detalles',
|
||||
// icon: Eye,
|
||||
// priority: 'primary' as const,
|
||||
// onClick: () => {
|
||||
// // TODO: Implement member details modal
|
||||
// console.log('View member details:', member.user_id);
|
||||
// },
|
||||
// });
|
||||
// Primary action - View profile details
|
||||
actions.push({
|
||||
label: 'Ver Perfil',
|
||||
icon: Eye,
|
||||
onClick: () => handleViewMemberDetails(member),
|
||||
priority: 'primary' as const
|
||||
});
|
||||
|
||||
// Secondary action - View activity
|
||||
actions.push({
|
||||
label: 'Ver Actividad',
|
||||
icon: Activity,
|
||||
onClick: () => handleViewActivity(member),
|
||||
priority: 'secondary' as const
|
||||
});
|
||||
|
||||
// Contextual role change actions (only for non-owners and if user can manage team)
|
||||
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
|
||||
@@ -204,7 +235,7 @@ const TeamPage: React.FC = () => {
|
||||
// Remove member action (only for owners and non-owner members)
|
||||
if (isOwner && member.role !== TENANT_ROLES.OWNER) {
|
||||
actions.push({
|
||||
label: 'Remover',
|
||||
label: 'Remover Miembro',
|
||||
icon: Trash2,
|
||||
onClick: () => {
|
||||
if (confirm('¿Estás seguro de que deseas remover este miembro?')) {
|
||||
@@ -217,6 +248,72 @@ const TeamPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const handleViewMemberDetails = (member: any) => {
|
||||
setSelectedMember(member);
|
||||
setMemberFormData({
|
||||
full_name: member.user?.full_name || member.user_full_name || '',
|
||||
email: member.user?.email || member.user_email || '',
|
||||
phone: member.user?.phone || '',
|
||||
role: member.role,
|
||||
language: member.user?.language || 'es',
|
||||
timezone: member.user?.timezone || 'Europe/Madrid',
|
||||
is_active: member.is_active,
|
||||
joined_at: member.joined_at
|
||||
});
|
||||
setModalMode('view');
|
||||
setShowMemberModal(true);
|
||||
};
|
||||
|
||||
const handleEditMember = () => {
|
||||
setModalMode('edit');
|
||||
};
|
||||
|
||||
const handleSaveMember = async () => {
|
||||
// TODO: Implement member update logic
|
||||
console.log('Saving member:', memberFormData);
|
||||
setShowMemberModal(false);
|
||||
};
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
const fieldMap: Record<number, string> = {
|
||||
0: 'full_name',
|
||||
1: 'email',
|
||||
2: 'phone',
|
||||
3: 'role',
|
||||
4: 'language',
|
||||
5: 'timezone',
|
||||
6: 'is_active'
|
||||
};
|
||||
|
||||
const fieldName = fieldMap[fieldIndex];
|
||||
if (fieldName) {
|
||||
// Convert string boolean values back to actual booleans for 'is_active' field
|
||||
const processedValue = fieldName === 'is_active' ? value === 'true' : value;
|
||||
|
||||
setMemberFormData((prev: any) => ({
|
||||
...prev,
|
||||
[fieldName]: processedValue
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewActivity = async (member: any) => {
|
||||
const userId = member.user_id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setActivityLoading(true);
|
||||
const activityData = await userService.getUserActivity(userId);
|
||||
setSelectedMemberActivity(activityData);
|
||||
setShowActivityModal(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user activity:', error);
|
||||
addToast('Error al cargar la actividad del usuario', { type: 'error' });
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMembers = enhancedTeamMembers.filter(member => {
|
||||
@@ -311,6 +408,7 @@ const TeamPage: React.FC = () => {
|
||||
canManageTeam ? [{
|
||||
id: 'add-member',
|
||||
label: 'Agregar Miembro',
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowAddForm(true)
|
||||
}] : undefined
|
||||
@@ -343,9 +441,21 @@ const TeamPage: React.FC = () => {
|
||||
value: teamStats.owners,
|
||||
icon: Crown,
|
||||
variant: "purple"
|
||||
},
|
||||
{
|
||||
title: "Roles Únicos",
|
||||
value: teamStats.uniqueRoles,
|
||||
icon: Users,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Días Promedio",
|
||||
value: teamStats.averageDaysInTeam,
|
||||
icon: UserCheck,
|
||||
variant: "info"
|
||||
}
|
||||
]}
|
||||
columns={4}
|
||||
columns={3}
|
||||
gap="md"
|
||||
/>
|
||||
|
||||
@@ -414,29 +524,18 @@ const TeamPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron miembros
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{searchTerm || selectedRole !== 'all'
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No se encontraron miembros"
|
||||
description={
|
||||
searchTerm || selectedRole !== 'all'
|
||||
? "No hay miembros que coincidan con los filtros seleccionados"
|
||||
: "Este tenant aún no tiene miembros del equipo"
|
||||
}
|
||||
</p>
|
||||
{canManageTeam && (
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="font-medium px-6 py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>Agregar Primer Miembro</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actionLabel={canManageTeam ? "Agregar Primer Miembro" : undefined}
|
||||
actionIcon={canManageTeam ? Plus : undefined}
|
||||
onAction={canManageTeam ? () => setShowAddForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal - Using StatusModal */}
|
||||
@@ -452,7 +551,7 @@ const TeamPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
// Check subscription limits before adding member
|
||||
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1);
|
||||
const usageCheck = await subscriptionService.checkQuotaLimit(tenantId, 'users', 1);
|
||||
|
||||
if (!usageCheck.allowed) {
|
||||
const errorMessage = usageCheck.message ||
|
||||
@@ -461,6 +560,10 @@ const TeamPage: React.FC = () => {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// The AddTeamMemberModal returns a string role, but it's always one of the tenant roles
|
||||
// Since the modal only allows MEMBER, ADMIN, VIEWER (no OWNER), we can safely cast it
|
||||
const role = userData.role as typeof TENANT_ROLES.MEMBER | typeof TENANT_ROLES.ADMIN | typeof TENANT_ROLES.VIEWER;
|
||||
|
||||
// Use appropriate mutation based on whether we're creating a user
|
||||
if (userData.createUser) {
|
||||
await addMemberWithUserMutation.mutateAsync({
|
||||
@@ -471,7 +574,7 @@ const TeamPage: React.FC = () => {
|
||||
full_name: userData.fullName!,
|
||||
password: userData.password!,
|
||||
phone: userData.phone,
|
||||
role: userData.role,
|
||||
role,
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid'
|
||||
}
|
||||
@@ -481,7 +584,7 @@ const TeamPage: React.FC = () => {
|
||||
await addMemberMutation.mutateAsync({
|
||||
tenantId,
|
||||
userId: userData.userId!,
|
||||
role: userData.role,
|
||||
role,
|
||||
});
|
||||
addToast('Miembro agregado exitosamente', { type: 'success' });
|
||||
}
|
||||
@@ -503,8 +606,197 @@ const TeamPage: React.FC = () => {
|
||||
}}
|
||||
availableUsers={[]}
|
||||
/>
|
||||
|
||||
{/* Team Member Details Modal */}
|
||||
<EditViewModal
|
||||
isOpen={showMemberModal}
|
||||
onClose={() => setShowMemberModal(false)}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={memberFormData.full_name || 'Miembro del Equipo'}
|
||||
subtitle={memberFormData.email}
|
||||
statusIndicator={selectedMember ? getMemberStatusConfig(selectedMember) : undefined}
|
||||
sections={[
|
||||
{
|
||||
title: 'Información Personal',
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre Completo',
|
||||
value: memberFormData.full_name,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Introduce el nombre completo',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
value: memberFormData.email,
|
||||
type: 'email',
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Introduce el email',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Teléfono',
|
||||
value: memberFormData.phone,
|
||||
type: 'tel',
|
||||
editable: true,
|
||||
placeholder: 'Introduce el teléfono',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración de Cuenta',
|
||||
icon: Shield,
|
||||
fields: [
|
||||
{
|
||||
label: 'Rol',
|
||||
value: getRoleLabel(memberFormData.role),
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: [
|
||||
{ label: 'Miembro - Acceso estándar', value: TENANT_ROLES.MEMBER },
|
||||
{ label: 'Administrador - Gestión de equipo', value: TENANT_ROLES.ADMIN },
|
||||
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
|
||||
],
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Idioma',
|
||||
value: memberFormData.language?.toUpperCase() || 'ES',
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: [
|
||||
{ label: 'Español', value: 'es' },
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Euskera', value: 'eu' }
|
||||
],
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Zona Horaria',
|
||||
value: memberFormData.timezone || 'Europe/Madrid',
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: [
|
||||
{ label: 'Madrid (CET)', value: 'Europe/Madrid' },
|
||||
{ label: 'London (GMT)', value: 'Europe/London' },
|
||||
{ label: 'New York (EST)', value: 'America/New_York' }
|
||||
],
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
value: memberFormData.is_active ? 'Activo' : 'Inactivo',
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: [
|
||||
{ label: 'Activo', value: 'true' },
|
||||
{ label: 'Inactivo', value: 'false' }
|
||||
],
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Detalles del Equipo',
|
||||
icon: UserCheck,
|
||||
fields: [
|
||||
{
|
||||
label: 'Fecha de Ingreso',
|
||||
value: memberFormData.joined_at,
|
||||
type: 'date',
|
||||
editable: false,
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Días en el Equipo',
|
||||
value: selectedMember ? Math.floor((Date.now() - new Date(selectedMember.joined_at).getTime()) / (1000 * 60 * 60 * 24)) : 0,
|
||||
type: 'number',
|
||||
editable: false,
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'ID de Usuario',
|
||||
value: selectedMember?.user_id || 'N/A',
|
||||
type: 'text',
|
||||
editable: false,
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
onFieldChange={handleFieldChange}
|
||||
onEdit={handleEditMember}
|
||||
onSave={handleSaveMember}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Activity Modal */}
|
||||
<EditViewModal
|
||||
isOpen={showActivityModal}
|
||||
onClose={() => setShowActivityModal(false)}
|
||||
mode="view"
|
||||
title="Actividad del Usuario"
|
||||
subtitle={selectedMemberActivity?.user_id ? `ID: ${selectedMemberActivity.user_id}` : ''}
|
||||
sections={[
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: Activity,
|
||||
fields: [
|
||||
{
|
||||
label: 'Estado de Cuenta',
|
||||
value: selectedMemberActivity?.is_active ? 'Activa' : 'Inactiva',
|
||||
type: 'text',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Estado de Verificación',
|
||||
value: selectedMemberActivity?.is_verified ? 'Verificada' : 'No verificada',
|
||||
type: 'text',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Fecha de Creación',
|
||||
value: selectedMemberActivity?.account_created ? new Date(selectedMemberActivity.account_created).toLocaleDateString('es-ES') : 'N/A',
|
||||
type: 'text',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Actividad Reciente',
|
||||
icon: Activity,
|
||||
fields: [
|
||||
{
|
||||
label: 'Último Inicio de Sesión',
|
||||
value: selectedMemberActivity?.last_login ? new Date(selectedMemberActivity.last_login).toLocaleString('es-ES') : 'Nunca',
|
||||
type: 'text',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Última Actividad',
|
||||
value: selectedMemberActivity?.last_activity ? new Date(selectedMemberActivity.last_activity).toLocaleString('es-ES') : 'N/A',
|
||||
type: 'text',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Sesiones Activas',
|
||||
value: selectedMemberActivity?.active_sessions || 0,
|
||||
type: 'number',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
export default TeamPage;
|
||||
|
||||
@@ -53,6 +53,7 @@ const OrganizationsPage = React.lazy(() => import('../pages/app/settings/organiz
|
||||
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
||||
const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/ModelsConfigPage'));
|
||||
const QualityTemplatesPage = React.lazy(() => import('../pages/app/database/quality-templates/QualityTemplatesPage'));
|
||||
const SustainabilityPage = React.lazy(() => import('../pages/app/database/sustainability/SustainabilityPage'));
|
||||
|
||||
// Data pages
|
||||
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
|
||||
@@ -238,6 +239,16 @@ export const AppRouter: React.FC = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/database/sustainability"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<SustainabilityPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/database/maquinaria"
|
||||
element={
|
||||
|
||||
@@ -97,7 +97,9 @@ export const ROUTES = {
|
||||
// Suppliers
|
||||
SUPPLIERS: '/app/database/suppliers',
|
||||
|
||||
|
||||
// Sustainability
|
||||
SUSTAINABILITY: '/app/database/sustainability',
|
||||
|
||||
// Point of Sale
|
||||
POS: '/app/operations/pos',
|
||||
POS_INTEGRATION: '/pos/integration',
|
||||
@@ -421,7 +423,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
name: 'Recipes',
|
||||
component: 'RecipesPage',
|
||||
title: 'Recetas',
|
||||
icon: 'production',
|
||||
icon: 'chef-hat',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
@@ -441,7 +443,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
name: 'Maquinaria',
|
||||
component: 'MaquinariaPage',
|
||||
title: 'Maquinaria',
|
||||
icon: 'production',
|
||||
icon: 'cog',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
@@ -451,7 +453,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
name: 'QualityTemplates',
|
||||
component: 'QualityTemplatesPage',
|
||||
title: 'Plantillas de Calidad',
|
||||
icon: 'settings',
|
||||
icon: 'clipboard-check',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
showInNavigation: true,
|
||||
@@ -473,7 +475,17 @@ export const routesConfig: RouteConfig[] = [
|
||||
name: 'ModelsConfig',
|
||||
component: 'ModelsConfigPage',
|
||||
title: 'Modelos IA',
|
||||
icon: 'training',
|
||||
icon: 'brain-circuit',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/database/sustainability',
|
||||
name: 'Sustainability',
|
||||
component: 'SustainabilityPage',
|
||||
title: 'Sostenibilidad',
|
||||
icon: 'leaf',
|
||||
requiresAuth: true,
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
|
||||
@@ -152,6 +152,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
apiClient.setRefreshToken(null);
|
||||
apiClient.setTenantId(null);
|
||||
|
||||
// Clear tenant store to remove cached tenant data
|
||||
// Import dynamically to avoid circular dependencies
|
||||
import('./tenant.store').then(({ useTenantStore }) => {
|
||||
useTenantStore.getState().clearTenants();
|
||||
}).catch(err => {
|
||||
console.warn('Failed to clear tenant store on logout:', err);
|
||||
});
|
||||
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
|
||||
Reference in New Issue
Block a user