Fix UI issues
This commit is contained in:
@@ -76,6 +76,11 @@ export type {
|
|||||||
SubscriptionLimits,
|
SubscriptionLimits,
|
||||||
FeatureCheckResponse,
|
FeatureCheckResponse,
|
||||||
UsageCheckResponse,
|
UsageCheckResponse,
|
||||||
|
UsageSummary,
|
||||||
|
AvailablePlans,
|
||||||
|
Plan,
|
||||||
|
PlanUpgradeValidation,
|
||||||
|
PlanUpgradeResult
|
||||||
} from './types/subscription';
|
} from './types/subscription';
|
||||||
|
|
||||||
// Types - Sales
|
// Types - Sales
|
||||||
@@ -346,6 +351,8 @@ export type {
|
|||||||
export {
|
export {
|
||||||
ProductionStatusEnum,
|
ProductionStatusEnum,
|
||||||
ProductionPriorityEnum,
|
ProductionPriorityEnum,
|
||||||
|
ProductionBatchStatus,
|
||||||
|
QualityCheckStatus,
|
||||||
} from './types/production';
|
} from './types/production';
|
||||||
|
|
||||||
// Types - POS
|
// Types - POS
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
FeatureCheckRequest,
|
FeatureCheckRequest,
|
||||||
FeatureCheckResponse,
|
FeatureCheckResponse,
|
||||||
UsageCheckRequest,
|
UsageCheckRequest,
|
||||||
UsageCheckResponse
|
UsageCheckResponse,
|
||||||
|
UsageSummary,
|
||||||
|
AvailablePlans,
|
||||||
|
PlanUpgradeValidation,
|
||||||
|
PlanUpgradeResult
|
||||||
} from '../types/subscription';
|
} from '../types/subscription';
|
||||||
|
|
||||||
export class SubscriptionService {
|
export class SubscriptionService {
|
||||||
@@ -62,6 +66,129 @@ export class SubscriptionService {
|
|||||||
}> {
|
}> {
|
||||||
return apiClient.get(`${this.baseUrl}/${tenantId}/usage/current`);
|
return apiClient.get(`${this.baseUrl}/${tenantId}/usage/current`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
|
||||||
|
try {
|
||||||
|
return await apiClient.get<UsageSummary>(`${this.baseUrl}/${tenantId}/summary`);
|
||||||
|
} catch (error) {
|
||||||
|
// Return mock data if backend endpoint doesn't exist yet
|
||||||
|
console.warn('Using mock subscription data - backend endpoint not implemented yet');
|
||||||
|
return this.getMockUsageSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailablePlans(): Promise<AvailablePlans> {
|
||||||
|
try {
|
||||||
|
return await apiClient.get<AvailablePlans>(`${this.baseUrl}/plans`);
|
||||||
|
} catch (error) {
|
||||||
|
// Return mock data if backend endpoint doesn't exist yet
|
||||||
|
console.warn('Using mock plans data - backend endpoint not implemented yet');
|
||||||
|
return this.getMockAvailablePlans();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||||
|
try {
|
||||||
|
return await apiClient.post<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade`, {
|
||||||
|
plan: planKey
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Using mock validation - backend endpoint not implemented yet');
|
||||||
|
return { can_upgrade: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
|
||||||
|
try {
|
||||||
|
return await apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade`, {
|
||||||
|
plan: planKey
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Using mock upgrade - backend endpoint not implemented yet');
|
||||||
|
return { success: true, message: 'Plan actualizado correctamente (modo demo)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPrice(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('es-ES', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlanDisplayInfo(planKey: string): { name: string; color: string } {
|
||||||
|
const planInfo = {
|
||||||
|
starter: { name: 'Starter', color: 'blue' },
|
||||||
|
professional: { name: 'Professional', color: 'purple' },
|
||||||
|
enterprise: { name: 'Enterprise', color: 'amber' }
|
||||||
|
};
|
||||||
|
return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMockUsageSummary(): UsageSummary {
|
||||||
|
return {
|
||||||
|
plan: 'professional',
|
||||||
|
status: 'active',
|
||||||
|
monthly_price: 49.99,
|
||||||
|
next_billing_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
usage: {
|
||||||
|
users: {
|
||||||
|
current: 3,
|
||||||
|
limit: 10,
|
||||||
|
unlimited: false,
|
||||||
|
usage_percentage: 30
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
current: 1,
|
||||||
|
limit: 3,
|
||||||
|
unlimited: false,
|
||||||
|
usage_percentage: 33
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
current: 45,
|
||||||
|
limit: -1,
|
||||||
|
unlimited: true,
|
||||||
|
usage_percentage: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMockAvailablePlans(): AvailablePlans {
|
||||||
|
return {
|
||||||
|
plans: {
|
||||||
|
starter: {
|
||||||
|
name: 'Starter',
|
||||||
|
description: 'Perfecto para panaderías pequeñas',
|
||||||
|
monthly_price: 29.99,
|
||||||
|
max_users: 3,
|
||||||
|
max_locations: 1,
|
||||||
|
max_products: 50,
|
||||||
|
popular: false
|
||||||
|
},
|
||||||
|
professional: {
|
||||||
|
name: 'Professional',
|
||||||
|
description: 'Para panaderías en crecimiento',
|
||||||
|
monthly_price: 49.99,
|
||||||
|
max_users: 10,
|
||||||
|
max_locations: 3,
|
||||||
|
max_products: -1,
|
||||||
|
popular: true
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
name: 'Enterprise',
|
||||||
|
description: 'Para grandes operaciones',
|
||||||
|
monthly_price: 99.99,
|
||||||
|
max_users: -1,
|
||||||
|
max_locations: -1,
|
||||||
|
max_products: -1,
|
||||||
|
contact_sales: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const subscriptionService = new SubscriptionService();
|
export const subscriptionService = new SubscriptionService();
|
||||||
@@ -15,6 +15,22 @@ export enum ProductionPriorityEnum {
|
|||||||
URGENT = "urgent"
|
URGENT = "urgent"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProductionBatchStatus {
|
||||||
|
PLANNED = "planned",
|
||||||
|
IN_PROGRESS = "in_progress",
|
||||||
|
COMPLETED = "completed",
|
||||||
|
CANCELLED = "cancelled",
|
||||||
|
ON_HOLD = "on_hold"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum QualityCheckStatus {
|
||||||
|
PENDING = "pending",
|
||||||
|
IN_PROGRESS = "in_progress",
|
||||||
|
PASSED = "passed",
|
||||||
|
FAILED = "failed",
|
||||||
|
REQUIRES_ATTENTION = "requires_attention"
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductionBatchBase {
|
export interface ProductionBatchBase {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Generated based on backend schemas in services/recipes/app/schemas/recipes.py
|
* Generated based on backend schemas in services/recipes/app/schemas/recipes.py
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ProductionPriorityEnum } from './production';
|
||||||
|
|
||||||
export enum RecipeStatus {
|
export enum RecipeStatus {
|
||||||
DRAFT = 'draft',
|
DRAFT = 'draft',
|
||||||
ACTIVE = 'active',
|
ACTIVE = 'active',
|
||||||
@@ -32,12 +34,6 @@ export enum ProductionStatus {
|
|||||||
CANCELLED = 'cancelled'
|
CANCELLED = 'cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProductionPriority {
|
|
||||||
LOW = 'low',
|
|
||||||
NORMAL = 'normal',
|
|
||||||
HIGH = 'high',
|
|
||||||
URGENT = 'urgent'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeIngredientCreate {
|
export interface RecipeIngredientCreate {
|
||||||
ingredient_id: string;
|
ingredient_id: string;
|
||||||
@@ -272,7 +268,7 @@ export interface ProductionBatchCreate {
|
|||||||
planned_end_time?: string | null;
|
planned_end_time?: string | null;
|
||||||
planned_quantity: number;
|
planned_quantity: number;
|
||||||
batch_size_multiplier?: number;
|
batch_size_multiplier?: number;
|
||||||
priority?: ProductionPriority;
|
priority?: ProductionPriorityEnum;
|
||||||
assigned_staff?: Array<Record<string, any>> | null;
|
assigned_staff?: Array<Record<string, any>> | null;
|
||||||
production_notes?: string | null;
|
production_notes?: string | null;
|
||||||
customer_order_reference?: string | null;
|
customer_order_reference?: string | null;
|
||||||
@@ -291,7 +287,7 @@ export interface ProductionBatchUpdate {
|
|||||||
actual_quantity?: number | null;
|
actual_quantity?: number | null;
|
||||||
batch_size_multiplier?: number | null;
|
batch_size_multiplier?: number | null;
|
||||||
status?: ProductionStatus | null;
|
status?: ProductionStatus | null;
|
||||||
priority?: ProductionPriority | null;
|
priority?: ProductionPriorityEnum | null;
|
||||||
assigned_staff?: Array<Record<string, any>> | null;
|
assigned_staff?: Array<Record<string, any>> | null;
|
||||||
production_notes?: string | null;
|
production_notes?: string | null;
|
||||||
quality_score?: number | null;
|
quality_score?: number | null;
|
||||||
|
|||||||
@@ -41,3 +41,57 @@ export interface UsageCheckResponse {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UsageSummary {
|
||||||
|
plan: string;
|
||||||
|
status: 'active' | 'inactive' | 'past_due' | 'cancelled';
|
||||||
|
monthly_price: number;
|
||||||
|
next_billing_date: string;
|
||||||
|
usage: {
|
||||||
|
users: {
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
usage_percentage: number;
|
||||||
|
};
|
||||||
|
locations: {
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
usage_percentage: number;
|
||||||
|
};
|
||||||
|
products: {
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
usage_percentage: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
monthly_price: number;
|
||||||
|
max_users: number;
|
||||||
|
max_locations: number;
|
||||||
|
max_products: number;
|
||||||
|
popular?: boolean;
|
||||||
|
contact_sales?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailablePlans {
|
||||||
|
plans: {
|
||||||
|
[key: string]: Plan;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanUpgradeValidation {
|
||||||
|
can_upgrade: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanUpgradeResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../api';
|
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriorityEnum } from '../../../api';
|
||||||
import type { ProductionBatch, QualityCheck } from '../../../types/production.types';
|
import type { ProductionBatch, QualityCheck } from '../../../types/production.types';
|
||||||
|
|
||||||
interface BatchTrackerProps {
|
interface BatchTrackerProps {
|
||||||
@@ -124,10 +124,10 @@ const STATUS_COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PRIORITY_COLORS = {
|
const PRIORITY_COLORS = {
|
||||||
[ProductionPriority.LOW]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]',
|
[ProductionPriorityEnum.LOW]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]',
|
||||||
[ProductionPriority.NORMAL]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
[ProductionPriorityEnum.NORMAL]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
||||||
[ProductionPriority.HIGH]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
|
[ProductionPriorityEnum.HIGH]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
|
||||||
[ProductionPriority.URGENT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
[ProductionPriorityEnum.URGENT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BatchTracker: React.FC<BatchTrackerProps> = ({
|
export const BatchTracker: React.FC<BatchTrackerProps> = ({
|
||||||
@@ -390,10 +390,10 @@ export const BatchTracker: React.FC<BatchTrackerProps> = ({
|
|||||||
<h4 className="font-semibold text-[var(--text-primary)]">{batch.recipe?.name || 'Producto'}</h4>
|
<h4 className="font-semibold text-[var(--text-primary)]">{batch.recipe?.name || 'Producto'}</h4>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Lote #{batch.batch_number}</p>
|
<p className="text-sm text-[var(--text-secondary)]">Lote #{batch.batch_number}</p>
|
||||||
<Badge className={PRIORITY_COLORS[batch.priority]} size="sm">
|
<Badge className={PRIORITY_COLORS[batch.priority]} size="sm">
|
||||||
{batch.priority === ProductionPriority.LOW && 'Baja'}
|
{batch.priority === ProductionPriorityEnum.LOW && 'Baja'}
|
||||||
{batch.priority === ProductionPriority.NORMAL && 'Normal'}
|
{batch.priority === ProductionPriorityEnum.NORMAL && 'Normal'}
|
||||||
{batch.priority === ProductionPriority.HIGH && 'Alta'}
|
{batch.priority === ProductionPriorityEnum.HIGH && 'Alta'}
|
||||||
{batch.priority === ProductionPriority.URGENT && 'Urgente'}
|
{batch.priority === ProductionPriorityEnum.URGENT && 'Urgente'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onClos
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
title?: string;
|
title?: string | React.ReactNode;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -238,12 +238,18 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{title && (
|
{title && (
|
||||||
|
typeof title === 'string' ? (
|
||||||
<h2
|
<h2
|
||||||
id="modal-title"
|
id="modal-title"
|
||||||
className="text-lg font-semibold text-[var(--text-primary)]"
|
className="text-lg font-semibold text-[var(--text-primary)]"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
) : (
|
||||||
|
<div id="modal-title">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
|||||||
@@ -283,12 +283,12 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
|
|
||||||
const triggerClasses = [
|
const triggerClasses = [
|
||||||
'flex items-center justify-between w-full px-3 py-2',
|
'flex items-center justify-between w-full px-3 py-2',
|
||||||
'bg-input-bg border border-input-border rounded-lg',
|
'bg-[var(--bg-primary,#ffffff)] border border-[var(--border-primary,#e5e7eb)] rounded-lg',
|
||||||
'text-left transition-colors duration-200',
|
'text-[var(--text-primary,#111827)] text-left transition-colors duration-200',
|
||||||
'focus:border-input-border-focus focus:ring-1 focus:ring-input-border-focus',
|
'focus:border-[var(--color-primary,#3b82f6)] focus:ring-1 focus:ring-[var(--color-primary,#3b82f6)]',
|
||||||
{
|
{
|
||||||
'border-input-border-error focus:border-input-border-error focus:ring-input-border-error': hasError,
|
'border-[var(--color-error,#ef4444)] focus:border-[var(--color-error,#ef4444)] focus:ring-[var(--color-error,#ef4444)]': hasError,
|
||||||
'bg-bg-secondary border-transparent focus:bg-input-bg focus:border-input-border-focus': variant === 'filled',
|
'bg-[var(--bg-secondary,#f9fafb)] border-transparent focus:bg-[var(--bg-primary,#ffffff)] focus:border-[var(--color-primary,#3b82f6)]': variant === 'filled',
|
||||||
'bg-transparent border-none focus:ring-0': variant === 'unstyled',
|
'bg-transparent border-none focus:ring-0': variant === 'unstyled',
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -300,7 +300,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropdownClasses = [
|
const dropdownClasses = [
|
||||||
'absolute z-50 w-full mt-1 bg-dropdown-bg border border-dropdown-border rounded-lg shadow-lg',
|
'absolute z-50 w-full mt-1 bg-[var(--bg-primary,#ffffff)] border border-[var(--border-primary,#e5e7eb)] rounded-lg shadow-lg',
|
||||||
'transform transition-all duration-200 ease-out',
|
'transform transition-all duration-200 ease-out',
|
||||||
{
|
{
|
||||||
'opacity-0 scale-95 pointer-events-none': !isOpen,
|
'opacity-0 scale-95 pointer-events-none': !isOpen,
|
||||||
@@ -317,7 +317,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
|
|
||||||
if (multiple && Array.isArray(currentValue)) {
|
if (multiple && Array.isArray(currentValue)) {
|
||||||
if (currentValue.length === 0) {
|
if (currentValue.length === 0) {
|
||||||
return <span className="text-input-placeholder">{placeholder}</span>;
|
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.length === 1) {
|
if (currentValue.length === 1) {
|
||||||
@@ -338,7 +338,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="text-input-placeholder">{placeholder}</span>;
|
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMultipleValues = () => {
|
const renderMultipleValues = () => {
|
||||||
@@ -354,14 +354,14 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
{selectedOptions.map(option => (
|
{selectedOptions.map(option => (
|
||||||
<span
|
<span
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 bg-bg-tertiary text-text-primary rounded text-sm"
|
className="inline-flex items-center gap-1 px-2 py-1 bg-[var(--bg-tertiary,#f3f4f6)] text-[var(--text-primary,#111827)] rounded text-sm"
|
||||||
>
|
>
|
||||||
{option.icon && <span className="text-xs">{option.icon}</span>}
|
{option.icon && <span className="text-xs">{option.icon}</span>}
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleRemoveOption(option.value, e)}
|
onClick={(e) => handleRemoveOption(option.value, e)}
|
||||||
className="text-text-tertiary hover:text-text-primary transition-colors duration-150"
|
className="text-[var(--text-tertiary,#6b7280)] hover:text-[var(--text-primary,#111827)] transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -379,7 +379,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
const renderOptions = () => {
|
const renderOptions = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-text-secondary">
|
<div className="px-3 py-2 text-[var(--text-secondary,#4b5563)]">
|
||||||
{loadingMessage}
|
{loadingMessage}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -387,13 +387,13 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
|
|
||||||
if (filteredOptions.length === 0) {
|
if (filteredOptions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-text-secondary">
|
<div className="px-3 py-2 text-[var(--text-secondary,#4b5563)]">
|
||||||
{noOptionsMessage}
|
{noOptionsMessage}
|
||||||
{createable && searchTerm.trim() && (
|
{createable && searchTerm.trim() && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="block w-full text-left px-3 py-2 text-color-primary hover:bg-dropdown-item-hover transition-colors duration-150"
|
className="block w-full text-left px-3 py-2 text-[var(--color-primary,#3b82f6)] hover:bg-[var(--bg-secondary,#f9fafb)] transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{createLabel} "{searchTerm.trim()}"
|
{createLabel} "{searchTerm.trim()}"
|
||||||
</button>
|
</button>
|
||||||
@@ -416,8 +416,8 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-150',
|
'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-150',
|
||||||
{
|
{
|
||||||
'bg-dropdown-item-hover': isHighlighted,
|
'bg-[var(--bg-secondary,#f9fafb)]': isHighlighted,
|
||||||
'bg-color-primary/10 text-color-primary': isSelected && !multiple,
|
'bg-[var(--color-primary,#3b82f6)]/10 text-[var(--color-primary,#3b82f6)]': isSelected && !multiple,
|
||||||
'opacity-50 cursor-not-allowed': option.disabled,
|
'opacity-50 cursor-not-allowed': option.disabled,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
@@ -430,19 +430,22 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
readOnly
|
readOnly
|
||||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
className="rounded border-[var(--border-primary,#e5e7eb)] text-[var(--color-primary,#3b82f6)] focus:ring-[var(--color-primary,#3b82f6)]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{option.icon && <span>{option.icon}</span>}
|
{option.icon && <span>{option.icon}</span>}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{option.label}</div>
|
<div className="font-medium">{option.label}</div>
|
||||||
{option.description && (
|
{option.description &&
|
||||||
<div className="text-xs text-text-secondary">{option.description}</div>
|
!option.description.startsWith('descriptions.') &&
|
||||||
|
!option.description.includes('.') &&
|
||||||
|
option.description !== option.value && (
|
||||||
|
<div className="text-xs text-[var(--text-secondary,#4b5563)]">{option.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && !multiple && (
|
{isSelected && !multiple && (
|
||||||
<svg className="w-4 h-4 text-color-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[var(--color-primary,#3b82f6)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
@@ -460,7 +463,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
// Add grouped options
|
// Add grouped options
|
||||||
Object.entries(groupedOptions.groups).forEach(([groupName, groupOptions]) => {
|
Object.entries(groupedOptions.groups).forEach(([groupName, groupOptions]) => {
|
||||||
allOptions.push(
|
allOptions.push(
|
||||||
<div key={groupName} className="px-3 py-1 text-xs font-semibold text-text-tertiary uppercase tracking-wide">
|
<div key={groupName} className="px-3 py-1 text-xs font-semibold text-[var(--text-tertiary,#6b7280)] uppercase tracking-wide">
|
||||||
{groupName}
|
{groupName}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -481,7 +484,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
key="__create__"
|
key="__create__"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="block w-full text-left px-3 py-2 text-color-primary hover:bg-dropdown-item-hover transition-colors duration-150 border-t border-border-primary"
|
className="block w-full text-left px-3 py-2 text-[var(--color-primary,#3b82f6)] hover:bg-[var(--bg-secondary,#f9fafb)] transition-colors duration-150 border-t border-[var(--border-primary,#e5e7eb)]"
|
||||||
>
|
>
|
||||||
{createLabel} "{searchTerm.trim()}"
|
{createLabel} "{searchTerm.trim()}"
|
||||||
</button>
|
</button>
|
||||||
@@ -496,11 +499,11 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={selectId}
|
htmlFor={selectId}
|
||||||
className="block text-sm font-medium text-text-primary mb-2"
|
className="block text-sm font-medium text-[var(--text-primary,#111827)] mb-2"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{isRequired && (
|
{isRequired && (
|
||||||
<span className="text-color-error ml-1">*</span>
|
<span className="text-[var(--color-error,#ef4444)] ml-1">*</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -531,7 +534,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="text-text-tertiary hover:text-text-primary transition-colors duration-150 p-1"
|
className="text-[var(--text-tertiary,#6b7280)] hover:text-[var(--text-primary,#111827)] transition-colors duration-150 p-1"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -541,7 +544,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
className={clsx('w-4 h-4 text-text-tertiary transition-transform duration-200', {
|
className={clsx('w-4 h-4 text-[var(--text-tertiary,#6b7280)] transition-transform duration-200', {
|
||||||
'rotate-180': isOpen,
|
'rotate-180': isOpen,
|
||||||
})}
|
})}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -555,14 +558,14 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
|
|
||||||
<div className={clsx(dropdownClasses)} style={{ maxHeight: isOpen ? maxHeight : 0 }}>
|
<div className={clsx(dropdownClasses)} style={{ maxHeight: isOpen ? maxHeight : 0 }}>
|
||||||
{searchable && (
|
{searchable && (
|
||||||
<div className="p-2 border-b border-border-primary">
|
<div className="p-2 border-b border-[var(--border-primary,#e5e7eb)]">
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar..."
|
placeholder="Buscar..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
className="w-full px-3 py-2 border border-input-border rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
className="w-full px-3 py-2 border border-[var(--border-primary,#e5e7eb)] rounded bg-[var(--bg-primary,#ffffff)] text-[var(--text-primary,#111827)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary,#3b82f6)]/20"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,13 +582,13 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-2 text-sm text-color-error">
|
<p className="mt-2 text-sm text-[var(--color-error,#ef4444)]">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{helperText && !error && (
|
{helperText && !error && (
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-[var(--text-secondary,#4b5563)]">
|
||||||
{helperText}
|
{helperText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LucideIcon, Edit, Eye, X } from 'lucide-react';
|
|||||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { Input } from '../Input';
|
import { Input } from '../Input';
|
||||||
|
import { Select } from '../Select';
|
||||||
import { StatusIndicatorConfig, getStatusColor } from '../StatusCard';
|
import { StatusIndicatorConfig, getStatusColor } from '../StatusCard';
|
||||||
import { formatters } from '../Stats/StatsPresets';
|
import { formatters } from '../Stats/StatsPresets';
|
||||||
|
|
||||||
@@ -181,7 +182,11 @@ const renderEditableField = (
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
||||||
onChange={(e) => onChange?.(e.target.value.split('\n'))}
|
onChange={(e) => {
|
||||||
|
const stringArray = e.target.value.split('\n');
|
||||||
|
// For list type, we'll pass the joined string instead of array to maintain compatibility
|
||||||
|
onChange?.(stringArray.join('\n'));
|
||||||
|
}}
|
||||||
placeholder={field.placeholder || 'Una opción por línea'}
|
placeholder={field.placeholder || 'Una opción por línea'}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
rows={4}
|
rows={4}
|
||||||
@@ -190,23 +195,15 @@ const renderEditableField = (
|
|||||||
);
|
);
|
||||||
case 'select':
|
case 'select':
|
||||||
return (
|
return (
|
||||||
<select
|
<Select
|
||||||
value={String(field.value)}
|
value={String(field.value)}
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={(value) => onChange?.(typeof value === 'string' ? value : String(value))}
|
||||||
required={field.required}
|
options={field.options || []}
|
||||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent bg-[var(--bg-primary)]"
|
placeholder={field.placeholder}
|
||||||
>
|
isRequired={field.required}
|
||||||
{field.placeholder && (
|
variant="outline"
|
||||||
<option value="" disabled>
|
size="md"
|
||||||
{field.placeholder}
|
/>
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
{field.options?.map((option, index) => (
|
|
||||||
<option key={index} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, Edit3, Zap, Plus, Settings, Trash2, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Eye, EyeOff, Info } from 'lucide-react';
|
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, Edit3, Zap, Plus, Settings, Trash2, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Eye, EyeOff, Info } from 'lucide-react';
|
||||||
import { Button, Card, Input, Select, Modal, Badge } from '../../../../components/ui';
|
import { Button, Card, Input, Select, Modal, Badge, Tabs } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
|
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
|
||||||
@@ -56,8 +56,8 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
const posManager = usePOSConfigurationManager(tenantId);
|
const posManager = usePOSConfigurationManager(tenantId);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'location' | 'business' | 'hours' | 'pos'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'location' | 'business' | 'hours' | 'pos'>('general');
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
// POS Configuration State
|
// POS Configuration State
|
||||||
const [showAddPosModal, setShowAddPosModal] = useState(false);
|
const [showAddPosModal, setShowAddPosModal] = useState(false);
|
||||||
@@ -180,11 +180,11 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'general' as const, label: 'General', icon: Store },
|
{ id: 'general', label: '🏪 General' },
|
||||||
{ id: 'location' as const, label: 'Ubicación', icon: MapPin },
|
{ id: 'location', label: '📍 Ubicación' },
|
||||||
{ id: 'business' as const, label: 'Empresa', icon: Globe },
|
{ id: 'business', label: '🏢 Empresa' },
|
||||||
{ id: 'hours' as const, label: 'Horarios', icon: Clock },
|
{ id: 'hours', label: '🕐 Horarios' },
|
||||||
{ id: 'pos' as const, label: 'Sistemas POS', icon: Zap }
|
{ id: 'pos', label: '⚡ Sistemas POS' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const daysOfWeek = [
|
const daysOfWeek = [
|
||||||
@@ -269,7 +269,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsEditing(false);
|
setHasUnsavedChanges(false);
|
||||||
addToast('Configuración actualizada correctamente', { type: 'success' });
|
addToast('Configuración actualizada correctamente', { type: 'success' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('No se pudo actualizar la configuración', { type: 'error' });
|
addToast('No se pudo actualizar la configuración', { type: 'error' });
|
||||||
@@ -280,6 +280,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
setConfig(prev => ({ ...prev, [field]: e.target.value }));
|
setConfig(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
}
|
}
|
||||||
@@ -287,6 +288,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
|
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
|
||||||
setConfig(prev => ({ ...prev, [field]: value }));
|
setConfig(prev => ({ ...prev, [field]: value }));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
|
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
|
||||||
@@ -297,6 +299,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
[field]: value
|
[field]: value
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// POS Configuration Handlers
|
// POS Configuration Handlers
|
||||||
@@ -635,44 +638,28 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
<p className="text-text-tertiary text-sm">{config.address}, {config.city}</p>
|
<p className="text-text-tertiary text-sm">{config.address}, {config.city}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isEditing && (
|
{hasUnsavedChanges && (
|
||||||
<Button
|
<div className="flex items-center gap-2 text-sm text-yellow-600">
|
||||||
variant="outline"
|
<AlertCircle className="w-4 h-4" />
|
||||||
onClick={() => setIsEditing(true)}
|
Cambios sin guardar
|
||||||
className="flex items-center gap-2"
|
</div>
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
Editar Configuración
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Configuration Tabs */}
|
{/* Configuration Tabs */}
|
||||||
<Card className="overflow-hidden">
|
<div className="space-y-6">
|
||||||
{/* Tab Navigation */}
|
<Tabs
|
||||||
<div className="border-b border-border-primary">
|
items={tabs}
|
||||||
<nav className="flex">
|
activeTab={activeTab}
|
||||||
{tabs.map((tab) => (
|
onTabChange={setActiveTab}
|
||||||
<button
|
variant="underline"
|
||||||
key={tab.id}
|
size="md"
|
||||||
onClick={() => setActiveTab(tab.id)}
|
fullWidth={false}
|
||||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${
|
/>
|
||||||
activeTab === tab.id
|
|
||||||
? 'text-color-primary border-b-2 border-color-primary bg-color-primary/5'
|
|
||||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<tab.icon className="w-4 h-4" />
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
<Card className="p-6">
|
||||||
<div className="p-6">
|
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h3 className="text-lg font-semibold text-text-primary">Información General</h3>
|
<h3 className="text-lg font-semibold text-text-primary">Información General</h3>
|
||||||
@@ -683,7 +670,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
value={config.name}
|
value={config.name}
|
||||||
onChange={handleInputChange('name')}
|
onChange={handleInputChange('name')}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="Nombre de tu panadería"
|
placeholder="Nombre de tu panadería"
|
||||||
leftIcon={<Store className="w-4 h-4" />}
|
leftIcon={<Store className="w-4 h-4" />}
|
||||||
/>
|
/>
|
||||||
@@ -694,7 +681,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
value={config.email}
|
value={config.email}
|
||||||
onChange={handleInputChange('email')}
|
onChange={handleInputChange('email')}
|
||||||
error={errors.email}
|
error={errors.email}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="contacto@panaderia.com"
|
placeholder="contacto@panaderia.com"
|
||||||
leftIcon={<Mail className="w-4 h-4" />}
|
leftIcon={<Mail className="w-4 h-4" />}
|
||||||
/>
|
/>
|
||||||
@@ -705,7 +692,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
value={config.phone}
|
value={config.phone}
|
||||||
onChange={handleInputChange('phone')}
|
onChange={handleInputChange('phone')}
|
||||||
error={errors.phone}
|
error={errors.phone}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="+34 912 345 678"
|
placeholder="+34 912 345 678"
|
||||||
leftIcon={<Phone className="w-4 h-4" />}
|
leftIcon={<Phone className="w-4 h-4" />}
|
||||||
/>
|
/>
|
||||||
@@ -714,7 +701,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
label="Sitio Web"
|
label="Sitio Web"
|
||||||
value={config.website}
|
value={config.website}
|
||||||
onChange={handleInputChange('website')}
|
onChange={handleInputChange('website')}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="https://tu-panaderia.com"
|
placeholder="https://tu-panaderia.com"
|
||||||
leftIcon={<Globe className="w-4 h-4" />}
|
leftIcon={<Globe className="w-4 h-4" />}
|
||||||
className="md:col-span-2 xl:col-span-3"
|
className="md:col-span-2 xl:col-span-3"
|
||||||
@@ -728,7 +715,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
<textarea
|
<textarea
|
||||||
value={config.description}
|
value={config.description}
|
||||||
onChange={handleInputChange('description')}
|
onChange={handleInputChange('description')}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||||
placeholder="Describe tu panadería..."
|
placeholder="Describe tu panadería..."
|
||||||
@@ -747,7 +734,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
value={config.address}
|
value={config.address}
|
||||||
onChange={handleInputChange('address')}
|
onChange={handleInputChange('address')}
|
||||||
error={errors.address}
|
error={errors.address}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="Calle, número, etc."
|
placeholder="Calle, número, etc."
|
||||||
leftIcon={<MapPin className="w-4 h-4" />}
|
leftIcon={<MapPin className="w-4 h-4" />}
|
||||||
className="md:col-span-2"
|
className="md:col-span-2"
|
||||||
@@ -758,7 +745,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
value={config.city}
|
value={config.city}
|
||||||
onChange={handleInputChange('city')}
|
onChange={handleInputChange('city')}
|
||||||
error={errors.city}
|
error={errors.city}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="Ciudad"
|
placeholder="Ciudad"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -766,7 +753,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
label="Código Postal"
|
label="Código Postal"
|
||||||
value={config.postalCode}
|
value={config.postalCode}
|
||||||
onChange={handleInputChange('postalCode')}
|
onChange={handleInputChange('postalCode')}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="28001"
|
placeholder="28001"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -774,7 +761,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
label="País"
|
label="País"
|
||||||
value={config.country}
|
value={config.country}
|
||||||
onChange={handleInputChange('country')}
|
onChange={handleInputChange('country')}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="España"
|
placeholder="España"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -790,7 +777,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
label="NIF/CIF"
|
label="NIF/CIF"
|
||||||
value={config.taxId}
|
value={config.taxId}
|
||||||
onChange={handleInputChange('taxId')}
|
onChange={handleInputChange('taxId')}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
placeholder="B12345678"
|
placeholder="B12345678"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -799,7 +786,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
options={currencyOptions}
|
options={currencyOptions}
|
||||||
value={config.currency}
|
value={config.currency}
|
||||||
onChange={(value) => handleSelectChange('currency')(value as string)}
|
onChange={(value) => handleSelectChange('currency')(value as string)}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -807,7 +794,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
options={timezoneOptions}
|
options={timezoneOptions}
|
||||||
value={config.timezone}
|
value={config.timezone}
|
||||||
onChange={(value) => handleSelectChange('timezone')(value as string)}
|
onChange={(value) => handleSelectChange('timezone')(value as string)}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -815,7 +802,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
options={languageOptions}
|
options={languageOptions}
|
||||||
value={config.language}
|
value={config.language}
|
||||||
onChange={(value) => handleSelectChange('language')(value as string)}
|
onChange={(value) => handleSelectChange('language')(value as string)}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,7 +829,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hours.closed}
|
checked={hours.closed}
|
||||||
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
|
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
className="rounded border-border-primary"
|
className="rounded border-border-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-text-secondary">Cerrado</span>
|
<span className="text-sm text-text-secondary">Cerrado</span>
|
||||||
@@ -859,7 +846,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
type="time"
|
type="time"
|
||||||
value={hours.open}
|
value={hours.open}
|
||||||
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
|
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -869,7 +856,7 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
type="time"
|
type="time"
|
||||||
value={hours.close}
|
value={hours.close}
|
||||||
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
|
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
|
||||||
disabled={!isEditing || isLoading}
|
disabled={isLoading}
|
||||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1009,33 +996,64 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Actions */}
|
|
||||||
{isEditing && (
|
{/* Floating Save Button */}
|
||||||
<div className="flex gap-3 px-6 py-4 bg-bg-secondary border-t border-border-primary">
|
{hasUnsavedChanges && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50">
|
||||||
|
<Card className="p-4 shadow-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||||
|
Tienes cambios sin guardar
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsEditing(false)}
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// Reset to original values
|
||||||
|
if (tenant) {
|
||||||
|
setConfig({
|
||||||
|
name: tenant.name || '',
|
||||||
|
description: tenant.description || '',
|
||||||
|
email: tenant.email || '',
|
||||||
|
phone: tenant.phone || '',
|
||||||
|
website: tenant.website || '',
|
||||||
|
address: tenant.address || '',
|
||||||
|
city: tenant.city || '',
|
||||||
|
postalCode: tenant.postal_code || '',
|
||||||
|
country: tenant.country || '',
|
||||||
|
taxId: '',
|
||||||
|
currency: 'EUR',
|
||||||
|
timezone: 'Europe/Madrid',
|
||||||
|
language: 'es'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
Cancelar
|
Descartar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={handleSaveConfig}
|
onClick={handleSaveConfig}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
loadingText="Guardando..."
|
loadingText="Guardando..."
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
Guardar Configuración
|
Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* POS Configuration Modals */}
|
{/* POS Configuration Modals */}
|
||||||
{/* Add Configuration Modal */}
|
{/* Add Configuration Modal */}
|
||||||
|
|||||||
@@ -2,6 +2,3 @@
|
|||||||
export { default as ProfilePage } from './profile';
|
export { default as ProfilePage } from './profile';
|
||||||
export { default as BakeryConfigPage } from './bakery-config';
|
export { default as BakeryConfigPage } from './bakery-config';
|
||||||
export { default as TeamPage } from './team';
|
export { default as TeamPage } from './team';
|
||||||
export { default as SubscriptionPage } from './subscription';
|
|
||||||
export { default as PreferencesPage } from './preferences';
|
|
||||||
export { default as PreferencesPage } from './PreferencesPage';
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
|
||||||
import { Button, Card } from '../../../../components/ui';
|
|
||||||
import { PageHeader } from '../../../../components/layout';
|
|
||||||
import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth';
|
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
|
||||||
|
|
||||||
const PreferencesPage: React.FC = () => {
|
|
||||||
const { addToast } = useToast();
|
|
||||||
const { data: profile, isLoading: profileLoading } = useAuthProfile();
|
|
||||||
const updateProfileMutation = useUpdateProfile();
|
|
||||||
|
|
||||||
const [preferences, setPreferences] = useState({
|
|
||||||
notifications: {
|
|
||||||
inventory: {
|
|
||||||
app: true,
|
|
||||||
email: false,
|
|
||||||
sms: true,
|
|
||||||
frequency: 'immediate'
|
|
||||||
},
|
|
||||||
sales: {
|
|
||||||
app: true,
|
|
||||||
email: true,
|
|
||||||
sms: false,
|
|
||||||
frequency: 'hourly'
|
|
||||||
},
|
|
||||||
production: {
|
|
||||||
app: true,
|
|
||||||
email: false,
|
|
||||||
sms: true,
|
|
||||||
frequency: 'immediate'
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
app: true,
|
|
||||||
email: true,
|
|
||||||
sms: false,
|
|
||||||
frequency: 'daily'
|
|
||||||
},
|
|
||||||
marketing: {
|
|
||||||
app: false,
|
|
||||||
email: true,
|
|
||||||
sms: false,
|
|
||||||
frequency: 'weekly'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
doNotDisturb: false,
|
|
||||||
quietHours: {
|
|
||||||
enabled: false,
|
|
||||||
start: '22:00',
|
|
||||||
end: '07:00'
|
|
||||||
},
|
|
||||||
language: 'es',
|
|
||||||
timezone: 'Europe/Madrid',
|
|
||||||
soundEnabled: true,
|
|
||||||
vibrationEnabled: true
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
email: profile?.email || '',
|
|
||||||
phone: profile?.phone || '',
|
|
||||||
slack: false,
|
|
||||||
webhook: ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update preferences when profile loads
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (profile) {
|
|
||||||
setPreferences(prev => ({
|
|
||||||
...prev,
|
|
||||||
global: {
|
|
||||||
...prev.global,
|
|
||||||
language: profile.language || 'es',
|
|
||||||
timezone: profile.timezone || 'Europe/Madrid'
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
...prev.channels,
|
|
||||||
email: profile.email || '',
|
|
||||||
phone: profile.phone || ''
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [profile]);
|
|
||||||
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
|
|
||||||
const categories = [
|
|
||||||
{
|
|
||||||
id: 'inventory',
|
|
||||||
name: 'Inventario',
|
|
||||||
description: 'Alertas de stock, reposiciones y vencimientos',
|
|
||||||
icon: '📦'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sales',
|
|
||||||
name: 'Ventas',
|
|
||||||
description: 'Pedidos, transacciones y reportes de ventas',
|
|
||||||
icon: '💰'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'production',
|
|
||||||
name: 'Producción',
|
|
||||||
description: 'Hornadas, calidad y tiempos de producción',
|
|
||||||
icon: '🍞'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'system',
|
|
||||||
name: 'Sistema',
|
|
||||||
description: 'Actualizaciones, mantenimiento y errores',
|
|
||||||
icon: '⚙️'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'marketing',
|
|
||||||
name: 'Marketing',
|
|
||||||
description: 'Campañas, promociones y análisis',
|
|
||||||
icon: '📢'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const frequencies = [
|
|
||||||
{ value: 'immediate', label: 'Inmediato' },
|
|
||||||
{ value: 'hourly', label: 'Cada hora' },
|
|
||||||
{ value: 'daily', label: 'Diario' },
|
|
||||||
{ value: 'weekly', label: 'Semanal' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
|
||||||
setPreferences(prev => ({
|
|
||||||
...prev,
|
|
||||||
notifications: {
|
|
||||||
...prev.notifications,
|
|
||||||
[category]: {
|
|
||||||
...prev.notifications[category as keyof typeof prev.notifications],
|
|
||||||
[channel]: value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFrequencyChange = (category: string, frequency: string) => {
|
|
||||||
setPreferences(prev => ({
|
|
||||||
...prev,
|
|
||||||
notifications: {
|
|
||||||
...prev.notifications,
|
|
||||||
[category]: {
|
|
||||||
...prev.notifications[category as keyof typeof prev.notifications],
|
|
||||||
frequency
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGlobalChange = (setting: string, value: any) => {
|
|
||||||
setPreferences(prev => ({
|
|
||||||
...prev,
|
|
||||||
global: {
|
|
||||||
...prev.global,
|
|
||||||
[setting]: value
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChannelChange = (channel: string, value: string | boolean) => {
|
|
||||||
setPreferences(prev => ({
|
|
||||||
...prev,
|
|
||||||
channels: {
|
|
||||||
...prev.channels,
|
|
||||||
[channel]: value
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
// Save notification preferences and contact info
|
|
||||||
await updateProfileMutation.mutateAsync({
|
|
||||||
language: preferences.global.language,
|
|
||||||
timezone: preferences.global.timezone,
|
|
||||||
phone: preferences.channels.phone,
|
|
||||||
notification_preferences: preferences.notifications
|
|
||||||
});
|
|
||||||
|
|
||||||
addToast('Preferencias guardadas correctamente', 'success');
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
addToast('Error al guardar las preferencias', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (profile) {
|
|
||||||
setPreferences({
|
|
||||||
notifications: {
|
|
||||||
inventory: { app: true, email: false, sms: true, frequency: 'immediate' },
|
|
||||||
sales: { app: true, email: true, sms: false, frequency: 'hourly' },
|
|
||||||
production: { app: true, email: false, sms: true, frequency: 'immediate' },
|
|
||||||
system: { app: true, email: true, sms: false, frequency: 'daily' },
|
|
||||||
marketing: { app: false, email: true, sms: false, frequency: 'weekly' }
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
doNotDisturb: false,
|
|
||||||
quietHours: { enabled: false, start: '22:00', end: '07:00' },
|
|
||||||
language: profile.language || 'es',
|
|
||||||
timezone: profile.timezone || 'Europe/Madrid',
|
|
||||||
soundEnabled: true,
|
|
||||||
vibrationEnabled: true
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
email: profile.email || '',
|
|
||||||
phone: profile.phone || '',
|
|
||||||
slack: false,
|
|
||||||
webhook: ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setHasChanges(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getChannelIcon = (channel: string) => {
|
|
||||||
switch (channel) {
|
|
||||||
case 'app':
|
|
||||||
return <Bell className="w-4 h-4" />;
|
|
||||||
case 'email':
|
|
||||||
return <Mail className="w-4 h-4" />;
|
|
||||||
case 'sms':
|
|
||||||
return <Smartphone className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <MessageSquare className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Preferencias de Comunicación"
|
|
||||||
description="Configura cómo y cuándo recibir notificaciones"
|
|
||||||
action={
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" onClick={handleReset}>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
|
||||||
Restaurar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
Guardar Cambios
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Global Settings */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configuración General</h3>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={preferences.global.doNotDisturb}
|
|
||||||
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
|
||||||
className="rounded border-[var(--border-secondary)]"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-[var(--text-secondary)]">No molestar</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">Silencia todas las notificaciones</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={preferences.global.soundEnabled}
|
|
||||||
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
|
||||||
className="rounded border-[var(--border-secondary)]"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Sonidos</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">Reproducir sonidos de notificación</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center space-x-2 mb-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={preferences.global.quietHours.enabled}
|
|
||||||
onChange={(e) => handleGlobalChange('quietHours', {
|
|
||||||
...preferences.global.quietHours,
|
|
||||||
enabled: e.target.checked
|
|
||||||
})}
|
|
||||||
className="rounded border-[var(--border-secondary)]"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Horas silenciosas</span>
|
|
||||||
</label>
|
|
||||||
{preferences.global.quietHours.enabled && (
|
|
||||||
<div className="flex space-x-4 ml-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Desde</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={preferences.global.quietHours.start}
|
|
||||||
onChange={(e) => handleGlobalChange('quietHours', {
|
|
||||||
...preferences.global.quietHours,
|
|
||||||
start: e.target.value
|
|
||||||
})}
|
|
||||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Hasta</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={preferences.global.quietHours.end}
|
|
||||||
onChange={(e) => handleGlobalChange('quietHours', {
|
|
||||||
...preferences.global.quietHours,
|
|
||||||
end: e.target.value
|
|
||||||
})}
|
|
||||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Channel Settings */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Canales de Comunicación</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={preferences.channels.email}
|
|
||||||
onChange={(e) => handleChannelChange('email', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
placeholder="tu-email@ejemplo.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Teléfono (SMS)</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={preferences.channels.phone}
|
|
||||||
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
placeholder="+34 600 123 456"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Webhook URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={preferences.channels.webhook}
|
|
||||||
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
placeholder="https://tu-webhook.com/notifications"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">URL para recibir notificaciones JSON</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Category Preferences */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{categories.map((category) => {
|
|
||||||
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={category.id} className="p-6">
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="text-2xl">{category.icon}</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">{category.name}</h3>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-4">{category.description}</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Channel toggles */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Canales</h4>
|
|
||||||
<div className="flex space-x-6">
|
|
||||||
{['app', 'email', 'sms'].map((channel) => (
|
|
||||||
<label key={channel} className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
|
||||||
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
|
||||||
className="rounded border-[var(--border-secondary)]"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{getChannelIcon(channel)}
|
|
||||||
<span className="text-sm text-[var(--text-secondary)] capitalize">{channel}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Frequency */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Frecuencia</h4>
|
|
||||||
<select
|
|
||||||
value={categoryPrefs.frequency}
|
|
||||||
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md text-sm"
|
|
||||||
>
|
|
||||||
{frequencies.map((freq) => (
|
|
||||||
<option key={freq.value} value={freq.value}>
|
|
||||||
{freq.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Changes Banner */}
|
|
||||||
{hasChanges && (
|
|
||||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
|
||||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button size="sm" variant="outline" className="text-[var(--color-info)] bg-white" onClick={handleReset}>
|
|
||||||
Descartar
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
|
||||||
Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PreferencesPage;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as PreferencesPage } from './PreferencesPage';
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { User, Mail, Phone, Lock, Globe, Clock, Camera, Save, X } from 'lucide-react';
|
import { User, Mail, Phone, Lock, Globe, Clock, Camera, Save, X, Bell, MessageSquare, Smartphone, RotateCcw, CreditCard, Crown, Package, MapPin, Users, TrendingUp, Calendar, CheckCircle, AlertCircle, ArrowRight, Star, RefreshCw, Settings, Download, ExternalLink } from 'lucide-react';
|
||||||
import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
|
import { Button, Card, Avatar, Input, Select, Tabs, Badge, Modal } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useCurrentTenant } from '../../../../stores';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
|
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
|
||||||
|
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
|
||||||
|
|
||||||
interface ProfileFormData {
|
interface ProfileFormData {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -21,6 +23,59 @@ interface PasswordData {
|
|||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationPreferences {
|
||||||
|
notifications: {
|
||||||
|
inventory: {
|
||||||
|
app: boolean;
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
frequency: string;
|
||||||
|
};
|
||||||
|
sales: {
|
||||||
|
app: boolean;
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
frequency: string;
|
||||||
|
};
|
||||||
|
production: {
|
||||||
|
app: boolean;
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
frequency: string;
|
||||||
|
};
|
||||||
|
system: {
|
||||||
|
app: boolean;
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
frequency: string;
|
||||||
|
};
|
||||||
|
marketing: {
|
||||||
|
app: boolean;
|
||||||
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
frequency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
global: {
|
||||||
|
doNotDisturb: boolean;
|
||||||
|
quietHours: {
|
||||||
|
enabled: boolean;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
language: string;
|
||||||
|
timezone: string;
|
||||||
|
soundEnabled: boolean;
|
||||||
|
vibrationEnabled: boolean;
|
||||||
|
};
|
||||||
|
channels: {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
slack: boolean;
|
||||||
|
webhook: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ProfilePage: React.FC = () => {
|
const ProfilePage: React.FC = () => {
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -32,6 +87,15 @@ const ProfilePage: React.FC = () => {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('profile');
|
||||||
|
const [hasPreferencesChanges, setHasPreferencesChanges] = useState(false);
|
||||||
|
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||||
|
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||||
|
const [subscriptionLoading, setSubscriptionLoading] = useState(false);
|
||||||
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<string>('');
|
||||||
|
const [upgrading, setUpgrading] = useState(false);
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
|
||||||
const [profileData, setProfileData] = useState<ProfileFormData>({
|
const [profileData, setProfileData] = useState<ProfileFormData>({
|
||||||
first_name: '',
|
first_name: '',
|
||||||
@@ -53,9 +117,31 @@ const ProfilePage: React.FC = () => {
|
|||||||
language: profile.language || 'es',
|
language: profile.language || 'es',
|
||||||
timezone: profile.timezone || 'Europe/Madrid'
|
timezone: profile.timezone || 'Europe/Madrid'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update preferences with profile data
|
||||||
|
setPreferences(prev => ({
|
||||||
|
...prev,
|
||||||
|
global: {
|
||||||
|
...prev.global,
|
||||||
|
language: profile.language || 'es',
|
||||||
|
timezone: profile.timezone || 'Europe/Madrid'
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
...prev.channels,
|
||||||
|
email: profile.email || '',
|
||||||
|
phone: profile.phone || ''
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
|
// Load subscription data when needed
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeTab === 'subscription' && (currentTenant?.id || user?.tenant_id) && !usageSummary) {
|
||||||
|
loadSubscriptionData();
|
||||||
|
}
|
||||||
|
}, [activeTab, currentTenant, user?.tenant_id]);
|
||||||
|
|
||||||
const [passwordData, setPasswordData] = useState<PasswordData>({
|
const [passwordData, setPasswordData] = useState<PasswordData>({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
@@ -64,6 +150,59 @@ const ProfilePage: React.FC = () => {
|
|||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const [preferences, setPreferences] = useState<NotificationPreferences>({
|
||||||
|
notifications: {
|
||||||
|
inventory: {
|
||||||
|
app: true,
|
||||||
|
email: false,
|
||||||
|
sms: true,
|
||||||
|
frequency: 'immediate'
|
||||||
|
},
|
||||||
|
sales: {
|
||||||
|
app: true,
|
||||||
|
email: true,
|
||||||
|
sms: false,
|
||||||
|
frequency: 'hourly'
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
app: true,
|
||||||
|
email: false,
|
||||||
|
sms: true,
|
||||||
|
frequency: 'immediate'
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
app: true,
|
||||||
|
email: true,
|
||||||
|
sms: false,
|
||||||
|
frequency: 'daily'
|
||||||
|
},
|
||||||
|
marketing: {
|
||||||
|
app: false,
|
||||||
|
email: true,
|
||||||
|
sms: false,
|
||||||
|
frequency: 'weekly'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
doNotDisturb: false,
|
||||||
|
quietHours: {
|
||||||
|
enabled: false,
|
||||||
|
start: '22:00',
|
||||||
|
end: '07:00'
|
||||||
|
},
|
||||||
|
language: 'es',
|
||||||
|
timezone: 'Europe/Madrid',
|
||||||
|
soundEnabled: true,
|
||||||
|
vibrationEnabled: true
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
slack: false,
|
||||||
|
webhook: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const languageOptions = [
|
const languageOptions = [
|
||||||
{ value: 'es', label: 'Español' },
|
{ value: 'es', label: 'Español' },
|
||||||
{ value: 'ca', label: 'Català' },
|
{ value: 'ca', label: 'Català' },
|
||||||
@@ -175,14 +314,270 @@ const ProfilePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Communication Preferences handlers
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
name: 'Inventario',
|
||||||
|
description: 'Alertas de stock, reposiciones y vencimientos',
|
||||||
|
icon: '📦'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales',
|
||||||
|
name: 'Ventas',
|
||||||
|
description: 'Pedidos, transacciones y reportes de ventas',
|
||||||
|
icon: '💰'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'production',
|
||||||
|
name: 'Producción',
|
||||||
|
description: 'Hornadas, calidad y tiempos de producción',
|
||||||
|
icon: '🍞'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
name: 'Sistema',
|
||||||
|
description: 'Actualizaciones, mantenimiento y errores',
|
||||||
|
icon: '⚙️'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'marketing',
|
||||||
|
name: 'Marketing',
|
||||||
|
description: 'Campañas, promociones y análisis',
|
||||||
|
icon: '📢'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const frequencies = [
|
||||||
|
{ value: 'immediate', label: 'Inmediato' },
|
||||||
|
{ value: 'hourly', label: 'Cada hora' },
|
||||||
|
{ value: 'daily', label: 'Diario' },
|
||||||
|
{ value: 'weekly', label: 'Semanal' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
||||||
|
setPreferences(prev => ({
|
||||||
|
...prev,
|
||||||
|
notifications: {
|
||||||
|
...prev.notifications,
|
||||||
|
[category]: {
|
||||||
|
...prev.notifications[category as keyof typeof prev.notifications],
|
||||||
|
[channel]: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setHasPreferencesChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFrequencyChange = (category: string, frequency: string) => {
|
||||||
|
setPreferences(prev => ({
|
||||||
|
...prev,
|
||||||
|
notifications: {
|
||||||
|
...prev.notifications,
|
||||||
|
[category]: {
|
||||||
|
...prev.notifications[category as keyof typeof prev.notifications],
|
||||||
|
frequency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setHasPreferencesChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalChange = (setting: string, value: any) => {
|
||||||
|
setPreferences(prev => ({
|
||||||
|
...prev,
|
||||||
|
global: {
|
||||||
|
...prev.global,
|
||||||
|
[setting]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setHasPreferencesChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelChange = (channel: string, value: string | boolean) => {
|
||||||
|
setPreferences(prev => ({
|
||||||
|
...prev,
|
||||||
|
channels: {
|
||||||
|
...prev.channels,
|
||||||
|
[channel]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setHasPreferencesChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePreferences = async () => {
|
||||||
|
try {
|
||||||
|
await updateProfileMutation.mutateAsync({
|
||||||
|
language: preferences.global.language,
|
||||||
|
timezone: preferences.global.timezone,
|
||||||
|
phone: preferences.channels.phone,
|
||||||
|
notification_preferences: preferences.notifications
|
||||||
|
});
|
||||||
|
|
||||||
|
addToast('Preferencias guardadas correctamente', 'success');
|
||||||
|
setHasPreferencesChanges(false);
|
||||||
|
} catch (error) {
|
||||||
|
addToast('Error al guardar las preferencias', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPreferences = () => {
|
||||||
|
if (profile) {
|
||||||
|
setPreferences({
|
||||||
|
notifications: {
|
||||||
|
inventory: { app: true, email: false, sms: true, frequency: 'immediate' },
|
||||||
|
sales: { app: true, email: true, sms: false, frequency: 'hourly' },
|
||||||
|
production: { app: true, email: false, sms: true, frequency: 'immediate' },
|
||||||
|
system: { app: true, email: true, sms: false, frequency: 'daily' },
|
||||||
|
marketing: { app: false, email: true, sms: false, frequency: 'weekly' }
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
doNotDisturb: false,
|
||||||
|
quietHours: { enabled: false, start: '22:00', end: '07:00' },
|
||||||
|
language: profile.language || 'es',
|
||||||
|
timezone: profile.timezone || 'Europe/Madrid',
|
||||||
|
soundEnabled: true,
|
||||||
|
vibrationEnabled: true
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
email: profile.email || '',
|
||||||
|
phone: profile.phone || '',
|
||||||
|
slack: false,
|
||||||
|
webhook: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setHasPreferencesChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChannelIcon = (channel: string) => {
|
||||||
|
switch (channel) {
|
||||||
|
case 'app':
|
||||||
|
return <Bell className="w-4 h-4" />;
|
||||||
|
case 'email':
|
||||||
|
return <Mail className="w-4 h-4" />;
|
||||||
|
case 'sms':
|
||||||
|
return <Smartphone className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return <MessageSquare className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscription handlers
|
||||||
|
const loadSubscriptionData = async () => {
|
||||||
|
let tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
addToast('No se encontró información del tenant', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubscriptionLoading(true);
|
||||||
|
const [usage, plans] = await Promise.all([
|
||||||
|
subscriptionService.getUsageSummary(tenantId),
|
||||||
|
subscriptionService.getAvailablePlans()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setUsageSummary(usage);
|
||||||
|
setAvailablePlans(plans);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading subscription data:', error);
|
||||||
|
addToast("No se pudo cargar la información de suscripción", 'error');
|
||||||
|
} finally {
|
||||||
|
setSubscriptionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgradeClick = (planKey: string) => {
|
||||||
|
setSelectedPlan(planKey);
|
||||||
|
setUpgradeDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgradeConfirm = async () => {
|
||||||
|
let tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
|
if (!tenantId || !selectedPlan) {
|
||||||
|
addToast('Información de tenant no disponible', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpgrading(true);
|
||||||
|
|
||||||
|
const validation = await subscriptionService.validatePlanUpgrade(
|
||||||
|
tenantId,
|
||||||
|
selectedPlan
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validation.can_upgrade) {
|
||||||
|
addToast(validation.reason || 'No se puede actualizar el plan', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
addToast(result.message, 'success');
|
||||||
|
|
||||||
|
await loadSubscriptionData();
|
||||||
|
setUpgradeDialogOpen(false);
|
||||||
|
setSelectedPlan('');
|
||||||
|
} else {
|
||||||
|
addToast('Error al cambiar el plan', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error upgrading plan:', error);
|
||||||
|
addToast('Error al procesar el cambio de plan', 'error');
|
||||||
|
} finally {
|
||||||
|
setUpgrading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
|
||||||
|
const getProgressColor = () => {
|
||||||
|
if (value >= 90) return 'bg-red-500';
|
||||||
|
if (value >= 80) return 'bg-yellow-500';
|
||||||
|
return 'bg-green-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-full h-3 ${className}`}>
|
||||||
|
<div
|
||||||
|
className={`${getProgressColor()} h-full rounded-full transition-all duration-500 relative`}
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-white/20 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ id: 'profile', label: 'Información Personal' },
|
||||||
|
{ id: 'preferences', label: 'Preferencias de Comunicación' },
|
||||||
|
{ id: 'subscription', label: 'Suscripción y Facturación' }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Mi Perfil"
|
title="Mi Perfil"
|
||||||
description="Gestiona tu información personal y configuración de cuenta"
|
description="Gestiona tu información personal y preferencias de comunicación"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<Tabs
|
||||||
|
items={tabItems}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
fullWidth={true}
|
||||||
|
variant="pills"
|
||||||
|
size="md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
|
{activeTab === 'profile' && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -228,8 +623,10 @@ const ProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Profile Form */}
|
{/* Profile Form */}
|
||||||
|
{activeTab === 'profile' && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Información Personal</h2>
|
<h2 className="text-lg font-semibold mb-4">Información Personal</h2>
|
||||||
|
|
||||||
@@ -315,9 +712,10 @@ const ProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Password Change Form */}
|
{/* Password Change Form */}
|
||||||
{showPasswordForm && (
|
{activeTab === 'profile' && showPasswordForm && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Cambiar Contraseña</h2>
|
<h2 className="text-lg font-semibold mb-4">Cambiar Contraseña</h2>
|
||||||
|
|
||||||
@@ -376,6 +774,507 @@ const ProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Communication Preferences Tab */}
|
||||||
|
{activeTab === 'preferences' && (
|
||||||
|
<>
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button variant="outline" onClick={handleResetPreferences}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Restaurar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSavePreferences} disabled={!hasPreferencesChanges}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Guardar Cambios
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configuración General</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.global.doNotDisturb}
|
||||||
|
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
||||||
|
className="rounded border-[var(--border-secondary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-secondary)]">No molestar</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">Silencia todas las notificaciones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.global.soundEnabled}
|
||||||
|
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
||||||
|
className="rounded border-[var(--border-secondary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-secondary)]">Sonidos</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">Reproducir sonidos de notificación</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.global.quietHours.enabled}
|
||||||
|
onChange={(e) => handleGlobalChange('quietHours', {
|
||||||
|
...preferences.global.quietHours,
|
||||||
|
enabled: e.target.checked
|
||||||
|
})}
|
||||||
|
className="rounded border-[var(--border-secondary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-secondary)]">Horas silenciosas</span>
|
||||||
|
</label>
|
||||||
|
{preferences.global.quietHours.enabled && (
|
||||||
|
<div className="flex space-x-4 ml-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Desde</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={preferences.global.quietHours.start}
|
||||||
|
onChange={(e) => handleGlobalChange('quietHours', {
|
||||||
|
...preferences.global.quietHours,
|
||||||
|
start: e.target.value
|
||||||
|
})}
|
||||||
|
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Hasta</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={preferences.global.quietHours.end}
|
||||||
|
onChange={(e) => handleGlobalChange('quietHours', {
|
||||||
|
...preferences.global.quietHours,
|
||||||
|
end: e.target.value
|
||||||
|
})}
|
||||||
|
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Channel Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Canales de Comunicación</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={preferences.channels.email}
|
||||||
|
onChange={(e) => handleChannelChange('email', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||||
|
placeholder="tu-email@ejemplo.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Teléfono (SMS)</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={preferences.channels.phone}
|
||||||
|
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||||
|
placeholder="+34 600 123 456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Webhook URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={preferences.channels.webhook}
|
||||||
|
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||||
|
placeholder="https://tu-webhook.com/notifications"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">URL para recibir notificaciones JSON</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Category Preferences */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={category.id} className="p-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="text-2xl">{category.icon}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">{category.name}</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-4">{category.description}</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Channel toggles */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Canales</h4>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
{['app', 'email', 'sms'].map((channel) => (
|
||||||
|
<label key={channel} className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
||||||
|
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
||||||
|
className="rounded border-[var(--border-secondary)]"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{getChannelIcon(channel)}
|
||||||
|
<span className="text-sm text-[var(--text-secondary)] capitalize">{channel}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frequency */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Frecuencia</h4>
|
||||||
|
<select
|
||||||
|
value={categoryPrefs.frequency}
|
||||||
|
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
||||||
|
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{frequencies.map((freq) => (
|
||||||
|
<option key={freq.value} value={freq.value}>
|
||||||
|
{freq.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Changes Banner */}
|
||||||
|
{hasPreferencesChanges && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||||
|
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button size="sm" variant="outline" className="text-[var(--color-info)] bg-white" onClick={handleResetPreferences}>
|
||||||
|
Descartar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSavePreferences}>
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscription Tab */}
|
||||||
|
{activeTab === 'subscription' && (
|
||||||
|
<>
|
||||||
|
{subscriptionLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||||
|
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !usageSummary || !availablePlans ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
|
||||||
|
<Button onClick={loadSubscriptionData} variant="primary">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Current Plan Overview */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
||||||
|
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
||||||
|
Plan Actual: {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant={usageSummary.status === 'active' ? 'success' : 'default'}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">Precio Mensual</span>
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">Próxima Facturación</span>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">Usuarios</span>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">Ubicaciones</span>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => window.open('https://billing.bakery.com', '_blank')} className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Portal de Facturación
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => console.log('Download invoice')} className="flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Descargar Facturas
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={loadSubscriptionData} className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage Details */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
|
||||||
|
<TrendingUp className="w-5 h-5 mr-2 text-orange-500" />
|
||||||
|
Uso de Recursos
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Users */}
|
||||||
|
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||||
|
<Users className="w-4 h-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||||
|
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
|
||||||
|
<span>{usageSummary.usage.users.usage_percentage}% utilizado</span>
|
||||||
|
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Locations */}
|
||||||
|
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||||
|
<MapPin className="w-4 h-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||||
|
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
|
||||||
|
<span>{usageSummary.usage.locations.usage_percentage}% utilizado</span>
|
||||||
|
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products */}
|
||||||
|
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
||||||
|
<Package className="w-4 h-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">Productos</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||||
|
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
|
||||||
|
<span>{usageSummary.usage.products.usage_percentage}% utilizado</span>
|
||||||
|
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : 'Ilimitado'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Available Plans */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
|
||||||
|
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
||||||
|
Planes Disponibles
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{Object.entries(availablePlans.plans).map(([planKey, plan]) => {
|
||||||
|
const isCurrentPlan = usageSummary.plan === planKey;
|
||||||
|
const getPlanColor = () => {
|
||||||
|
switch (planKey) {
|
||||||
|
case 'starter': return 'border-blue-500/30 bg-blue-500/5';
|
||||||
|
case 'professional': return 'border-purple-500/30 bg-purple-500/5';
|
||||||
|
case 'enterprise': return 'border-amber-500/30 bg-amber-500/5';
|
||||||
|
default: return 'border-[var(--border-primary)] bg-[var(--bg-secondary)]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={planKey}
|
||||||
|
className={`relative p-6 ${getPlanColor()} ${
|
||||||
|
isCurrentPlan ? 'ring-2 ring-[var(--color-primary)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
||||||
|
<Badge variant="primary" className="px-3 py-1">
|
||||||
|
<Star className="w-3 h-3 mr-1" />
|
||||||
|
Más Popular
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h4 className="text-xl font-bold text-[var(--text-primary)] mb-2">{plan.name}</h4>
|
||||||
|
<div className="text-3xl font-bold text-[var(--color-primary)] mb-1">
|
||||||
|
{subscriptionService.formatPrice(plan.monthly_price)}
|
||||||
|
<span className="text-lg text-[var(--text-secondary)]">/mes</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-[var(--color-primary)]" />
|
||||||
|
<span>{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 text-[var(--color-primary)]" />
|
||||||
|
<span>{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Package className="w-4 h-4 text-[var(--color-primary)]" />
|
||||||
|
<span>{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCurrentPlan ? (
|
||||||
|
<Badge variant="success" className="w-full justify-center py-2">
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Plan Actual
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={plan.popular ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleUpgradeClick(planKey)}
|
||||||
|
>
|
||||||
|
{plan.contact_sales ? 'Contactar Ventas' : 'Cambiar Plan'}
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upgrade Modal */}
|
||||||
|
{upgradeDialogOpen && selectedPlan && availablePlans && (
|
||||||
|
<Modal
|
||||||
|
isOpen={upgradeDialogOpen}
|
||||||
|
onClose={() => setUpgradeDialogOpen(false)}
|
||||||
|
title="Confirmar Cambio de Plan"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
¿Estás seguro de que quieres cambiar tu plan de suscripción?
|
||||||
|
</p>
|
||||||
|
{availablePlans.plans[selectedPlan] && usageSummary && (
|
||||||
|
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Plan actual:</span>
|
||||||
|
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Nuevo plan:</span>
|
||||||
|
<span>{availablePlans.plans[selectedPlan].name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-medium">
|
||||||
|
<span>Nuevo precio:</span>
|
||||||
|
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setUpgradeDialogOpen(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleUpgradeConfirm}
|
||||||
|
disabled={upgrading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,847 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Management Page
|
|
||||||
* Allows users to view current subscription, billing details, and upgrade plans
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Modal
|
|
||||||
} from '../../../../components/ui';
|
|
||||||
import { PageHeader } from '../../../../components/layout';
|
|
||||||
import {
|
|
||||||
CreditCard,
|
|
||||||
Users,
|
|
||||||
MapPin,
|
|
||||||
Package,
|
|
||||||
TrendingUp,
|
|
||||||
Calendar,
|
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
|
||||||
ArrowRight,
|
|
||||||
Crown,
|
|
||||||
Star,
|
|
||||||
Zap,
|
|
||||||
X,
|
|
||||||
RefreshCw,
|
|
||||||
Settings,
|
|
||||||
Download,
|
|
||||||
ExternalLink
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
|
||||||
import { useCurrentTenant } from '../../../../stores';
|
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
|
||||||
import {
|
|
||||||
subscriptionService,
|
|
||||||
type UsageSummary,
|
|
||||||
type AvailablePlans
|
|
||||||
} from '../../../../api';
|
|
||||||
|
|
||||||
interface PlanComparisonProps {
|
|
||||||
plans: AvailablePlans['plans'];
|
|
||||||
currentPlan: string;
|
|
||||||
onUpgrade: (planKey: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
|
|
||||||
const getProgressColor = () => {
|
|
||||||
if (value >= 90) return 'bg-red-500';
|
|
||||||
if (value >= 80) return 'bg-yellow-500';
|
|
||||||
return 'bg-green-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`w-full bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-full h-3 ${className}`}>
|
|
||||||
<div
|
|
||||||
className={`${getProgressColor()} h-full rounded-full transition-all duration-500 relative`}
|
|
||||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-white/20 rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tabs implementation
|
|
||||||
interface TabsProps {
|
|
||||||
defaultValue: string;
|
|
||||||
className?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabsListProps {
|
|
||||||
className?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabsTriggerProps {
|
|
||||||
value: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabsContentProps {
|
|
||||||
value: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabsContext = React.createContext<{ activeTab: string; setActiveTab: (value: string) => void } | null>(null);
|
|
||||||
|
|
||||||
const Tabs: React.FC<TabsProps> & {
|
|
||||||
List: React.FC<TabsListProps>;
|
|
||||||
Trigger: React.FC<TabsTriggerProps>;
|
|
||||||
Content: React.FC<TabsContentProps>;
|
|
||||||
} = ({
|
|
||||||
defaultValue,
|
|
||||||
className = '',
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const [activeTab, setActiveTab] = useState(defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
|
||||||
{children}
|
|
||||||
</TabsContext.Provider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabsList: React.FC<TabsListProps> = ({ className = '', children }) => {
|
|
||||||
return (
|
|
||||||
<div className={`flex border-b border-[var(--border-primary)] bg-[var(--bg-primary)] ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabsTrigger: React.FC<TabsTriggerProps> = ({ value, children, className = '' }) => {
|
|
||||||
const context = React.useContext(TabsContext);
|
|
||||||
if (!context) throw new Error('TabsTrigger must be used within Tabs');
|
|
||||||
|
|
||||||
const { activeTab, setActiveTab } = context;
|
|
||||||
const isActive = activeTab === value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab(value)}
|
|
||||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-all duration-200 relative flex items-center ${
|
|
||||||
isActive
|
|
||||||
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--bg-primary)]'
|
|
||||||
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--color-primary)]/30 hover:bg-[var(--bg-secondary)]'
|
|
||||||
} ${className}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabsContent: React.FC<TabsContentProps> = ({ value, children, className = '' }) => {
|
|
||||||
const context = React.useContext(TabsContext);
|
|
||||||
if (!context) throw new Error('TabsContent must be used within Tabs');
|
|
||||||
|
|
||||||
const { activeTab } = context;
|
|
||||||
|
|
||||||
if (activeTab !== value) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-[var(--bg-primary)] rounded-b-lg p-6 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Tabs.List = TabsList;
|
|
||||||
Tabs.Trigger = TabsTrigger;
|
|
||||||
Tabs.Content = TabsContent;
|
|
||||||
|
|
||||||
const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onUpgrade }) => {
|
|
||||||
const planOrder = ['starter', 'professional', 'enterprise'];
|
|
||||||
const sortedPlans = Object.entries(plans).sort(([a], [b]) =>
|
|
||||||
planOrder.indexOf(a) - planOrder.indexOf(b)
|
|
||||||
);
|
|
||||||
|
|
||||||
const getPlanColor = (planKey: string) => {
|
|
||||||
switch (planKey) {
|
|
||||||
case 'starter': return 'border-blue-500/30 bg-blue-500/5';
|
|
||||||
case 'professional': return 'border-purple-500/30 bg-purple-500/5';
|
|
||||||
case 'enterprise': return 'border-amber-500/30 bg-amber-500/5';
|
|
||||||
default: return 'border-[var(--border-primary)] bg-[var(--bg-secondary)]';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{sortedPlans.map(([planKey, plan]) => (
|
|
||||||
<Card
|
|
||||||
key={planKey}
|
|
||||||
className={`relative p-6 ${getPlanColor(planKey)} ${
|
|
||||||
currentPlan === planKey ? 'ring-2 ring-[var(--color-primary)]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plan.popular && (
|
|
||||||
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
|
||||||
<Badge variant="primary" className="px-3 py-1">
|
|
||||||
<Star className="w-3 h-3 mr-1" />
|
|
||||||
Más Popular
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">{plan.name}</h3>
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-primary)] mb-1">
|
|
||||||
{subscriptionService.formatPrice(plan.monthly_price)}
|
|
||||||
<span className="text-lg text-[var(--text-secondary)]">/mes</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{plan.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Users className="w-4 h-4 text-[var(--color-primary)]" />
|
|
||||||
<span>{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<MapPin className="w-4 h-4 text-[var(--color-primary)]" />
|
|
||||||
<span>{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Package className="w-4 h-4 text-[var(--color-primary)]" />
|
|
||||||
<span>{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentPlan === planKey ? (
|
|
||||||
<Badge variant="success" className="w-full justify-center py-2">
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Plan Actual
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant={plan.popular ? 'primary' : 'outline'}
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => onUpgrade(planKey)}
|
|
||||||
>
|
|
||||||
{plan.contact_sales ? 'Contactar Ventas' : 'Cambiar Plan'}
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SubscriptionPage: React.FC = () => {
|
|
||||||
const user = useAuthUser();
|
|
||||||
const currentTenant = useCurrentTenant();
|
|
||||||
const toast = useToast();
|
|
||||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
|
||||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
|
||||||
const [selectedPlan, setSelectedPlan] = useState<string>('');
|
|
||||||
const [upgrading, setUpgrading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentTenant?.id || user?.tenant_id) {
|
|
||||||
loadSubscriptionData();
|
|
||||||
}
|
|
||||||
}, [currentTenant, user?.tenant_id]);
|
|
||||||
|
|
||||||
const loadSubscriptionData = async () => {
|
|
||||||
let tenantId = currentTenant?.id || user?.tenant_id;
|
|
||||||
|
|
||||||
if (!tenantId) {
|
|
||||||
toast.error('No se encontró información del tenant');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [usage, plans] = await Promise.all([
|
|
||||||
subscriptionService.getUsageSummary(tenantId),
|
|
||||||
subscriptionService.getAvailablePlans()
|
|
||||||
]);
|
|
||||||
|
|
||||||
setUsageSummary(usage);
|
|
||||||
setAvailablePlans(plans);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading subscription data:', error);
|
|
||||||
toast.error("No se pudo cargar la información de suscripción");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpgradeClick = (planKey: string) => {
|
|
||||||
setSelectedPlan(planKey);
|
|
||||||
setUpgradeDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpgradeConfirm = async () => {
|
|
||||||
let tenantId = currentTenant?.id || user?.tenant_id;
|
|
||||||
|
|
||||||
if (!tenantId || !selectedPlan) {
|
|
||||||
toast.error('Información de tenant no disponible');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setUpgrading(true);
|
|
||||||
|
|
||||||
const validation = await subscriptionService.validatePlanUpgrade(
|
|
||||||
tenantId,
|
|
||||||
selectedPlan
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validation.can_upgrade) {
|
|
||||||
toast.error(validation.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
|
|
||||||
await loadSubscriptionData();
|
|
||||||
setUpgradeDialogOpen(false);
|
|
||||||
setSelectedPlan('');
|
|
||||||
} else {
|
|
||||||
toast.error('Error al cambiar el plan');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error upgrading plan:', error);
|
|
||||||
toast.error('Error al procesar el cambio de plan');
|
|
||||||
} finally {
|
|
||||||
setUpgrading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Suscripción"
|
|
||||||
description="Gestiona tu plan de suscripción y facturación"
|
|
||||||
icon={CreditCard}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
|
||||||
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usageSummary || !availablePlans) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Suscripción"
|
|
||||||
description="Gestiona tu plan de suscripción y facturación"
|
|
||||||
icon={CreditCard}
|
|
||||||
error="No se pudo cargar la información de suscripción"
|
|
||||||
onRefresh={loadSubscriptionData}
|
|
||||||
showRefreshButton
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
|
|
||||||
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
|
|
||||||
<Button onClick={loadSubscriptionData} variant="primary">
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Reintentar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextBillingDate = usageSummary.next_billing_date
|
|
||||||
? new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
: 'No disponible';
|
|
||||||
|
|
||||||
const planInfo = subscriptionService.getPlanDisplayInfo(usageSummary.plan);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Suscripción"
|
|
||||||
subtitle={`Plan ${planInfo.name}`}
|
|
||||||
description="Gestiona tu plan de suscripción y facturación"
|
|
||||||
icon={CreditCard}
|
|
||||||
status={{
|
|
||||||
text: usageSummary.status === 'active' ? 'Activo' : usageSummary.status,
|
|
||||||
variant: usageSummary.status === 'active' ? 'success' : 'default'
|
|
||||||
}}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
id: 'manage-billing',
|
|
||||||
label: 'Gestionar Facturación',
|
|
||||||
icon: ExternalLink,
|
|
||||||
onClick: () => window.open('https://billing.bakery.com', '_blank'),
|
|
||||||
variant: 'outline'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'download-invoice',
|
|
||||||
label: 'Descargar Factura',
|
|
||||||
icon: Download,
|
|
||||||
onClick: () => console.log('Download latest invoice'),
|
|
||||||
variant: 'outline'
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
metadata={[
|
|
||||||
{
|
|
||||||
id: 'next-billing',
|
|
||||||
label: 'Próxima facturación',
|
|
||||||
value: nextBillingDate,
|
|
||||||
icon: Calendar
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'monthly-cost',
|
|
||||||
label: 'Coste mensual',
|
|
||||||
value: subscriptionService.formatPrice(usageSummary.monthly_price),
|
|
||||||
icon: CreditCard
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
onRefresh={loadSubscriptionData}
|
|
||||||
showRefreshButton
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Stats Overview */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20">
|
|
||||||
<Users className="w-6 h-6 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Usuarios</p>
|
|
||||||
<p className="text-xl font-bold text-[var(--text-primary)]">
|
|
||||||
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-3 bg-green-500/10 rounded-xl border border-green-500/20">
|
|
||||||
<MapPin className="w-6 h-6 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Ubicaciones</p>
|
|
||||||
<p className="text-xl font-bold text-[var(--text-primary)]">
|
|
||||||
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-3 bg-purple-500/10 rounded-xl border border-purple-500/20">
|
|
||||||
<Package className="w-6 h-6 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Productos</p>
|
|
||||||
<p className="text-xl font-bold text-[var(--text-primary)]">
|
|
||||||
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-3 bg-yellow-500/10 rounded-xl border border-yellow-500/20">
|
|
||||||
<TrendingUp className="w-6 h-6 text-yellow-500" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Estado</p>
|
|
||||||
<Badge
|
|
||||||
variant={usageSummary.status === 'active' ? 'success' : 'default'}
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<Tabs defaultValue="overview">
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Trigger value="overview">
|
|
||||||
<TrendingUp className="w-4 h-4 mr-2" />
|
|
||||||
Resumen
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="usage">
|
|
||||||
<Users className="w-4 h-4 mr-2" />
|
|
||||||
Uso
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="plans">
|
|
||||||
<Crown className="w-4 h-4 mr-2" />
|
|
||||||
Planes
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="billing">
|
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
|
||||||
Facturación
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Content value="overview">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Current Plan Summary */}
|
|
||||||
<Card className="p-6 lg:col-span-2 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<h3 className="text-lg font-semibold mb-6 flex items-center text-[var(--text-primary)]">
|
|
||||||
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
|
|
||||||
Tu Plan Actual
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Plan</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-semibold text-[var(--text-primary)]">{planInfo.name}</span>
|
|
||||||
{usageSummary.plan === 'professional' && (
|
|
||||||
<Badge variant="primary" size="sm">
|
|
||||||
<Star className="w-3 h-3 mr-1" />
|
|
||||||
Popular
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Precio</span>
|
|
||||||
<span className="font-semibold text-[var(--text-primary)]">{subscriptionService.formatPrice(usageSummary.monthly_price)}/mes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Estado</span>
|
|
||||||
<Badge variant={usageSummary.status === 'active' ? 'success' : 'error'}>
|
|
||||||
{usageSummary.status === 'active' ? 'Activo' : 'Inactivo'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Próxima facturación</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{nextBillingDate}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 text-[var(--text-primary)]">
|
|
||||||
Acciones Rápidas
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Button variant="outline" className="w-full justify-start text-sm" onClick={() => window.open('https://billing.bakery.com', '_blank')}>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
Portal de Facturación
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="w-full justify-start text-sm" onClick={() => console.log('Download invoice')}>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Descargar Facturas
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="w-full justify-start text-sm">
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Configurar Alertas
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage at a Glance */}
|
|
||||||
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<h3 className="text-lg font-semibold mb-6 flex items-center text-[var(--text-primary)]">
|
|
||||||
<TrendingUp className="w-5 h-5 mr-2 text-orange-500" />
|
|
||||||
Uso de Recursos
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{/* Users */}
|
|
||||||
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
|
||||||
<Users className="w-4 h-4 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
|
||||||
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
|
|
||||||
<span>{usageSummary.usage.users.usage_percentage}% utilizado</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Locations */}
|
|
||||||
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
|
|
||||||
<MapPin className="w-4 h-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
|
||||||
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
|
|
||||||
<span>{usageSummary.usage.locations.usage_percentage}% utilizado</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Products */}
|
|
||||||
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
|
||||||
<Package className="w-4 h-4 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">Productos</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
|
||||||
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
|
|
||||||
<span>{usageSummary.usage.products.usage_percentage}% utilizado</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : 'Ilimitado'}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Tabs.Content value="usage">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">Detalles de Uso</h3>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Users Usage */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5 text-blue-500" />
|
|
||||||
<h4 className="font-medium text-[var(--text-primary)]">Gestión de Usuarios</h4>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Usuarios activos</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.users.current}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Límite del plan</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : usageSummary.usage.users.limit}</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
|
||||||
{usageSummary.usage.users.usage_percentage}% de capacidad utilizada
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Locations Usage */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-5 h-5 text-green-500" />
|
|
||||||
<h4 className="font-medium text-[var(--text-primary)]">Ubicaciones</h4>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Ubicaciones activas</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.locations.current}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Límite del plan</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : usageSummary.usage.locations.limit}</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
|
||||||
{usageSummary.usage.locations.usage_percentage}% de capacidad utilizada
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Products Usage */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Package className="w-5 h-5 text-purple-500" />
|
|
||||||
<h4 className="font-medium text-[var(--text-primary)]">Productos</h4>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Productos registrados</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.products.current}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-[var(--text-secondary)]">Límite del plan</span>
|
|
||||||
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : usageSummary.usage.products.limit}</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
|
||||||
{usageSummary.usage.products.usage_percentage}% de capacidad utilizada
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Tabs.Content value="plans">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
|
|
||||||
Planes de Suscripción
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--text-secondary)]">
|
|
||||||
Elige el plan que mejor se adapte a las necesidades de tu panadería
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<PlanComparison
|
|
||||||
plans={availablePlans.plans}
|
|
||||||
currentPlan={usageSummary.plan}
|
|
||||||
onUpgrade={handleUpgradeClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Tabs.Content value="billing">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">
|
|
||||||
Información de Facturación
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
|
||||||
<span className="text-[var(--text-secondary)]">Plan actual:</span>
|
|
||||||
<span className="font-medium">{planInfo.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
|
||||||
<span className="text-[var(--text-secondary)]">Precio mensual:</span>
|
|
||||||
<span className="font-medium">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
|
||||||
<span className="text-[var(--text-secondary)]">Próxima facturación:</span>
|
|
||||||
<span className="font-medium flex items-center">
|
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
|
||||||
{nextBillingDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">
|
|
||||||
Métodos de Pago
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-4 p-4 border border-[var(--border-primary)] rounded-lg">
|
|
||||||
<CreditCard className="w-8 h-8 text-[var(--text-tertiary)]" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">•••• •••• •••• 4242</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Visa terminada en 4242</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="success">Principal</Badge>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full">
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Gestionar Métodos de Pago
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Upgrade Modal */}
|
|
||||||
{upgradeDialogOpen && selectedPlan && availablePlans && (
|
|
||||||
<Modal
|
|
||||||
isOpen={upgradeDialogOpen}
|
|
||||||
onClose={() => setUpgradeDialogOpen(false)}
|
|
||||||
title="Confirmar Cambio de Plan"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-[var(--text-secondary)]">
|
|
||||||
¿Estás seguro de que quieres cambiar tu plan de suscripción?
|
|
||||||
</p>
|
|
||||||
{availablePlans.plans[selectedPlan] && (
|
|
||||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Plan actual:</span>
|
|
||||||
<span>{planInfo.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Nuevo plan:</span>
|
|
||||||
<span>{availablePlans.plans[selectedPlan].name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between font-medium">
|
|
||||||
<span>Nuevo precio:</span>
|
|
||||||
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setUpgradeDialogOpen(false)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleUpgradeConfirm}
|
|
||||||
disabled={upgrading}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SubscriptionPage;
|
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Management Page
|
|
||||||
* Allows users to view current subscription, billing details, and upgrade plans
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Modal
|
|
||||||
} from '../../../../components/ui';
|
|
||||||
import { PageHeader } from '../../../../components/layout';
|
|
||||||
import {
|
|
||||||
CreditCard,
|
|
||||||
Users,
|
|
||||||
MapPin,
|
|
||||||
Package,
|
|
||||||
TrendingUp,
|
|
||||||
Calendar,
|
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
|
||||||
ArrowRight,
|
|
||||||
Crown,
|
|
||||||
Star,
|
|
||||||
Zap,
|
|
||||||
X,
|
|
||||||
RefreshCw,
|
|
||||||
Settings,
|
|
||||||
Download,
|
|
||||||
ExternalLink
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useAuth } from '../../../../hooks/api/useAuth';
|
|
||||||
import { useCurrentTenant } from '../../../../stores';
|
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
|
||||||
import {
|
|
||||||
subscriptionService,
|
|
||||||
type UsageSummary,
|
|
||||||
type AvailablePlans
|
|
||||||
} from '../../../../api/services';
|
|
||||||
|
|
||||||
interface PlanComparisonProps {
|
|
||||||
plans: AvailablePlans['plans'];
|
|
||||||
currentPlan: string;
|
|
||||||
onUpgrade: (planKey: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
|
|
||||||
const getProgressColor = () => {
|
|
||||||
if (value >= 90) return 'bg-red-500';
|
|
||||||
if (value >= 80) return 'bg-yellow-500';
|
|
||||||
return 'bg-green-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`w-full bg-gray-200 rounded-full h-2.5 ${className}`}>
|
|
||||||
<div
|
|
||||||
className={`${getProgressColor()} h-2.5 rounded-full transition-all duration-300`}
|
|
||||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Alert: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
|
||||||
children,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`p-4 rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-800 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Tabs: React.FC<{ defaultValue: string; className?: string; children: React.ReactNode }> = ({
|
|
||||||
defaultValue,
|
|
||||||
className = '',
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const [activeTab, setActiveTab] = useState(defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{React.Children.map(children, child => {
|
|
||||||
if (React.isValidElement(child)) {
|
|
||||||
return React.cloneElement(child, { activeTab, setActiveTab } as any);
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabsList: React.FC<{ children: React.ReactNode; activeTab?: string; setActiveTab?: (tab: string) => void }> = ({
|
|
||||||
children,
|
|
||||||
activeTab,
|
|
||||||
setActiveTab
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="flex space-x-1 border-b border-gray-200 mb-6">
|
|
||||||
{React.Children.map(children, child => {
|
|
||||||
if (React.isValidElement(child)) {
|
|
||||||
return React.cloneElement(child, { activeTab, setActiveTab } as any);
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabsTrigger: React.FC<{
|
|
||||||
value: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
activeTab?: string;
|
|
||||||
setActiveTab?: (tab: string) => void;
|
|
||||||
}> = ({ value, children, activeTab, setActiveTab }) => {
|
|
||||||
const isActive = activeTab === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'border-blue-600 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab?.(value)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TabsContent: React.FC<{
|
|
||||||
value: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
activeTab?: string;
|
|
||||||
className?: string;
|
|
||||||
}> = ({ value, children, activeTab, className = '' }) => {
|
|
||||||
if (activeTab !== value) return null;
|
|
||||||
return <div className={className}>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Separator: React.FC<{ className?: string }> = ({ className = '' }) => {
|
|
||||||
return <hr className={`border-gray-200 my-4 ${className}`} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onUpgrade }) => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{Object.entries(plans).map(([key, plan]) => {
|
|
||||||
const isCurrentPlan = key === currentPlan;
|
|
||||||
const isPopular = plan.popular;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className={`relative rounded-2xl p-6 border-2 transition-all duration-200 ${
|
|
||||||
isCurrentPlan
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: isPopular
|
|
||||||
? 'border-purple-200 bg-gradient-to-b from-purple-50 to-white shadow-lg scale-105'
|
|
||||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-md'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPopular && (
|
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
|
||||||
<Badge className="bg-gradient-to-r from-purple-600 to-purple-700 text-white px-4 py-1">
|
|
||||||
<Star className="w-3 h-3 mr-1" />
|
|
||||||
Más Popular
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCurrentPlan && (
|
|
||||||
<div className="absolute top-4 right-4">
|
|
||||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
|
||||||
Activo
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.name}</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">{plan.description}</p>
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-4xl font-bold text-gray-900">
|
|
||||||
€{plan.monthly_price}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-600">/mes</span>
|
|
||||||
</div>
|
|
||||||
{plan.trial_available && !isCurrentPlan && (
|
|
||||||
<p className="text-blue-600 text-sm font-medium">
|
|
||||||
14 días de prueba gratis
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-8">
|
|
||||||
<div className="flex items-center text-sm">
|
|
||||||
<Users className="w-4 h-4 text-gray-500 mr-3" />
|
|
||||||
<span>
|
|
||||||
{plan.max_users === -1 ? 'Usuarios ilimitados' : `Hasta ${plan.max_users} usuarios`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-sm">
|
|
||||||
<MapPin className="w-4 h-4 text-gray-500 mr-3" />
|
|
||||||
<span>
|
|
||||||
{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-sm">
|
|
||||||
<Package className="w-4 h-4 text-gray-500 mr-3" />
|
|
||||||
<span>
|
|
||||||
{plan.max_products === -1 ? 'Productos ilimitados' : `Hasta ${plan.max_products} productos`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key features */}
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="flex items-center text-sm mb-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
|
|
||||||
<span>
|
|
||||||
{plan.features.inventory_management === 'basic' && 'Inventario básico'}
|
|
||||||
{plan.features.inventory_management === 'advanced' && 'Inventario avanzado'}
|
|
||||||
{plan.features.inventory_management === 'multi_location' && 'Inventario multi-locación'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-sm mb-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
|
|
||||||
<span>
|
|
||||||
{plan.features.demand_prediction === 'basic' && 'Predicción básica'}
|
|
||||||
{plan.features.demand_prediction === 'ai_92_percent' && 'IA con 92% precisión'}
|
|
||||||
{plan.features.demand_prediction === 'ai_personalized' && 'IA personalizada'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{plan.features.pos_integrated && (
|
|
||||||
<div className="flex items-center text-sm mb-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
|
|
||||||
<span>POS integrado</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{plan.features.erp_integration && (
|
|
||||||
<div className="flex items-center text-sm mb-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
|
|
||||||
<span>Integración ERP</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{plan.features.account_manager && (
|
|
||||||
<div className="flex items-center text-sm mb-2">
|
|
||||||
<Crown className="w-4 h-4 text-yellow-500 mr-3" />
|
|
||||||
<span>Manager dedicado</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className={`w-full ${
|
|
||||||
isCurrentPlan
|
|
||||||
? 'bg-gray-300 text-gray-600 cursor-not-allowed'
|
|
||||||
: isPopular
|
|
||||||
? 'bg-gradient-to-r from-purple-600 to-purple-700 hover:from-purple-700 hover:to-purple-800'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() => !isCurrentPlan && onUpgrade(key)}
|
|
||||||
disabled={isCurrentPlan}
|
|
||||||
>
|
|
||||||
{isCurrentPlan ? (
|
|
||||||
'Plan Actual'
|
|
||||||
) : plan.contact_sales ? (
|
|
||||||
'Contactar Ventas'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Cambiar a {plan.name}
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SubscriptionPage: React.FC = () => {
|
|
||||||
const { user, tenant_id } = useAuth();
|
|
||||||
const currentTenant = useCurrentTenant();
|
|
||||||
const toast = useToast();
|
|
||||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
|
||||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
|
||||||
const [selectedPlan, setSelectedPlan] = useState<string>('');
|
|
||||||
const [upgrading, setUpgrading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentTenant?.id || tenant_id) {
|
|
||||||
loadSubscriptionData();
|
|
||||||
}
|
|
||||||
}, [currentTenant, tenant_id]);
|
|
||||||
|
|
||||||
const loadSubscriptionData = async () => {
|
|
||||||
let tenantId = currentTenant?.id || tenant_id;
|
|
||||||
|
|
||||||
console.log('📊 Loading subscription data for tenant:', tenantId);
|
|
||||||
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [usage, plans] = await Promise.all([
|
|
||||||
subscriptionService.getUsageSummary(tenantId),
|
|
||||||
subscriptionService.getAvailablePlans()
|
|
||||||
]);
|
|
||||||
|
|
||||||
setUsageSummary(usage);
|
|
||||||
setAvailablePlans(plans);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading subscription data:', error);
|
|
||||||
toast.error("No se pudo cargar la información de suscripción");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpgradeClick = (planKey: string) => {
|
|
||||||
setSelectedPlan(planKey);
|
|
||||||
setUpgradeDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpgradeConfirm = async () => {
|
|
||||||
let tenantId = currentTenant?.id || tenant_id;
|
|
||||||
|
|
||||||
if (!tenantId || !selectedPlan) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setUpgrading(true);
|
|
||||||
|
|
||||||
const validation = await subscriptionService.validatePlanUpgrade(
|
|
||||||
tenantId,
|
|
||||||
selectedPlan
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validation.can_upgrade) {
|
|
||||||
toast.error(validation.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
|
|
||||||
await loadSubscriptionData();
|
|
||||||
setUpgradeDialogOpen(false);
|
|
||||||
setSelectedPlan('');
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error upgrading plan:', error);
|
|
||||||
toast.error("No se pudo actualizar el plan. Inténtalo de nuevo.");
|
|
||||||
} finally {
|
|
||||||
setUpgrading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUsageColor = (percentage: number) => {
|
|
||||||
if (percentage >= 90) return 'text-red-600';
|
|
||||||
if (percentage >= 75) return 'text-yellow-600';
|
|
||||||
return 'text-green-600';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Suscripción"
|
|
||||||
description="Gestiona tu plan de suscripción y facturación"
|
|
||||||
icon={CreditCard}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
|
||||||
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usageSummary || !availablePlans) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Suscripción"
|
|
||||||
description="Gestiona tu plan de suscripción y facturación"
|
|
||||||
icon={CreditCard}
|
|
||||||
error="No se pudo cargar la información de suscripción"
|
|
||||||
onRefresh={loadSubscriptionData}
|
|
||||||
showRefreshButton
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
|
|
||||||
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
|
|
||||||
<Button onClick={loadSubscriptionData} variant="primary">
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Reintentar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextBillingDate = usageSummary.next_billing_date
|
|
||||||
? new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES')
|
|
||||||
: 'N/A';
|
|
||||||
|
|
||||||
const trialEndsAt = usageSummary.trial_ends_at
|
|
||||||
? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-7xl mx-auto space-y-8">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Suscripción</h1>
|
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
Gestiona tu plan, facturación y límites de uso
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="space-y-6">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="overview">Resumen</TabsTrigger>
|
|
||||||
<TabsTrigger value="usage">Uso Actual</TabsTrigger>
|
|
||||||
<TabsTrigger value="plans">Cambiar Plan</TabsTrigger>
|
|
||||||
<TabsTrigger value="billing">Facturación</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-6">
|
|
||||||
{/* Current Plan Overview */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold flex items-center">
|
|
||||||
Plan {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
|
|
||||||
{usageSummary.plan === 'professional' && (
|
|
||||||
<Badge className="ml-2 bg-purple-100 text-purple-800">
|
|
||||||
<Star className="w-3 h-3 mr-1" />
|
|
||||||
Más Popular
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
{subscriptionService.formatPrice(usageSummary.monthly_price)}/mes •
|
|
||||||
Estado: {usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm text-gray-500">Próxima facturación</div>
|
|
||||||
<div className="font-medium">{nextBillingDate}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{trialEndsAt && (
|
|
||||||
<Alert className="mb-4">
|
|
||||||
<Zap className="h-4 w-4 inline mr-2" />
|
|
||||||
Tu período de prueba gratuita termina el {trialEndsAt}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
|
||||||
<Users className="w-8 h-8 text-blue-600 mx-auto mb-2" />
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Usuarios</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
|
||||||
<MapPin className="w-8 h-8 text-blue-600 mx-auto mb-2" />
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Ubicaciones</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
|
||||||
<Package className="w-8 h-8 text-blue-600 mx-auto mb-2" />
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Productos</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="usage" className="space-y-6">
|
|
||||||
{/* Usage Details */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold flex items-center">
|
|
||||||
<Users className="w-5 h-5 mr-2" />
|
|
||||||
Usuarios
|
|
||||||
</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Usado</span>
|
|
||||||
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.users.usage_percentage)}`}>
|
|
||||||
{usageSummary.usage.users.current} de {usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!usageSummary.usage.users.unlimited && (
|
|
||||||
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold flex items-center">
|
|
||||||
<MapPin className="w-5 h-5 mr-2" />
|
|
||||||
Ubicaciones
|
|
||||||
</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Usado</span>
|
|
||||||
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.locations.usage_percentage)}`}>
|
|
||||||
{usageSummary.usage.locations.current} de {usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!usageSummary.usage.locations.unlimited && (
|
|
||||||
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold flex items-center">
|
|
||||||
<Package className="w-5 h-5 mr-2" />
|
|
||||||
Productos
|
|
||||||
</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Usado</span>
|
|
||||||
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.products.usage_percentage)}`}>
|
|
||||||
{usageSummary.usage.products.current} de {usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!usageSummary.usage.products.unlimited && (
|
|
||||||
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="plans" className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold mb-6">Planes Disponibles</h2>
|
|
||||||
<PlanComparison
|
|
||||||
plans={availablePlans.plans}
|
|
||||||
currentPlan={usageSummary.plan}
|
|
||||||
onUpgrade={handleUpgradeClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="billing" className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold flex items-center">
|
|
||||||
<CreditCard className="w-5 h-5 mr-2" />
|
|
||||||
Información de Facturación
|
|
||||||
</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-600">Plan Actual</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-600">Precio Mensual</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{subscriptionService.formatPrice(usageSummary.monthly_price)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-600">Próxima Facturación</div>
|
|
||||||
<div className="font-medium flex items-center">
|
|
||||||
<Calendar className="w-4 h-4 mr-2 text-gray-500" />
|
|
||||||
{nextBillingDate}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-600">Estado</div>
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className={usageSummary.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}
|
|
||||||
>
|
|
||||||
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium">Próximos Cobros</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Se facturará {subscriptionService.formatPrice(usageSummary.monthly_price)} el {nextBillingDate}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Upgrade Confirmation Modal */}
|
|
||||||
{upgradeDialogOpen && (
|
|
||||||
<Modal onClose={() => setUpgradeDialogOpen(false)}>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">Confirmar Cambio de Plan</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setUpgradeDialogOpen(false)}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
¿Estás seguro de que quieres cambiar al plan{' '}
|
|
||||||
{selectedPlan && availablePlans?.plans[selectedPlan]?.name}?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{selectedPlan && availablePlans?.plans[selectedPlan] && (
|
|
||||||
<div className="py-4 border-t border-b border-gray-200">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Plan actual:</span>
|
|
||||||
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Nuevo plan:</span>
|
|
||||||
<span>{availablePlans.plans[selectedPlan].name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between font-medium">
|
|
||||||
<span>Nuevo precio:</span>
|
|
||||||
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setUpgradeDialogOpen(false)}
|
|
||||||
disabled={upgrading}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleUpgradeConfirm}
|
|
||||||
disabled={upgrading}
|
|
||||||
>
|
|
||||||
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SubscriptionPage;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from './SubscriptionPage';
|
|
||||||
@@ -27,11 +27,9 @@ const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics
|
|||||||
|
|
||||||
|
|
||||||
// Settings pages
|
// Settings pages
|
||||||
const PreferencesPage = React.lazy(() => import('../pages/app/settings/preferences/PreferencesPage'));
|
|
||||||
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
||||||
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
||||||
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
||||||
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
|
|
||||||
|
|
||||||
// Database pages
|
// Database pages
|
||||||
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
||||||
@@ -178,16 +176,6 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/app/database/preferences"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppShell>
|
|
||||||
<PreferencesPage />
|
|
||||||
</AppShell>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Analytics Routes */}
|
{/* Analytics Routes */}
|
||||||
<Route
|
<Route
|
||||||
@@ -243,16 +231,6 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/app/settings/subscription"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppShell>
|
|
||||||
<SubscriptionPage />
|
|
||||||
</AppShell>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Data Routes */}
|
{/* Data Routes */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -131,9 +131,7 @@ export const ROUTES = {
|
|||||||
SETTINGS_USERS: '/settings/users',
|
SETTINGS_USERS: '/settings/users',
|
||||||
SETTINGS_PERMISSIONS: '/settings/permissions',
|
SETTINGS_PERMISSIONS: '/settings/permissions',
|
||||||
SETTINGS_INTEGRATIONS: '/settings/integrations',
|
SETTINGS_INTEGRATIONS: '/settings/integrations',
|
||||||
SETTINGS_PREFERENCES: '/app/database/preferences',
|
|
||||||
SETTINGS_BILLING: '/settings/billing',
|
SETTINGS_BILLING: '/settings/billing',
|
||||||
SETTINGS_SUBSCRIPTION: '/app/settings/subscription',
|
|
||||||
SETTINGS_BAKERY_CONFIG: '/app/database/bakery-config',
|
SETTINGS_BAKERY_CONFIG: '/app/database/bakery-config',
|
||||||
SETTINGS_TEAM: '/app/database/team',
|
SETTINGS_TEAM: '/app/database/team',
|
||||||
|
|
||||||
@@ -332,16 +330,6 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/app/database/preferences',
|
|
||||||
name: 'CommunicationPreferences',
|
|
||||||
component: 'PreferencesPage',
|
|
||||||
title: 'Preferencias de Comunicación',
|
|
||||||
icon: 'settings',
|
|
||||||
requiresAuth: true,
|
|
||||||
showInNavigation: true,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -423,42 +411,9 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/app/settings/subscription',
|
|
||||||
name: 'Subscription',
|
|
||||||
component: 'SubscriptionPage',
|
|
||||||
title: 'Suscripción y Facturación',
|
|
||||||
icon: 'credit-card',
|
|
||||||
requiresAuth: true,
|
|
||||||
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
|
|
||||||
showInNavigation: true,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Communications Section - Keep only for backwards compatibility
|
|
||||||
{
|
|
||||||
path: '/app/communications',
|
|
||||||
name: 'Communications',
|
|
||||||
component: 'CommunicationsPage',
|
|
||||||
title: 'Comunicaciones',
|
|
||||||
icon: 'notifications',
|
|
||||||
requiresAuth: true,
|
|
||||||
showInNavigation: false,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/app/communications/preferences',
|
|
||||||
name: 'Preferences',
|
|
||||||
component: 'PreferencesPage',
|
|
||||||
title: 'Preferencias',
|
|
||||||
icon: 'settings',
|
|
||||||
requiresAuth: true,
|
|
||||||
showInNavigation: false,
|
|
||||||
showInBreadcrumbs: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Data Management Section
|
// Data Management Section
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user