mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
[AC-2774] [AC-2781] Consolidated issues for Consolidated Billing (#9717)
* Rename provider client components for brevity * Make purchased seats dynamic on create client component * Fix access and empty state for service users * Refactor manage client subscription dialog * Fixed manage subscription dialog errors * Make unassigned seats dynamic for create client dialog * Expanded invoice statuses * Update invoice header on invoices component
This commit is contained in:
parent
043a7a39ff
commit
fa1a6359bc
@ -7892,7 +7892,7 @@
|
||||
"message": "Adjustments to seats will be reflected in the next billing cycle."
|
||||
},
|
||||
"unassignedSeatsDescription": {
|
||||
"message": "Unassigned subscription seats"
|
||||
"message": "Unassigned seats"
|
||||
},
|
||||
"purchaseSeatDescription": {
|
||||
"message": "Additional seats purchased"
|
||||
@ -8399,10 +8399,6 @@
|
||||
"exportClientReport": {
|
||||
"message": "Export client report"
|
||||
},
|
||||
"invoiceNumberHeader": {
|
||||
"message": "Invoice number",
|
||||
"description": "A table header for an invoice's number"
|
||||
},
|
||||
"memberAccessReport": {
|
||||
"message": "Member access"
|
||||
},
|
||||
@ -8450,5 +8446,29 @@
|
||||
},
|
||||
"smAccessRemovalSecretMessage": {
|
||||
"message": "This action will remove your access to this secret."
|
||||
},
|
||||
"invoice": {
|
||||
"message": "Invoice"
|
||||
},
|
||||
"unassignedSeatsAvailable": {
|
||||
"message": "You have $SEATS$ unassigned seats available.",
|
||||
"placeholders": {
|
||||
"seats": {
|
||||
"content": "$1",
|
||||
"example": "10"
|
||||
}
|
||||
},
|
||||
"description": "A message showing how many unassigned seats are available for a provider."
|
||||
},
|
||||
"contactYourProviderForAdditionalSeats": {
|
||||
"message": "Contact your provider admin to purchase additional seats."
|
||||
},
|
||||
"open": {
|
||||
"message": "Open",
|
||||
"description": "The status of an invoice."
|
||||
},
|
||||
"uncollectible": {
|
||||
"message": "Uncollectible",
|
||||
"description": "The status of an invoice."
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { canAccessBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
|
||||
import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@ -72,9 +72,9 @@ export class ClientsComponent extends BaseClientsComponent {
|
||||
switchMap((params) => {
|
||||
this.providerId = params.providerId;
|
||||
return this.providerService.get$(this.providerId).pipe(
|
||||
canAccessBilling(this.configService),
|
||||
map((canAccessBilling) => {
|
||||
if (canAccessBilling) {
|
||||
hasConsolidatedBilling(this.configService),
|
||||
map((hasConsolidatedBilling) => {
|
||||
if (hasConsolidatedBilling) {
|
||||
return from(
|
||||
this.router.navigate(["../manage-client-organizations"], {
|
||||
relativeTo: this.activatedRoute,
|
||||
|
@ -5,7 +5,7 @@
|
||||
<bit-nav-item
|
||||
icon="bwi-bank"
|
||||
[text]="'clients' | i18n"
|
||||
[route]="(canAccessBilling$ | async) ? 'manage-client-organizations' : 'clients'"
|
||||
[route]="(hasConsolidatedBilling$ | async) ? 'manage-client-organizations' : 'clients'"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
icon="bwi-sliders"
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { switchMap, Observable, Subject, filter, startWith } from "rxjs";
|
||||
import { switchMap, Observable, Subject, combineLatest, map } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { canAccessBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
|
||||
import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
@ -37,6 +37,8 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected provider$: Observable<Provider>;
|
||||
|
||||
protected hasConsolidatedBilling$: Observable<boolean>;
|
||||
protected canAccessBilling$: Observable<boolean>;
|
||||
|
||||
protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$(
|
||||
@ -57,10 +59,15 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
|
||||
this.canAccessBilling$ = this.provider$.pipe(
|
||||
filter((provider) => !!provider),
|
||||
canAccessBilling(this.configService),
|
||||
startWith(false),
|
||||
this.hasConsolidatedBilling$ = this.provider$.pipe(
|
||||
hasConsolidatedBilling(this.configService),
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
|
||||
this.canAccessBilling$ = combineLatest([this.hasConsolidatedBilling$, this.provider$]).pipe(
|
||||
map(
|
||||
([hasConsolidatedBilling, provider]) => hasConsolidatedBilling && provider.isProviderAdmin,
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/fronte
|
||||
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
||||
|
||||
import {
|
||||
ManageClientOrganizationsComponent,
|
||||
ManageClientsComponent,
|
||||
ProviderSubscriptionComponent,
|
||||
hasConsolidatedBilling,
|
||||
ProviderPaymentMethodComponent,
|
||||
@ -85,7 +85,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "manage-client-organizations",
|
||||
canActivate: [hasConsolidatedBilling],
|
||||
component: ManageClientOrganizationsComponent,
|
||||
component: ManageClientsComponent,
|
||||
data: { titleId: "clients" },
|
||||
},
|
||||
{
|
||||
@ -118,7 +118,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "billing",
|
||||
canActivate: [hasConsolidatedBilling],
|
||||
canActivate: [ProviderPermissionsGuard, hasConsolidatedBilling],
|
||||
data: { providerPermissions: (provider: Provider) => provider.isProviderAdmin },
|
||||
children: [
|
||||
{
|
||||
@ -129,6 +129,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "subscription",
|
||||
component: ProviderSubscriptionComponent,
|
||||
canActivate: [ProviderPermissionsGuard],
|
||||
data: {
|
||||
titleId: "subscription",
|
||||
},
|
||||
@ -136,6 +137,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "payment-method",
|
||||
component: ProviderPaymentMethodComponent,
|
||||
canActivate: [ProviderPermissionsGuard],
|
||||
data: {
|
||||
titleId: "paymentMethod",
|
||||
},
|
||||
@ -143,6 +145,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "history",
|
||||
component: ProviderBillingHistoryComponent,
|
||||
canActivate: [ProviderPermissionsGuard],
|
||||
data: {
|
||||
titleId: "billingHistory",
|
||||
},
|
||||
|
@ -10,11 +10,11 @@ import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/sh
|
||||
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
||||
|
||||
import {
|
||||
CreateClientOrganizationComponent,
|
||||
CreateClientDialogComponent,
|
||||
NoClientsComponent,
|
||||
ManageClientOrganizationNameComponent,
|
||||
ManageClientOrganizationsComponent,
|
||||
ManageClientOrganizationSubscriptionComponent,
|
||||
ManageClientNameDialogComponent,
|
||||
ManageClientsComponent,
|
||||
ManageClientSubscriptionDialogComponent,
|
||||
ProviderBillingHistoryComponent,
|
||||
ProviderPaymentMethodComponent,
|
||||
ProviderSelectPaymentMethodDialogComponent,
|
||||
@ -66,11 +66,11 @@ import { SetupComponent } from "./setup/setup.component";
|
||||
SetupComponent,
|
||||
SetupProviderComponent,
|
||||
UserAddEditComponent,
|
||||
CreateClientOrganizationComponent,
|
||||
CreateClientDialogComponent,
|
||||
NoClientsComponent,
|
||||
ManageClientOrganizationsComponent,
|
||||
ManageClientOrganizationNameComponent,
|
||||
ManageClientOrganizationSubscriptionComponent,
|
||||
ManageClientsComponent,
|
||||
ManageClientNameDialogComponent,
|
||||
ManageClientSubscriptionDialogComponent,
|
||||
ProviderBillingHistoryComponent,
|
||||
ProviderSubscriptionComponent,
|
||||
ProviderSelectPaymentMethodDialogComponent,
|
||||
|
@ -56,13 +56,15 @@
|
||||
<input type="text" bitInput formControlName="seats" />
|
||||
<bit-hint
|
||||
class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2"
|
||||
*ngIf="unassignedSeatsForSelectedPlan > 0"
|
||||
*ngIf="openSeats > 0"
|
||||
>
|
||||
<span class="tw-col-span-1"
|
||||
>{{ unassignedSeatsForSelectedPlan }}
|
||||
{{ "unassignedSeatsDescription" | i18n | lowercase }}</span
|
||||
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
|
||||
>
|
||||
<span class="tw-col-span-1"
|
||||
>{{ additionalSeatsPurchased }}
|
||||
{{ "purchaseSeatDescription" | i18n | lowercase }}</span
|
||||
>
|
||||
<span class="tw-col-span-1">0 {{ "purchaseSeatDescription" | i18n | lowercase }}</span>
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
@ -1,6 +1,6 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
@ -11,22 +11,22 @@ import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
|
||||
type CreateClientOrganizationParams = {
|
||||
type CreateClientDialogParams = {
|
||||
providerId: string;
|
||||
plans: PlanResponse[];
|
||||
};
|
||||
|
||||
export enum CreateClientOrganizationResultType {
|
||||
export enum CreateClientDialogResultType {
|
||||
Closed = "closed",
|
||||
Submitted = "submitted",
|
||||
}
|
||||
|
||||
export const openCreateClientOrganizationDialog = (
|
||||
export const openCreateClientDialog = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<CreateClientOrganizationParams>,
|
||||
dialogConfig: DialogConfig<CreateClientDialogParams>,
|
||||
) =>
|
||||
dialogService.open<CreateClientOrganizationResultType, CreateClientOrganizationParams>(
|
||||
CreateClientOrganizationComponent,
|
||||
dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>(
|
||||
CreateClientDialogComponent,
|
||||
dialogConfig,
|
||||
);
|
||||
|
||||
@ -39,26 +39,24 @@ type PlanCard = {
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-create-client-organization",
|
||||
templateUrl: "./create-client-organization.component.html",
|
||||
templateUrl: "./create-client-dialog.component.html",
|
||||
})
|
||||
export class CreateClientOrganizationComponent implements OnInit {
|
||||
protected formGroup = this.formBuilder.group({
|
||||
clientOwnerEmail: ["", [Validators.required, Validators.email]],
|
||||
organizationName: ["", Validators.required],
|
||||
seats: [null, [Validators.required, Validators.min(1)]],
|
||||
export class CreateClientDialogComponent implements OnInit {
|
||||
protected formGroup = new FormGroup({
|
||||
clientOwnerEmail: new FormControl<string>("", [Validators.required, Validators.email]),
|
||||
organizationName: new FormControl<string>("", [Validators.required]),
|
||||
seats: new FormControl<number>(null, [Validators.required, Validators.min(1)]),
|
||||
});
|
||||
protected loading = true;
|
||||
protected planCards: PlanCard[];
|
||||
protected ResultType = CreateClientOrganizationResultType;
|
||||
protected ResultType = CreateClientDialogResultType;
|
||||
|
||||
private providerPlans: ProviderPlanResponse[];
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams,
|
||||
private dialogRef: DialogRef<CreateClientOrganizationResultType>,
|
||||
private formBuilder: FormBuilder,
|
||||
@Inject(DIALOG_DATA) private dialogParams: CreateClientDialogParams,
|
||||
private dialogRef: DialogRef<CreateClientDialogResultType>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private webProviderService: WebProviderService,
|
||||
@ -159,14 +157,41 @@ export class CreateClientOrganizationComponent implements OnInit {
|
||||
this.dialogRef.close(this.ResultType.Submitted);
|
||||
};
|
||||
|
||||
protected get unassignedSeatsForSelectedPlan(): number {
|
||||
if (this.loading || !this.planCards) {
|
||||
protected get openSeats(): number {
|
||||
const selectedProviderPlan = this.getSelectedProviderPlan();
|
||||
|
||||
if (selectedProviderPlan === null) {
|
||||
return 0;
|
||||
}
|
||||
const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan;
|
||||
const selectedProviderPlan = this.providerPlans.find(
|
||||
(providerPlan) => providerPlan.planName === selectedPlan.name,
|
||||
);
|
||||
|
||||
return selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats;
|
||||
}
|
||||
|
||||
protected get unassignedSeats(): number {
|
||||
const unassignedSeats = this.openSeats - this.formGroup.value.seats;
|
||||
|
||||
return unassignedSeats > 0 ? unassignedSeats : 0;
|
||||
}
|
||||
|
||||
protected get additionalSeatsPurchased(): number {
|
||||
const selectedProviderPlan = this.getSelectedProviderPlan();
|
||||
|
||||
if (selectedProviderPlan === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const selectedSeats = this.formGroup.value.seats ?? 0;
|
||||
|
||||
const purchased = selectedSeats - this.openSeats;
|
||||
|
||||
return purchased > 0 ? purchased : 0;
|
||||
}
|
||||
|
||||
private getSelectedProviderPlan(): ProviderPlanResponse {
|
||||
if (this.loading || !this.planCards) {
|
||||
return null;
|
||||
}
|
||||
const selectedPlan = this.planCards.find((planCard) => planCard.selected).plan;
|
||||
return this.providerPlans.find((providerPlan) => providerPlan.planName === selectedPlan.name);
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export * from "./create-client-organization.component";
|
||||
export * from "./manage-client-organizations.component";
|
||||
export * from "./manage-client-organization-name.component";
|
||||
export * from "./manage-client-organization-subscription.component";
|
||||
export * from "./create-client-dialog.component";
|
||||
export * from "./manage-clients.component";
|
||||
export * from "./manage-client-name-dialog.component";
|
||||
export * from "./manage-client-subscription-dialog.component";
|
||||
export * from "./no-clients.component";
|
||||
|
@ -7,7 +7,7 @@ import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/model
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
type ManageClientOrganizationNameParams = {
|
||||
type ManageClientNameDialogParams = {
|
||||
providerId: string;
|
||||
organization: {
|
||||
id: string;
|
||||
@ -16,34 +16,33 @@ type ManageClientOrganizationNameParams = {
|
||||
};
|
||||
};
|
||||
|
||||
export enum ManageClientOrganizationNameResultType {
|
||||
export enum ManageClientNameDialogResultType {
|
||||
Closed = "closed",
|
||||
Submitted = "submitted",
|
||||
}
|
||||
|
||||
export const openManageClientOrganizationNameDialog = (
|
||||
export const openManageClientNameDialog = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<ManageClientOrganizationNameParams>,
|
||||
dialogConfig: DialogConfig<ManageClientNameDialogParams>,
|
||||
) =>
|
||||
dialogService.open<ManageClientOrganizationNameResultType, ManageClientOrganizationNameParams>(
|
||||
ManageClientOrganizationNameComponent,
|
||||
dialogService.open<ManageClientNameDialogResultType, ManageClientNameDialogParams>(
|
||||
ManageClientNameDialogComponent,
|
||||
dialogConfig,
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: "app-manage-client-organization-name",
|
||||
templateUrl: "manage-client-organization-name.component.html",
|
||||
templateUrl: "manage-client-name-dialog.component.html",
|
||||
})
|
||||
export class ManageClientOrganizationNameComponent {
|
||||
protected ResultType = ManageClientOrganizationNameResultType;
|
||||
export class ManageClientNameDialogComponent {
|
||||
protected ResultType = ManageClientNameDialogResultType;
|
||||
protected formGroup = this.formBuilder.group({
|
||||
name: [this.dialogParams.organization.name, Validators.required],
|
||||
});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected dialogParams: ManageClientOrganizationNameParams,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: ManageClientNameDialogParams,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private dialogRef: DialogRef<ManageClientOrganizationNameResultType>,
|
||||
private dialogRef: DialogRef<ManageClientNameDialogResultType>,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
@ -1,40 +0,0 @@
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<span bitDialogTitle>
|
||||
{{ "manageSeats" | i18n }}
|
||||
<small class="tw-text-muted" *ngIf="clientName">{{ clientName }}</small>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p>
|
||||
{{ "manageSeatsDescription" | i18n }}
|
||||
</p>
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>
|
||||
{{ "assignedSeats" | i18n }}
|
||||
</bit-label>
|
||||
<input id="assignedSeats" type="number" bitInput required [(ngModel)]="assignedSeats" />
|
||||
<bit-hint class="tw-text-muted" *ngIf="remainingOpenSeats > 0">
|
||||
<div class="tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2">
|
||||
<span class="tw-col-span-1"
|
||||
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
|
||||
>
|
||||
<span class="tw-col-span-1">0 {{ "purchaseSeatDescription" | i18n | lowercase }}</span>
|
||||
</div>
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="submit"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
bitFormButton
|
||||
(click)="updateSubscription(assignedSeats)"
|
||||
>
|
||||
<i class="bwi bwi-refresh bwi-fw" aria-hidden="true"></i>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
@ -1,116 +0,0 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
|
||||
import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
type ManageClientOrganizationDialogParams = {
|
||||
organization: ProviderOrganizationOrganizationDetailsResponse;
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "manage-client-organization-subscription.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class ManageClientOrganizationSubscriptionComponent implements OnInit {
|
||||
loading = true;
|
||||
providerOrganizationId: string;
|
||||
providerId: string;
|
||||
|
||||
clientName: string;
|
||||
assignedSeats: number;
|
||||
unassignedSeats: number;
|
||||
planName: string;
|
||||
AdditionalSeatPurchased: number;
|
||||
remainingOpenSeats: number;
|
||||
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) protected data: ManageClientOrganizationDialogParams,
|
||||
private billingApiService: BillingApiService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
this.providerOrganizationId = data.organization.id;
|
||||
this.providerId = data.organization.providerId;
|
||||
this.clientName = data.organization.organizationName;
|
||||
this.assignedSeats = data.organization.seats;
|
||||
this.planName = data.organization.plan;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const response = await this.billingApiService.getProviderSubscription(this.providerId);
|
||||
this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans);
|
||||
const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans);
|
||||
const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans);
|
||||
this.remainingOpenSeats = seatMinimum - assignedByPlan;
|
||||
this.unassignedSeats = Math.abs(this.remainingOpenSeats);
|
||||
} catch (error) {
|
||||
this.remainingOpenSeats = 0;
|
||||
this.AdditionalSeatPurchased = 0;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async updateSubscription(assignedSeats: number) {
|
||||
this.loading = true;
|
||||
if (!assignedSeats) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("assignedSeatCannotUpdate"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new UpdateClientOrganizationRequest();
|
||||
request.assignedSeats = assignedSeats;
|
||||
request.name = this.clientName;
|
||||
|
||||
await this.billingApiService.updateClientOrganization(
|
||||
this.providerId,
|
||||
this.providerOrganizationId,
|
||||
request,
|
||||
);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated"));
|
||||
this.loading = false;
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
getPurchasedSeatsByPlan(planName: string, plans: ProviderPlanResponse[]): number {
|
||||
const plan = plans.find((plan) => plan.planName === planName);
|
||||
if (plan) {
|
||||
return plan.purchasedSeats;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getAssignedByPlan(planName: string, plans: ProviderPlanResponse[]): number {
|
||||
const plan = plans.find((plan) => plan.planName === planName);
|
||||
if (plan) {
|
||||
return plan.assignedSeats;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getProviderSeatMinimumByPlan(planName: string, plans: ProviderPlanResponse[]) {
|
||||
const plan = plans.find((plan) => plan.planName === planName);
|
||||
if (plan) {
|
||||
return plan.seatMinimum;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, data: ManageClientOrganizationDialogParams) {
|
||||
return dialogService.open(ManageClientOrganizationSubscriptionComponent, { data });
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<span bitDialogTitle>
|
||||
{{ "manageSeats" | i18n }}
|
||||
<small class="tw-text-muted">{{ dialogParams.organization.organizationName }}</small>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p>{{ "manageSeatsDescription" | i18n }}</p>
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>
|
||||
{{ "assignedSeats" | i18n }}
|
||||
</bit-label>
|
||||
<input type="number" bitInput formControlName="assignedSeats" />
|
||||
<bit-hint class="tw-text-muted" *ngIf="openSeats > 0 || isServiceUserWithPurchasedSeats">
|
||||
<div
|
||||
*ngIf="!this.isServiceUserWithPurchasedSeats"
|
||||
class="tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1"
|
||||
[ngClass]="{ 'tw-grid-rows-2': this.isProviderAdmin }"
|
||||
>
|
||||
<span class="tw-col-span-1">
|
||||
{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}
|
||||
</span>
|
||||
<span *ngIf="this.isProviderAdmin" class="tw-col-span-1"
|
||||
>{{ additionalSeatsPurchased }}
|
||||
{{ "purchaseSeatDescription" | i18n | lowercase }}</span
|
||||
>
|
||||
</div>
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
@ -0,0 +1,180 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from "@angular/forms";
|
||||
|
||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
|
||||
import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
type ManageClientSubscriptionDialogParams = {
|
||||
organization: ProviderOrganizationOrganizationDetailsResponse;
|
||||
provider: Provider;
|
||||
};
|
||||
|
||||
export enum ManageClientSubscriptionDialogResultType {
|
||||
Closed = "closed",
|
||||
Submitted = "submitted",
|
||||
}
|
||||
|
||||
export const openManageClientSubscriptionDialog = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<ManageClientSubscriptionDialogParams>,
|
||||
) =>
|
||||
dialogService.open<
|
||||
ManageClientSubscriptionDialogResultType,
|
||||
ManageClientSubscriptionDialogParams
|
||||
>(ManageClientSubscriptionDialogComponent, dialogConfig);
|
||||
|
||||
@Component({
|
||||
templateUrl: "./manage-client-subscription-dialog.component.html",
|
||||
})
|
||||
export class ManageClientSubscriptionDialogComponent implements OnInit {
|
||||
protected loading = true;
|
||||
protected providerPlan: ProviderPlanResponse;
|
||||
protected openSeats: number;
|
||||
protected readonly ResultType = ManageClientSubscriptionDialogResultType;
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
assignedSeats: new FormControl<number>(this.dialogParams.organization.seats, [
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
]),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: ManageClientSubscriptionDialogParams,
|
||||
private dialogRef: DialogRef<ManageClientSubscriptionDialogResultType>,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const response = await this.billingApiService.getProviderSubscription(
|
||||
this.dialogParams.provider.id,
|
||||
);
|
||||
|
||||
this.providerPlan = response.plans.find(
|
||||
(plan) => plan.planName === this.dialogParams.organization.plan,
|
||||
);
|
||||
|
||||
this.openSeats = this.providerPlan.seatMinimum - this.providerPlan.assignedSeats;
|
||||
|
||||
this.formGroup.controls.assignedSeats.addValidators(
|
||||
this.isServiceUserWithPurchasedSeats
|
||||
? this.createPurchasedSeatsValidator()
|
||||
: this.createUnassignedSeatsValidator(),
|
||||
);
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.loading = true;
|
||||
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new UpdateClientOrganizationRequest();
|
||||
request.assignedSeats = this.formGroup.value.assignedSeats;
|
||||
request.name = this.dialogParams.organization.organizationName;
|
||||
|
||||
await this.billingApiService.updateClientOrganization(
|
||||
this.dialogParams.provider.id,
|
||||
this.dialogParams.organization.id,
|
||||
request,
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("subscriptionUpdated"),
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
this.dialogRef.close(this.ResultType.Submitted);
|
||||
};
|
||||
|
||||
createPurchasedSeatsValidator =
|
||||
(): ValidatorFn =>
|
||||
(formControl: FormControl<number>): ValidationErrors | null => {
|
||||
if (this.isProviderAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seatAdjustment = formControl.value - this.dialogParams.organization.seats;
|
||||
|
||||
if (seatAdjustment <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
insufficientPermissions: {
|
||||
message: this.i18nService.t("contactYourProviderForAdditionalSeats"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
createUnassignedSeatsValidator =
|
||||
(): ValidatorFn =>
|
||||
(formControl: FormControl<number>): ValidationErrors | null => {
|
||||
if (this.isProviderAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seatAdjustment = formControl.value - this.dialogParams.organization.seats;
|
||||
|
||||
if (seatAdjustment <= this.openSeats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unassignedSeatsAvailableMessage = this.i18nService.t(
|
||||
"unassignedSeatsAvailable",
|
||||
this.openSeats,
|
||||
);
|
||||
|
||||
const contactYourProviderMessage = this.i18nService.t(
|
||||
"contactYourProviderForAdditionalSeats",
|
||||
);
|
||||
|
||||
return {
|
||||
insufficientPermissions: {
|
||||
message: `${unassignedSeatsAvailableMessage} ${contactYourProviderMessage}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
get unassignedSeats(): number {
|
||||
const seatDifference =
|
||||
this.formGroup.value.assignedSeats - this.dialogParams.organization.seats;
|
||||
|
||||
const unassignedSeats = this.openSeats - seatDifference;
|
||||
|
||||
return unassignedSeats >= 0 ? unassignedSeats : 0;
|
||||
}
|
||||
|
||||
get additionalSeatsPurchased(): number {
|
||||
const seatDifference =
|
||||
this.formGroup.value.assignedSeats - this.dialogParams.organization.seats;
|
||||
|
||||
const purchasedSeats = seatDifference - this.openSeats;
|
||||
|
||||
return purchasedSeats > 0 ? purchasedSeats : 0;
|
||||
}
|
||||
|
||||
get isProviderAdmin(): boolean {
|
||||
return this.dialogParams.provider.type === ProviderUserType.ProviderAdmin;
|
||||
}
|
||||
|
||||
get isServiceUserWithPurchasedSeats(): boolean {
|
||||
return !this.isProviderAdmin && this.providerPlan && this.providerPlan.purchasedSeats > 0;
|
||||
}
|
||||
}
|
@ -1,12 +1,6 @@
|
||||
<app-header>
|
||||
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
|
||||
<a
|
||||
type="button"
|
||||
bitButton
|
||||
*ngIf="manageOrganizations"
|
||||
buttonType="primary"
|
||||
(click)="createClientOrganization()"
|
||||
>
|
||||
<a type="button" bitButton *ngIf="isProviderAdmin" buttonType="primary" (click)="createClient()">
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "addNewOrganization" | i18n }}
|
||||
</a>
|
||||
@ -73,15 +67,15 @@
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="manageName(client)">
|
||||
<button type="button" bitMenuItem (click)="manageClientName(client)">
|
||||
<i aria-hidden="true" class="bwi bwi-pencil-square"></i>
|
||||
{{ "updateName" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="manageSubscription(client)">
|
||||
<button type="button" bitMenuItem (click)="manageClientSubscription(client)">
|
||||
<i aria-hidden="true" class="bwi bwi-family"></i>
|
||||
{{ "manageSubscription" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="remove(client)">
|
||||
<button *ngIf="this.isProviderAdmin" type="button" bitMenuItem (click)="remove(client)">
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
|
||||
</span>
|
||||
@ -92,6 +86,9 @@
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
<div *ngIf="clients.length === 0" class="tw-mt-10">
|
||||
<app-no-clients (addNewOrganizationClicked)="createClientOrganization()" />
|
||||
<app-no-clients
|
||||
[showAddOrganizationButton]="this.isProviderAdmin"
|
||||
(addNewOrganizationClicked)="createClient()"
|
||||
/>
|
||||
</div>
|
||||
</ng-container>
|
@ -10,7 +10,7 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { canAccessBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
|
||||
import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@ -21,24 +21,27 @@ import { BaseClientsComponent } from "../../../admin-console/providers/clients/b
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
|
||||
import {
|
||||
CreateClientOrganizationResultType,
|
||||
openCreateClientOrganizationDialog,
|
||||
} from "./create-client-organization.component";
|
||||
CreateClientDialogResultType,
|
||||
openCreateClientDialog,
|
||||
} from "./create-client-dialog.component";
|
||||
import {
|
||||
ManageClientOrganizationNameResultType,
|
||||
openManageClientOrganizationNameDialog,
|
||||
} from "./manage-client-organization-name.component";
|
||||
import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component";
|
||||
ManageClientNameDialogResultType,
|
||||
openManageClientNameDialog,
|
||||
} from "./manage-client-name-dialog.component";
|
||||
import {
|
||||
ManageClientSubscriptionDialogResultType,
|
||||
openManageClientSubscriptionDialog,
|
||||
} from "./manage-client-subscription-dialog.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "manage-client-organizations.component.html",
|
||||
templateUrl: "manage-clients.component.html",
|
||||
})
|
||||
export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
||||
export class ManageClientsComponent extends BaseClientsComponent {
|
||||
providerId: string;
|
||||
provider: Provider;
|
||||
|
||||
loading = true;
|
||||
manageOrganizations = false;
|
||||
isProviderAdmin = false;
|
||||
|
||||
protected plans: PlanResponse[];
|
||||
|
||||
@ -73,9 +76,9 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
||||
switchMap((params) => {
|
||||
this.providerId = params.providerId;
|
||||
return this.providerService.get$(this.providerId).pipe(
|
||||
canAccessBilling(this.configService),
|
||||
map((canAccessBilling) => {
|
||||
if (!canAccessBilling) {
|
||||
hasConsolidatedBilling(this.configService),
|
||||
map((hasConsolidatedBilling) => {
|
||||
if (!hasConsolidatedBilling) {
|
||||
return from(
|
||||
this.router.navigate(["../clients"], {
|
||||
relativeTo: this.activatedRoute,
|
||||
@ -99,7 +102,7 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
||||
async load() {
|
||||
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
|
||||
|
||||
this.manageOrganizations = this.provider.type === ProviderUserType.ProviderAdmin;
|
||||
this.isProviderAdmin = this.provider.type === ProviderUserType.ProviderAdmin;
|
||||
|
||||
this.clients = (await this.apiService.getProviderClients(this.providerId)).data;
|
||||
|
||||
@ -110,8 +113,23 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async manageName(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||
const dialogRef = openManageClientOrganizationNameDialog(this.dialogService, {
|
||||
createClient = async () => {
|
||||
const reference = openCreateClientDialog(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
plans: this.plans,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(reference.closed);
|
||||
|
||||
if (result === CreateClientDialogResultType.Submitted) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => {
|
||||
const dialogRef = openManageClientNameDialog(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
organization: {
|
||||
@ -124,38 +142,25 @@ export class ManageClientOrganizationsComponent extends BaseClientsComponent {
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === ManageClientOrganizationNameResultType.Submitted) {
|
||||
if (result === ManageClientNameDialogResultType.Submitted) {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||
if (organization == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, {
|
||||
organization: organization,
|
||||
});
|
||||
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
createClientOrganization = async () => {
|
||||
const reference = openCreateClientOrganizationDialog(this.dialogService, {
|
||||
manageClientSubscription = async (
|
||||
organization: ProviderOrganizationOrganizationDetailsResponse,
|
||||
) => {
|
||||
const dialogRef = openManageClientSubscriptionDialog(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
plans: this.plans,
|
||||
organization,
|
||||
provider: this.provider,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(reference.closed);
|
||||
|
||||
if (result === CreateClientOrganizationResultType.Closed) {
|
||||
return;
|
||||
}
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result === ManageClientSubscriptionDialogResultType.Submitted) {
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Output } from "@angular/core";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
@ -26,7 +26,13 @@ const gearIcon = svgIcon`
|
||||
template: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
|
||||
<bit-icon [icon]="icon"></bit-icon>
|
||||
<p class="tw-mt-4">{{ "noClients" | i18n }}</p>
|
||||
<a type="button" bitButton buttonType="primary" (click)="addNewOrganization()">
|
||||
<a
|
||||
*ngIf="showAddOrganizationButton"
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="addNewOrganization()"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "addNewOrganization" | i18n }}
|
||||
</a>
|
||||
@ -34,6 +40,7 @@ const gearIcon = svgIcon`
|
||||
})
|
||||
export class NoClientsComponent {
|
||||
icon = gearIcon;
|
||||
@Input() showAddOrganizationButton = true;
|
||||
@Output() addNewOrganizationClicked = new EventEmitter();
|
||||
|
||||
addNewOrganization = () => this.addNewOrganizationClicked.emit();
|
||||
|
@ -20,7 +20,6 @@ export const hasConsolidatedBilling: CanActivateFn = async (route: ActivatedRout
|
||||
if (
|
||||
!consolidatedBillingEnabled ||
|
||||
!provider ||
|
||||
!provider.isProviderAdmin ||
|
||||
provider.providerStatus !== ProviderStatusType.Billable
|
||||
) {
|
||||
return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]);
|
||||
|
@ -10,7 +10,7 @@
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ "date" | i18n }}</th>
|
||||
<th bitCell>{{ "invoiceNumberHeader" | i18n }}</th>
|
||||
<th bitCell>{{ "invoice" | i18n }}</th>
|
||||
<th bitCell>{{ "total" | i18n }}</th>
|
||||
<th bitCell>{{ "status" | i18n }}</th>
|
||||
</tr>
|
||||
@ -29,7 +29,23 @@
|
||||
</a>
|
||||
</td>
|
||||
<td bitCell>{{ invoice.total | currency: "$" }}</td>
|
||||
<td bitCell>{{ invoice.status | titlecase }}</td>
|
||||
<td bitCell *ngIf="expandInvoiceStatus(invoice) as expandedInvoiceStatus">
|
||||
<span *ngIf="expandedInvoiceStatus === 'open'">
|
||||
{{ "open" | i18n | titlecase }}
|
||||
</span>
|
||||
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
|
||||
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
|
||||
{{ "unpaid" | i18n | titlecase }}
|
||||
</span>
|
||||
<span *ngIf="expandedInvoiceStatus === 'paid'">
|
||||
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
|
||||
{{ "paid" | i18n | titlecase }}
|
||||
</span>
|
||||
<span *ngIf="expandedInvoiceStatus === 'uncollectible'">
|
||||
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
|
||||
{{ "uncollectible" | i18n | titlecase }}
|
||||
</span>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
|
@ -46,4 +46,19 @@ export class InvoicesComponent implements OnInit {
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
expandInvoiceStatus = (
|
||||
invoice: InvoiceResponse,
|
||||
): "open" | "unpaid" | "paid" | "uncollectible" => {
|
||||
switch (invoice.status) {
|
||||
case "open": {
|
||||
const dueDate = new Date(invoice.dueDate);
|
||||
return dueDate < new Date() ? "unpaid" : invoice.status;
|
||||
}
|
||||
case "paid":
|
||||
case "uncollectible": {
|
||||
return invoice.status;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
|
||||
type MaybeProvider = Provider | undefined;
|
||||
|
||||
export const canAccessBilling = (
|
||||
export const hasConsolidatedBilling = (
|
||||
configService: ConfigService,
|
||||
): OperatorFunction<MaybeProvider, boolean> =>
|
||||
switchMap<MaybeProvider, Observable<boolean>>((provider) =>
|
||||
@ -16,9 +16,7 @@ export const canAccessBilling = (
|
||||
.pipe(
|
||||
map((consolidatedBillingEnabled) =>
|
||||
provider
|
||||
? provider.isProviderAdmin &&
|
||||
provider.providerStatus === ProviderStatusType.Billable &&
|
||||
consolidatedBillingEnabled
|
||||
? provider.providerStatus === ProviderStatusType.Billable && consolidatedBillingEnabled
|
||||
: false,
|
||||
),
|
||||
),
|
||||
|
@ -18,6 +18,7 @@ export class InvoiceResponse extends BaseResponse {
|
||||
number: string;
|
||||
total: number;
|
||||
status: string;
|
||||
dueDate: string;
|
||||
url: string;
|
||||
pdfUrl: string;
|
||||
|
||||
@ -28,6 +29,7 @@ export class InvoiceResponse extends BaseResponse {
|
||||
this.number = this.getResponseProperty("Number");
|
||||
this.total = this.getResponseProperty("Total");
|
||||
this.status = this.getResponseProperty("Status");
|
||||
this.dueDate = this.getResponseProperty("DueDate");
|
||||
this.url = this.getResponseProperty("Url");
|
||||
this.pdfUrl = this.getResponseProperty("PdfUrl");
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user