fix: billing PREPAY_MANDATE stripe UI, invitation wizard show all feature instances

Made-with: Cursor
This commit is contained in:
ValueOn AG 2026-03-23 10:29:24 +01:00
parent 3bcf4e4d9e
commit cc8a699e58
7 changed files with 276 additions and 112 deletions

View file

@ -108,8 +108,10 @@
min-width: 0;
display: flex;
flex-direction: column;
overflow-x: hidden;
/* Allow horizontal scroll when inner content has a true minimum width; hiding X clips dashboard/store grids on narrow viewports */
overflow-x: auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.mobileTopBar {

View file

@ -3,9 +3,12 @@
*/
.dashboard {
padding: 2rem;
box-sizing: border-box;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
min-width: 0;
}
/* Header */
@ -50,10 +53,10 @@
color: var(--text-primary, #1a1a1a);
}
/* Instance Grid */
/* Instance Grid — min(100%, Npx) keeps one fluid column on narrow viewports (no horizontal clip) */
.instanceGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 1rem;
}
@ -63,6 +66,7 @@
align-items: center;
gap: 1rem;
padding: 1.25rem;
min-width: 0;
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
@ -123,9 +127,8 @@
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
line-height: 1.3;
}
.mandateName {
@ -245,3 +248,23 @@
:global(.dark-theme) .emptyState p {
color: var(--text-secondary-dark, #aaa);
}
@media (max-width: 600px) {
.dashboard {
padding: 1rem 0.875rem;
}
.header h1 {
font-size: 1.35rem;
}
.instanceCard {
padding: 1rem;
gap: 0.75rem;
}
.cardIcon {
width: 40px;
height: 40px;
}
}

View file

@ -3,9 +3,12 @@
*/
.store {
padding: 2rem;
box-sizing: border-box;
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
min-width: 0;
}
/* Header */
@ -39,6 +42,7 @@
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
padding: 1.5rem;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
@ -59,6 +63,7 @@
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.cardIcon {
@ -72,6 +77,8 @@
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
min-width: 0;
overflow-wrap: anywhere;
}
.cardBody {
@ -260,3 +267,17 @@
:global(.dark-theme) .loading {
color: var(--text-secondary-dark, #aaa);
}
@media (max-width: 600px) {
.store {
padding: 1rem 0.875rem;
}
.header h1 {
font-size: 1.35rem;
}
.card {
padding: 1.125rem;
}
}

View file

@ -905,6 +905,51 @@
background: var(--bg-tertiary, #f8f9fa);
}
.accessOverviewSubheading {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #666);
margin: 1rem 0 0.5rem;
}
.accessOverviewSubheading:first-child {
margin-top: 0;
}
.accessOverviewRoleBullets {
margin: 0.25rem 0 0.85rem;
padding-left: 1.25rem;
font-size: 0.875rem;
line-height: 1.55;
}
.accessOverviewInstanceStack {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.accessOverviewInstanceBlock {
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
}
.accessOverviewInstanceTitle {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary, #1a1a1a);
margin-bottom: 0.35rem;
}
.accessOverviewInstanceFeature {
font-weight: 500;
color: var(--text-secondary, #666);
}
.emptyHint {
font-size: 0.875rem;
color: var(--text-tertiary, #999);

View file

@ -47,7 +47,7 @@ interface AccessEntry {
interface MandateInfo {
id: string;
name: string;
label?: string;
label?: string | null;
roleIds: string[];
featureInstances: {
id: string;
@ -58,6 +58,18 @@ interface MandateInfo {
}[];
}
function _mandateNameLine(mandate: MandateInfo): string {
const label = mandate.label?.trim();
if (label) {
return `${mandate.name} (${label})`;
}
return mandate.name;
}
function _roleDescriptionLine(role: RoleInfo): string {
return role.description?.de || role.description?.en || '';
}
interface UserAccessOverview {
user: UserOption;
isSysAdmin: boolean;
@ -174,9 +186,14 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
const renderOverviewTab = () => {
if (!overview) return null;
const roleById = new Map(overview.roles.map((r) => [r.id, r]));
const globalRoles = overview.roles.filter((r) => r.scope === 'global');
const _resolveRoles = (roleIds: string[]): RoleInfo[] =>
roleIds.map((id) => roleById.get(id)).filter((r): r is RoleInfo => !!r);
return (
<div className={styles.scrollableContent}>
{/* SysAdmin Notice */}
{overview.isSysAdmin && (
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
@ -184,109 +201,148 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
</div>
)}
{/* Mandates & Feature Instances */}
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Mandate & Feature-Instanzen</h3>
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugriff nach Mandant</h3>
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
) : (
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.mandates.map(mandate => (
<div key={mandate.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleMandate(mandate.id)}
>
<div className={styles.roleInfo}>
{expandedMandates.has(mandate.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
<span className={styles.roleLabel}>{mandate.label || mandate.name}</span>
<span className={styles.roleDescription}>
{mandate.featureInstances.length} Feature-Instanz(en)
</span>
{overview.mandates.map((mandate) => {
const mandateRoles = _resolveRoles(mandate.roleIds);
return (
<div key={mandate.id} className={styles.roleCard}>
<div className={styles.roleHeader} onClick={() => toggleMandate(mandate.id)}>
<div className={styles.roleInfo} style={{ flexWrap: 'wrap', rowGap: '0.35rem' }}>
{expandedMandates.has(mandate.id) ? (
<FaChevronDown className={styles.expandIcon} />
) : (
<FaChevronRight className={styles.expandIcon} />
)}
<span className={styles.roleLabel}>{_mandateNameLine(mandate)}</span>
<span className={styles.roleDescription}>
{mandateRoles.length} Mandantenrolle(n) · {mandate.featureInstances.length} Feature-Instanz(en)
</span>
</div>
</div>
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandateRoles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen direkt am Mandanten.</p>
) : (
<ul className={styles.accessOverviewRoleBullets}>
{mandateRoles.map((r) => (
<li key={r.id}>
<strong>{r.roleLabel}</strong>
<span
className={styles.badge}
style={{
background: getScopeColor(r.scope),
color: 'white',
marginLeft: '0.35rem',
fontSize: '0.65rem',
verticalAlign: 'middle',
}}
>
{r.scope}
</span>
</li>
))}
</ul>
)}
<div className={styles.accessOverviewSubheading}>Feature-Instanzen</div>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
) : (
<div className={styles.accessOverviewInstanceStack}>
{mandate.featureInstances.map((instance) => {
const instanceRoles = _resolveRoles(instance.roleIds);
const featureTitle = instance.featureLabel?.de || instance.featureCode;
return (
<div key={instance.id} className={styles.accessOverviewInstanceBlock}>
<div className={styles.accessOverviewInstanceTitle}>
{instance.label}{' '}
<span className={styles.accessOverviewInstanceFeature}>({featureTitle})</span>
</div>
{instanceRoles.length === 0 ? (
<p className={styles.emptyHint} style={{ margin: '0.35rem 0 0' }}>
Keine Rollen.
</p>
) : (
<ul className={styles.accessOverviewRoleBullets}>
{instanceRoles.map((r) => (
<li key={r.id}>
<strong>{r.roleLabel}</strong>
<span
className={styles.badge}
style={{
background: getScopeColor(r.scope),
color: 'white',
marginLeft: '0.35rem',
fontSize: '0.65rem',
verticalAlign: 'middle',
}}
>
{r.scope}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{mandate.featureInstances.map(instance => (
<div
key={instance.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '6px',
border: '1px solid var(--border-color)',
}}
>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>
{instance.label}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
Feature: {instance.featureLabel?.de || instance.featureCode}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
Rollen: {instance.roleIds.length > 0
? overview.roles
.filter(r => instance.roleIds.includes(r.id))
.map(r => r.roleLabel)
.join(', ')
: 'Keine'
}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
);
})}
</div>
)}
{/* Roles */}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugewiesene Rollen</h3>
{overview.roles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen zugewiesen.</p>
) : (
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.roles.map(role => (
<div key={role.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
{expandedRoles.has(role.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span
className={styles.badge}
style={{
background: getScopeColor(role.scope),
color: 'white',
marginLeft: '0.5rem'
}}
>
{role.scope}
</span>
</div>
</div>
{expandedRoles.has(role.id) && (
<div className={styles.roleContent}>
<div style={{ fontSize: '0.875rem' }}>
<p><strong>Beschreibung:</strong> {role.description?.de || role.description?.en || '-'}</p>
<p><strong>Quelle:</strong> {role.source === 'mandate'
? `Mandate: ${role.sourceMandateName}`
: `Feature-Instanz: ${role.sourceInstanceLabel}`
}</p>
{globalRoles.length > 0 && (
<>
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>
Globale Rollen
</h3>
<p className={styles.emptyHint} style={{ marginTop: '-0.5rem', marginBottom: '1rem' }}>
Nicht an einen Mandanten gebunden.
</p>
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{globalRoles.map((role) => (
<div key={role.id} className={styles.roleCard}>
<div className={styles.roleHeader} onClick={() => toggleRole(role.id)}>
<div className={styles.roleInfo}>
{expandedRoles.has(role.id) ? (
<FaChevronDown className={styles.expandIcon} />
) : (
<FaChevronRight className={styles.expandIcon} />
)}
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span
className={styles.badge}
style={{ background: getScopeColor(role.scope), color: 'white', marginLeft: '0.5rem' }}
>
{role.scope}
</span>
</div>
</div>
)}
</div>
))}
</div>
{expandedRoles.has(role.id) && (
<div className={styles.roleContent}>
<div style={{ fontSize: '0.875rem' }}>
<p>
<strong>Beschreibung:</strong> {_roleDescriptionLine(role) || '—'}
</p>
</div>
</div>
)}
</div>
))}
</div>
</>
)}
</div>
);

View file

@ -128,9 +128,8 @@ export const AdminInvitationWizardPage: React.FC = () => {
useEffect(() => {
if (inviteType === 'featureInstance' && selectedMandate) {
fetchInstances(selectedMandate.id).then(data =>
setInstances((data || []).filter((i: FeatureInstance) => i.enabled))
);
// Show all instances for the mandate (including disabled). Previously only `enabled` instances were listed, which hid deactivated instances from admins.
fetchInstances(selectedMandate.id).then(data => setInstances(data || []));
} else {
setInstances([]);
setSelectedInstance(null);
@ -411,7 +410,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
<div style={{ marginBottom: '16px' }}>
<label className={styles.formLabel}>Feature-Instanz *</label>
{instances.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Keine aktiven Feature-Instanzen für diesen Mandanten vorhanden.</p>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Keine Feature-Instanzen für diesen Mandanten vorhanden.</p>
) : (
<select
className={styles.filterSelect}
@ -423,9 +422,13 @@ export const AdminInvitationWizardPage: React.FC = () => {
}}
>
<option value="">-- Feature-Instanz wählen --</option>
{instances.map(inst => (
<option key={inst.id} value={inst.id}>{inst.label || inst.featureCode}</option>
))}
{instances.map(inst => {
const baseLabel = inst.label || inst.featureCode;
const suffix = inst.enabled === false ? ' (deaktiviert)' : '';
return (
<option key={inst.id} value={inst.id}>{`${baseLabel}${suffix}`}</option>
);
})}
</select>
)}
</div>

View file

@ -65,8 +65,9 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
return 'Prepaid (Mandant)';
};
const canTopUp = balance.billingModel === 'PREPAY_USER'
|| balance.billingModel === 'PREPAY_MANDATE';
// Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
@ -82,7 +83,20 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
Niedriges Guthaben
</div>
)}
{canTopUp && onCheckout && (
{isMandatePrepaidPool && (
<p
style={{
marginTop: '12px',
fontSize: '13px',
lineHeight: 1.45,
opacity: 0.75,
marginBottom: 0,
}}
>
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
</p>
)}
{canStripeTopUpHere && onCheckout && (
<div style={{ marginTop: '12px' }}>
{!showCheckout ? (
<button