feat: plan management
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user