fix: billing PREPAY_MANDATE stripe UI, invitation wizard show all feature instances
Made-with: Cursor
This commit is contained in:
parent
3bcf4e4d9e
commit
cc8a699e58
7 changed files with 276 additions and 112 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue