Skip to content

Commit ef988eb

Browse files
authored
feat: invoice individual view (#86)
1 parent e39d0cd commit ef988eb

17 files changed

Lines changed: 1404 additions & 11 deletions

apps/web/src/app/features/account/customers/views/customer-detail/customer-detail.component.html

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,26 @@ <h1>@if(c.name){{{c.name}}}@else{{{c.id}}}</h1>
3232
<div class="flex gap-extra-large view-panels">
3333
<div class="view-left-panel">
3434
<div class="view-section">
35-
<h2 class="section-title">Subscriptions</h2>
36-
<div class="dashed-placeholder">
37-
<span>No subscriptions</span>
35+
<div class="flex align-vert space-between">
36+
<h2 class="section-title">Subscriptions</h2>
37+
<button
38+
type="button"
39+
class="action-button action-button-small outline action-button-disabled"
40+
disabled
41+
>
42+
<img src="assets/icons/add.svg" class="icon" alt="Add icon" />
43+
</button>
3844
</div>
45+
<app-paginated-list
46+
endpoint="subscriptions"
47+
[columns]="subscriptionColumns"
48+
[limit]="5"
49+
[queryParams]="subscriptionQueryParams()"
50+
[expand]="subscriptionExpand()"
51+
[paginationEnabled]="false"
52+
[showResultCount]="true"
53+
(rowClick)="OnSubscriptionClick($event)"
54+
></app-paginated-list>
3955
</div>
4056
<div class="view-section">
4157
<h2 class="section-title">Payments</h2>
@@ -68,9 +84,15 @@ <h2 class="section-title">Entitlements</h2>
6884
</div>
6985
<div class="view-section">
7086
<h2 class="section-title">Invoices</h2>
71-
<div class="dashed-placeholder">
72-
<span>No invoicess</span>
73-
</div>
87+
<app-paginated-list
88+
endpoint="invoices"
89+
[columns]="invoiceColumns"
90+
[limit]="5"
91+
[queryParams]="invoiceQueryParams()"
92+
[expand]="invoiceExpand()"
93+
[paginationEnabled]="false"
94+
(rowClick)="OnInvoiceClick($event)"
95+
></app-paginated-list>
7496
</div>
7597
<div class="view-section">
7698
<h2 class="section-title">Pending invoice items</h2>

apps/web/src/app/features/account/customers/views/customer-detail/customer-detail.component.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
OnDestroy,
99
} from '@angular/core';
1010
import { DatePipe } from '@angular/common';
11-
import type { Customer, PaymentIntent } from '@zoneless/shared-types';
11+
import type {
12+
Customer,
13+
Invoice,
14+
PaymentIntent,
15+
Subscription as SubscriptionType,
16+
} from '@zoneless/shared-types';
1217
import { GetPaymentIntentListStatus } from '@zoneless/shared-types';
1318
import { CustomerService } from '../../../../../data';
1419
import { CustomerActionsService } from '../../services/customer-actions.service';
@@ -25,6 +30,18 @@ import { EventsListComponent } from '../../../components';
2530
import { MetadataToArray } from '../../../util/metadata';
2631
import { MetaService } from '../../../../../core';
2732
import { Subscription } from 'rxjs';
33+
import {
34+
FormatInvoiceFrequency,
35+
FormatInvoiceNumber,
36+
} from '../../../invoices/util/invoice-display';
37+
import {
38+
FormatShortDate,
39+
FormatSubscriptionBillingFrequency,
40+
FormatSubscriptionNextInvoice,
41+
FormatSubscriptionProduct,
42+
GetSubscriptionListStatus,
43+
GetSubscriptionPeriodProgress,
44+
} from '../../../subscriptions/util/subscription-display';
2845

