feat: plan management

This commit is contained in:
2026-07-17 21:28:41 -03:00
parent d6a4aab52b
commit 8d9a03cd23
21 changed files with 1355 additions and 131 deletions
@@ -12,6 +12,7 @@ interface ButtonProps {
style?: React.CSSProperties;
width?: ButtonWidth;
color?: ButtonColor;
disabled?: boolean;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}
@@ -52,6 +53,7 @@ export default function Button(props: ButtonProps): React.ReactElement {
id={props.name}
value={props.text}
onClick={props.onClick}
disabled={props.disabled}
style={props.style}
/>
</>
@@ -18,6 +18,19 @@
gap: 15px;
}
.headerButton {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.title {
font-size: 20px;
font-weight: 700;
@@ -25,6 +38,22 @@
margin: 0;
}
.toggleIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--white);
color: var(--green-darkestX1);
border: 1px solid var(--green-darkestX1);
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.filters {
display: flex;
gap: 10px;
@@ -23,6 +23,7 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
const [financials, setFinancials] = useState<DashboardFinancialsResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
if (selectedOrgId) {
@@ -80,15 +81,18 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
if (error) {
return (
<div className={styles.container}>
<div className={styles.header}>
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
</div>
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
<p>{error}</p>
</div>
<span className={styles.toggleIcon}>{isOpen ? "" : "+"}</span>
</button>
{isOpen && (
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
<p>{error}</p>
</div>
)}
</div>
);
}
@@ -103,11 +107,16 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
return (
<div className={styles.container}>
<div className={styles.header}>
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
<div className={styles.filters}>
<span className={styles.toggleIcon}>{isOpen ? "" : "+"}</span>
</button>
{isOpen && (
<>
<div className={styles.filters}>
<select
className={styles.select}
value={selectedTimeframe}
@@ -128,10 +137,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
<option key={c.id} value={c.id}>{c.fullName}</option>
))}
</select>
</div>
</div>
</div>
<div className={styles.metricsRow}>
<div className={styles.metricsRow}>
<div className={styles.metricBox}>
<span className={styles.metricLabel}>Ingresos del Periodo</span>
<span className={styles.metricValue}>
@@ -145,9 +153,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
{financials && (
<UnpaidIncomeCard value={financials.unpaidToday} />
)}
</div>
</div>
<div className={styles.chartContainer}>
<div className={styles.chartContainer}>
{!loading && chartData.length > 0 ? (
<LineChart
height={350}
@@ -210,7 +218,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
{loading ? 'Cargando datos...' : 'No hay datos para este periodo'}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -6,7 +6,15 @@
height: 70px;
overflow: hidden;
z-index: 1;
pointer-events: none;
}
.locationContainer a,
.locationContainer button,
.locationContainer [role="button"] {
pointer-events: auto;
}
.locationTitle {
display: flex;
align-items: center;
@@ -24,6 +24,7 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
const eventHandler = useEventHandlerStore();
const [pendingRepeats, setPendingRepeats] = useState<PendingRepeatView[]>([]);
const [filterByMe, setFilterByMe] = useState<boolean>(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const loadRepeats = () => {
if (!SessionInfo.userId) return;
@@ -79,12 +80,19 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
return (
<ThemeProvider theme={turnosXpressTheme}>
<Box className={style.container}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" className={style.title} sx={{ mb: 0 }}>
Turnos recurrentes pendientes de creación (Semana Actual)
</Typography>
{(allowEmployeeFilter && companyId) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<button className={style.header} type="button" onClick={() => setIsOpen((value) => !value)}>
<div className={style.headerTitleWrap}>
<Typography variant="h6" className={style.title} sx={{ mb: 0 }}>
Turnos recurrentes pendientes de creación (Semana Actual)
</Typography>
<span className={style.counter}>{pendingRepeats.length}</span>
</div>
<span className={style.toggleIcon}>{isOpen ? "" : "+"}</span>
</button>
{isOpen && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', mt: 1, mb: 2 }}>
{(allowEmployeeFilter && companyId) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }} onClick={(e) => e.stopPropagation()}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Solo mis turnos
</Typography>
@@ -95,14 +103,15 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
size="small"
sx={{ padding: 0 }}
/>
</Box>
)}
</Box>
{pendingRepeats.length === 0 ? (
</Box>
)}
</Box>
)}
{isOpen && pendingRepeats.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary', p: 2 }}>
No hay turnos recurrentes pendientes.
</Typography>
) : (
) : isOpen ? (
<div className={style.grid}>
{pendingRepeats.map(repeat => (
<Card key={repeat.id} className={style.card}>
@@ -127,7 +136,7 @@ const PendingRepeatsCard: React.FC<PendingRepeatsCardProps> = ({ companyId, empl
</Card>
))}
</div>
)}
) : null}
</Box>
</ThemeProvider>
);
@@ -7,12 +7,62 @@
border-radius: 8px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.headerTitleWrap {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.title {
color: var(--wine-red);
margin-bottom: 16px !important;
margin-bottom: 0 !important;
font-weight: 600 !important;
}
.counter {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 9px;
border-radius: 999px;
background: var(--wine-red);
color: var(--white);
font-size: 13px;
font-weight: 900;
}
.toggleIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--white);
color: var(--wine-red);
border: 1px solid var(--wine-red);
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -143,3 +143,27 @@
font-weight: 600;
color: #374151;
}
.renewalNotice {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid rgba(185, 28, 28, 0.18);
border-radius: 14px;
background: linear-gradient(135deg, rgba(185, 28, 28, 0.08), rgba(245, 158, 11, 0.08));
}
.renewalTitle {
margin: 0 0 6px;
font-size: 15px;
font-weight: 800;
color: #7f1d1d;
}
.renewalText {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: #374151;
}
@@ -24,6 +24,7 @@ export interface PlanMetricsWidgetProps {
};
onUpgrade: () => void;
onRenew?: () => void;
onViewDetails?: () => void;
onCancel: () => void;
isOwner?: boolean;
}
@@ -88,17 +89,29 @@ const MetricRow = ({
export default function PlanMetricsWidget({
subscription,
metrics,
onUpgrade,
onCancel,
onRenew,
isOwner = true,
}: PlanMetricsWidgetProps) {
const isCancelled = subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
const isPaidPlan = subscription.plan.price > 0;
const isRenewableStatus = subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || isCancelled;
const expiresSoon = Boolean(
subscription.endDate &&
dayjs(subscription.endDate).isAfter(dayjs()) &&
dayjs(subscription.endDate).isBefore(dayjs().add(1, "month").add(1, "day"))
);
const showRenewalNotice = isOwner && isPaidPlan && isRenewableStatus && expiresSoon && onRenew;
return (
<div className={styles.widgetContainer}>
<div className={styles.widgetHeader}>
<h3 className={styles.title}>Métricas del Plan {subscription.plan.name}</h3>
<span className={styles.subtitle}>Uso actual de tu suscripción</span>
{subscription.endDate && subscription.plan.price > 0 && (
<span className={styles.subtitle}>
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
</span>
)}
</div>
<div className={styles.metricsList}>
@@ -134,35 +147,24 @@ export default function PlanMetricsWidget({
/>
</div>
{isOwner && (
<div className={styles.actionsContainer}>
{showRenewalNotice && (
<div className={styles.renewalNotice}>
<div>
<p className={styles.renewalTitle}>Tu plan está por vencer</p>
<p className={styles.renewalText}>
El plan {subscription.plan.name} vence el {dayjs(subscription.endDate).format("DD/MM/YYYY")}. Podés extenderlo ahora y el nuevo período se suma al vencimiento actual.
</p>
</div>
<Button
color="primary"
text="Mejorar mi plan"
text="Extender plan"
width="custom"
style={{ width: "100%", padding: "12px 0", borderRadius: "8px", fontWeight: "600" }}
onClick={onUpgrade}
onClick={onRenew}
/>
<div className={styles.statusFooter}>
{isCancelled ? (
<div className={styles.cancelledInfo}>
<p className={styles.cancelledText}>La suscripción ha sido cancelada</p>
<p className={styles.validUntilText}>
Vigente hasta el {dayjs(subscription.endDate).format("DD/MM/YYYY")}
</p>
</div>
) : (
<Button
color="link"
text="Cancelar subscripción"
onClick={onCancel}
width="custom"
style={{ margin: 0, padding: 0, fontSize: "13px", color: "#6b7280", minHeight: "auto" }}
/>
)}
</div>
</div>
)}
</div>
);
}