Fix UI issues

This commit is contained in:
Urtzi Alfaro
2025-09-20 08:59:12 +02:00
parent 66ef2121a1
commit abe7cf2444
19 changed files with 1327 additions and 2277 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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)]">

View File

@@ -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>
)} )}

View File

@@ -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 (

View File

@@ -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 */}

View File

@@ -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';

View File

@@ -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;

View File

@@ -1 +0,0 @@
export { default as PreferencesPage } from './PreferencesPage';

View File

@@ -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>
); );
}; };

View File

@@ -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;

View File

@@ -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;

View File

@@ -1 +0,0 @@
export { default } from './SubscriptionPage';

View File

@@ -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

View File

@@ -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
{ {