2946
@Component({
3047
selector: 'app-customer-detail',
@@ -52,9 +69,21 @@ export class CustomerDetailComponent implements OnInit, OnDestroy {
5269
loading: WritableSignal<boolean> = signal(false);
5370
detailsExpanded: WritableSignal<boolean> = signal(false);
5471

72+
subscriptionColumns: PaginatedListColumn[] = [];
73+
subscriptionQueryParams: WritableSignal<Record<string, string>> = signal({});
74+
subscriptionExpand: WritableSignal<string[]> = signal([
75+
'items.data.price.product',
76+
]);
77+
5578
paymentColumns: PaginatedListColumn[] = [];
5679
paymentQueryParams: WritableSignal<Record<string, string>> = signal({});
5780

81+
invoiceColumns: PaginatedListColumn[] = [];
82+
invoiceQueryParams: WritableSignal<Record<string, string>> = signal({});
83+
invoiceExpand: WritableSignal<string[]> = signal([
84+
'subscription.items.data.price',
85+
]);
86+
5887
private sub?: Subscription;
5988

6089
customerActions: PopupMenuAction[] = [
@@ -73,7 +102,9 @@ export class CustomerDetailComponent implements OnInit, OnDestroy {
73102
if (!id) return;
74103
await this.LoadCustomer(id);
75104
this.metaService.SetMetaTitle(this.customer()?.name ?? 'Customer');
105+
this.InitSubscriptionList(id);
76106
this.InitPaymentList(id);
107+
this.InitInvoiceList(id);
77108
this.sub = this.actions.events$.subscribe((event) => {
78109
if (event.type === 'deleted' && event.customerId === id) {
79110
this.router.navigate(['/account/customers']);
@@ -96,6 +127,58 @@ export class CustomerDetailComponent implements OnInit, OnDestroy {
96127
}
97128
}
98129

130+
private InitSubscriptionList(customerId: string): void {
131+
this.subscriptionColumns = [
132+
{
133+
header: 'Product',
134+
field: 'product',
135+
type: 'text',
136+
bolded: true,
137+
progressGetter: (item: unknown) =>
138+
GetSubscriptionPeriodProgress(item as SubscriptionType),
139+
formatter: (item: unknown) =>
140+
FormatSubscriptionProduct(item as SubscriptionType),
141+
},
142+
{
143+
header: '',
144+
field: 'status',
145+
type: 'status',
146+
formatter: (item: unknown) =>
147+
GetSubscriptionListStatus(item as SubscriptionType),
148+
},
149+
{
150+
header: 'Frequency',
151+
field: 'frequency',
152+
type: 'text',
153+
dimmed: true,
154+
formatter: (item: unknown) =>
155+
FormatSubscriptionBillingFrequency(item as SubscriptionType),
156+
},
157+
{
158+
header: 'Next invoice',
159+
field: 'next_invoice',
160+
type: 'text',
161+
dimmed: true,
162+
formatter: (item: unknown) =>
163+
FormatSubscriptionNextInvoice(item as SubscriptionType),
164+
},
165+
{
166+
header: '',
167+
field: 'actions',
168+
type: 'actions',
169+
actions: [
170+
{
171+
title: 'Copy subscription ID',
172+
action: (item: SubscriptionType) => {
173+
void navigator.clipboard.writeText(item.id);
174+
},
175+
},
176+
],
177+
},
178+
];
179+
this.subscriptionQueryParams.set({ customer: customerId });
180+
}
181+
99182
private InitPaymentList(customerId: string): void {
100183
this.paymentColumns = [
101184
{
@@ -131,8 +214,79 @@ export class CustomerDetailComponent implements OnInit, OnDestroy {
131214
this.paymentQueryParams.set({ customer: customerId });
132215
}
133216

217+
private InitInvoiceList(customerId: string): void {
218+
this.invoiceColumns = [
219+
{
220+
header: 'Total',
221+
field: 'total',
222+
type: 'currency-with-code',
223+
currencyField: 'currency',
224+
bolded: true,
225+
},
226+
{
227+
header: '',
228+
field: 'status',
229+
type: 'status',
230+
formatter: (item: unknown) => (item as Invoice).status ?? '',
231+
},
232+
{
233+
header: 'Frequency',
234+
field: 'parent',
235+
type: 'text',
236+
dimmed: true,
237+
formatter: (item: unknown) => FormatInvoiceFrequency(item as Invoice),
238+
},
239+
{
240+
header: 'Invoice number',
241+
field: 'number',
242+
type: 'text',
243+
dimmed: true,
244+
formatter: (item: unknown) => FormatInvoiceNumber(item as Invoice),
245+
},
246+
{
247+
header: 'Due',
248+
field: 'due_date',
249+
type: 'text',
250+
dimmed: true,
251+
formatter: (item: unknown) => {
252+
const dueDate = (item as Invoice).due_date;
253+
return dueDate ? FormatShortDate(dueDate) : '—';
254+
},
255+
},
256+
{
257+
header: 'Created',
258+
field: 'created',
259+
type: 'date',
260+
dimmed: true,
261+
dateFormat: 'd MMM, HH:mm',
262+
},
263+
{
264+
header: '',
265+
field: '',
266+
type: 'actions',
267+
actions: [
268+
{
269+
title: 'Copy invoice ID',
270+
action: (item: Invoice) => {
271+
void navigator.clipboard.writeText(item.id);
272+
},
273+
},
274+
],
275+
},
276+
];
277+
this.invoiceQueryParams.set({ customer: customerId });
278+
}
279+
280+
OnSubscriptionClick(subscription: SubscriptionType): void {
281+
void this.router.navigate(['/account/subscriptions', subscription.id]);
282+
}
283+
134284
OnPaymentClick(paymentIntent: PaymentIntent): void {
135-
this.router.navigate(['/account/payments', paymentIntent.id]);
285+
void this.router.navigate(['/account/payments', paymentIntent.id]);
286+
}
287+
288+
OnInvoiceClick(invoice: Invoice): void {
289+
void this.router.navigate(['/account/invoices', invoice.id]);
136290
}
137291

138292
OnEdit(): void {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<app-metadata-edit-modal
2+
[isOpen]="actions.metadataDialogOpen()"
3+
[loading]="actions.metadataSaving()"
4+
title="Edit metadata"
5+
[metadata]="actions.metadataDraft()"
6+
(formChange)="actions.OnMetadataChange($event)"
7+
(confirmed)="actions.ConfirmEditMetadata()"
8+
(cancelled)="actions.metadataDialogOpen.set(false)"
9+
></app-metadata-edit-modal>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
2+
import { MetadataEditModalComponent } from '../../../components';
3+
import { InvoiceActionsService } from '../../services/invoice-actions.service';
4+
5+
@Component({
6+
selector: 'app-invoice-actions-host',
7+
imports: [MetadataEditModalComponent],
8+
templateUrl: './invoice-actions-host.component.html',
9+
changeDetection: ChangeDetectionStrategy.OnPush,
10+
})
11+
export class InvoiceActionsHostComponent {
12+
readonly actions = inject(InvoiceActionsService);
13+
}

apps/web/src/app/features/account/invoices/invoices.routes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Routes } from '@angular/router';
2+
import { InvoiceActionsService } from './services/invoice-actions.service';
23

34
export const invoiceRoutes: Routes = [
45
{
56
path: '',
7+
providers: [InvoiceActionsService],
68
children: [
79
{
810
path: '',
@@ -11,6 +13,13 @@ export const invoiceRoutes: Routes = [
1113
(m) => m.InvoiceListComponent
1214
),
1315
},
16+
{
17+
path: ':invoiceId',
18+
loadComponent: () =>
19+
import('./views/invoice-detail/invoice-detail.component').then(
20+
(m) => m.InvoiceDetailComponent
21+
),
22+
},
1423
],
1524
},
1625
];
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { inject, Injectable, signal, WritableSignal } from '@angular/core';
2+
import type { Invoice } from '@zoneless/shared-types';
3+
import { Subject } from 'rxjs';
4+
import { InvoiceService } from '../../../../data';
5+
6+
export type InvoiceActionEvent = {
7+
type: 'updated';
8+
invoice: Invoice;
9+
};
10+
11+
@Injectable()
12+
export class InvoiceActionsService {
13+
private readonly invoiceService = inject(InvoiceService);
14+
15+
metadataDialogOpen: WritableSignal<boolean> = signal(false);
16+
metadataSaving: WritableSignal<boolean> = signal(false);
17+
metadataTarget: WritableSignal<Invoice | null> = signal(null);
18+
metadataDraft: WritableSignal<Record<string, string>> = signal({});
19+
20+
readonly events$ = new Subject<InvoiceActionEvent>();
21+
22+
OpenEditMetadata(invoice: Invoice): void {
23+
this.metadataTarget.set(invoice);
24+
this.metadataDraft.set({ ...(invoice.metadata ?? {}) });
25+
this.metadataDialogOpen.set(true);
26+
}
27+
28+
OnMetadataChange(metadata: Record<string, string>): void {
29+
this.metadataDraft.set(metadata);
30+
}
31+
32+
async ConfirmEditMetadata(): Promise<void> {
33+
const invoice = this.metadataTarget();
34+
if (!invoice) return;
35+
this.metadataSaving.set(true);
36+
try {
37+
const updated = await this.invoiceService.UpdateInvoice(invoice.id, {
38+
metadata: this.metadataDraft(),
39+
});
40+
this.events$.next({ type: 'updated', invoice: updated });
41+
this.metadataDialogOpen.set(false);
42+
} finally {
43+
this.metadataSaving.set(false);
44+
}
45+
}
46+
47+
CopyInvoiceId(invoice: Invoice): void {
48+
void navigator.clipboard.writeText(invoice.id);
49+
}
50+
}

0 commit comments

Comments
 (0)