Now that the groundwork is done and i18n is configured, Tailwind is in place, and our Angular app is ready, it’s time to make things happen.
In this part, we’ll focus on functionality. You’ll build a complete invoice dashboard where users can create, edit, view, and delete invoices. We’ll also add handy features like printing, exporting, and importing, all while keeping the app clean, reactive, and localization-friendly.
By the end of this section, your app will display placeholders and will feel alive, interactive, and ready for multilingual support when we integrate Localazy in the final part.
1️⃣ Step 1: Build the dashboard 🔗
You’ll scaffold a dashboard screen as the app’s first view, then plug in a minimal Tailwind/i18n template.
1. Generate the dashboard component 🔗
Run this command from your project root:
npm run ng -- generate component features/dashboard --standalone
Angular CLI scaffolds the component under src/app/features/dashboard/:
dashboard.component.ts // Component logic (TypeScript)
dashboard.component.html // Template for layout & i18n text
dashboard.component.scss // Local styling (will use Tailwind classes)
It also wires the component for standalone use.
2. Implementing the dashboard component 🔗
Next, let's implement the component:
src/app/features/dashboard/dashboard.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [RouterModule, TranslateModule],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {}
src/app/features/dashboard/dashboard.component.html
<section class="max-w-5xl mx-auto p-6">
<header class="flex items-center gap-3 mb-6">
<h2 class="text-2xl font-semibold">{{ 'dashboard.title' | translate }}</h2>
<a routerLink="/invoice/new" class="btn ml-auto">
{{ 'invoice.actions.create' | translate }}
</a>
</header>
<div class="rounded-xl border border-dashed p-8 text-gray-600">
{{ 'dashboard.empty' | translate }}
</div>
</section>
3. Invoice Edit component 🔗
We’ll now create the Invoice Edit view, wire up its template, add the needed i18n keys, and define lazy-loaded routes for a lightweight Angular app. Generate the standalone component first, then replace its files with the code below.
src/app/features/invoice-edit/invoice-edit.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-invoice-edit',
standalone: true,
imports: [RouterModule, TranslateModule],
templateUrl: './invoice-edit.component.html',
styleUrls: ['./invoice-edit.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceEditComponent {}
Like the dashboard, this component uses Angular’s standalone API, importing both the RouterModule and TranslateModule directly. The OnPush change detection keeps the UI performant as the project scales with invoice forms and API data.
src/app/features/invoice-edit/invoice-edit.component.html
<section class="max-w-3xl mx-auto p-6 space-y-4">
<h2 class="text-2xl font-semibold">{{ 'invoice.edit.title' | translate }}</h2>
<p class="text-gray-600">{{ 'invoice.edit.placeholder' | translate }}</p>
<a routerLink="/" class="btn-secondary inline-flex items-center">
{{ 'nav.back' | translate }}
</a>
</section>
4. Add translation keys 🔗
Place these in public/assets/i18n/ so ngx-translate (and later Localazy) can sync them.
public/assets/i18n/en.json
{
"app": {
"title": "Welcome to Your Invoice & Expense Tracker",
},
"dashboard": {
"title": "Dashboard",
"empty": "No invoices yet. Create your first invoice."
},
"invoice": {
"edit": {
"title": "New Invoice",
"placeholder": "Form coming next."
},
"actions": {
"create": "Create Invoice"
}
},
"nav": {
"back": "Back"
}
}
public/assets/i18n/fr.json
{
"app": {
"title": "Bienvenue dans votre outil de suivi des factures et des dépenses",
},
"dashboard": {
"title": "Tableau de bord",
"empty": "Aucune facture pour le moment. Créez votre première facture."
},
"invoice": {
"edit": {
"title": "Nouvelle facture",
"placeholder": "Formulaire à venir."
},
"actions": {
"create": "Créer une facture"
}
},
"nav": {
"back": "Retour"
}
}
5. Define routes with lazy loading 🔗
Create/update src/app/app.routes.ts to lazy-load screens. See Angular Router docs for reference: angular.dev > Guide > Router.
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{
path: 'invoice/new',
loadComponent: () =>
import('./features/invoice-edit/invoice-edit.component').then(m => m.InvoiceEditComponent),
},
{
path: 'invoice/:id',
loadComponent: () =>
import('./features/invoice-edit/invoice-edit.component').then(m => m.InvoiceEditComponent),
},
{ path: '**', redirectTo: '' },
];
This routing setup ensures that:
/loads the Dashboard (your main screen)./invoice/newopens the Invoice Editor for creating a new invoice./invoice/:idreuses the same component for editing an existing invoice.- Any undefined route gracefully redirects back to the Dashboard.
6. Run & verify 🔗
Start your local server to check that everything works:
npm start
Then open http://localhost:4200 and verify that:
- The Dashboard loads with a localized title and Create Invoice button.
- By clicking, it opens the Invoice Edit screen with the Back button styled using
.btn-secondary. - Text is updated when switching languages, confirming that ngx-translate and Tailwind have been correctly integrated.
2️⃣ Step 2: Set up the invoice models and store 🔗
Define the app’s data layer so the UI and i18n stay clean. We’ll model invoices and line items first, then wire a lightweight store next.
1. Create the models 🔗
Start by defining the core data structures that your invoice app will use: invoices, line items, and their statuses. These models form the foundation of your store and components.
File: src/app/core/models/invoice.model.ts
export type InvoiceStatus = 'draft' | 'sent' | 'paid';
export interface LineItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
taxRate?: number;
discountRate?: number;
}
export interface Invoice {
id: string;
number: string;
clientName: string;
clientEmail?: string;
issueDate: string;
dueDate?: string;
currency: string;
items: LineItem[];
notes?: string;
status: InvoiceStatus;
createdAt: string;
updatedAt: string;
}
These models define the structure of invoices and line items, ensuring consistent data handling across components and simplifying integration with the store.
2. Create the Signal-based store with Persistence 🔗
Use Angular signals to manage the invoice state without extra libraries. This store exposes reactive selectors (via computed), persists to localStorage with an effect, and keeps your components lean.
src/app/core/stores/invoice.store.ts
import { Injectable, computed, effect, signal } from '@angular/core';
import { Invoice, InvoiceStatus, LineItem } from '../models/invoice.model';
const STORAGE_KEY = 'invoice.store.v1';
function nowIso() { return new Date().toISOString(); }
function newId() { return (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)); }
function calcLineTotal(li: LineItem): number {
const qty = Math.max(0, li.quantity || 0);
const price = Math.max(0, li.unitPrice || 0);
const preTax = qty * price;
const discount = li.discountRate ? preTax * (li.discountRate / 100) : 0;
const afterDiscount = preTax - discount;
const tax = li.taxRate ? afterDiscount * (li.taxRate / 100) : 0;
return +(afterDiscount + tax).toFixed(2);
}
function calcInvoiceTotal(inv: Invoice): number {
return +inv.items.reduce((sum, li) => sum + calcLineTotal(li), 0).toFixed(2);
}
function load(): Invoice[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Invoice[];
// basic sanity
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
@Injectable({ providedIn: 'root' })
export class InvoiceStore {
private readonly _invoices = signal<Invoice[]>(load());
// Persist to localStorage on change
private readonly _persist = effect(() => {
const value = JSON.stringify(this._invoices());
localStorage.setItem(STORAGE_KEY, value);
});
// Selectors
readonly invoices = this._invoices.asReadonly();
totalCount = computed(() => this._invoices().length);
totalByStatus = (status: InvoiceStatus) => computed(
() => this._invoices().filter(i => i.status === status).length
);
byId = (id: string) => computed(() => this._invoices().find(i => i.id === id) || null);
totalAmount = (id: string) => computed(() => {
const inv = this._invoices().find(i => i.id === id);
return inv ? calcInvoiceTotal(inv) : 0;
});
// Mutations
createDraft(partial?: Partial<Invoice>): Invoice {
const id = newId();
const created = nowIso();
const number = this.nextNumber();
const invoice: Invoice = {
id,
number,
clientName: partial?.clientName ?? '',
clientEmail: partial?.clientEmail,
issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),
dueDate: partial?.dueDate,
currency: partial?.currency ?? 'USD',
items: partial?.items ?? [this.newLineItem()],
notes: partial?.notes,
status: partial?.status ?? 'draft',
createdAt: created,
updatedAt: created,
};
this._invoices.update(arr => [invoice, ...arr]);
return invoice;
}
update(id: string, changes: Partial<Invoice>): void {
this._invoices.update(arr =>
arr.map(inv => inv.id === id ? { ...inv, ...changes, updatedAt: nowIso() } : inv)
);
}
remove(id: string): void {
this._invoices.update(arr => arr.filter(inv => inv.id !== id));
}
// ---- Line item helpers
newLineItem(): LineItem {
return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };
}
addLineItem(invoiceId: string, li?: Partial<LineItem>): void {
this._invoices.update(arr =>
arr.map(inv => inv.id === invoiceId
? { ...inv, items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items], updatedAt: nowIso() }
: inv
)
);
}
updateLineItem(invoiceId: string, itemId: string, changes: Partial<LineItem>): void {
this._invoices.update(arr =>
arr.map(inv => {
if (inv.id !== invoiceId) return inv;
const items = inv.items.map(it => it.id === itemId ? { ...it, ...changes } : it);
return { ...inv, items, updatedAt: nowIso() };
})
);
}
removeLineItem(invoiceId: string, itemId: string): void {
this._invoices.update(arr =>
arr.map(inv => {
if (inv.id !== invoiceId) return inv;
const items = inv.items.filter(it => it.id !== itemId);
return { ...inv, items, updatedAt: nowIso() };
})
);
}
setStatus(id: string, status: InvoiceStatus) {
this.update(id, { status });
}
// Utilities
private nextNumber(): string {
const seq = this._invoices().length + 1;
return `INV-${String(seq).padStart(4, '0')}`;
}
}This setup lets you handle invoices in real time while keeping data in sync across sessions.
Because it’s based on Angular’s built-in reactivity, the store stays small, fast, and ready to scale alongside your localized UI managed with ngx-translate and Localazy.
3. Add i18n keys for statuses 🔗
Before displaying invoice statuses in the UI, define their localized labels.
This ensures values like Draft, Sent, and Paid are translated dynamically in any language your app supports.
public/assets/i18n/en.json
{
"status": {
"draft": "Draft",
"sent": "Sent",
"paid": "Paid"
}
}
public/assets/i18n/fr.json
{
"status": {
"draft": "Brouillon",
"sent": "Envoyée",
"paid": "Payée"
}
}
4. Update the dashboard component to read from the store 🔗
Now that your store is ready, update the dashboard to pull invoice data directly from it. This step lets you display stored invoices, totals, and statuses with live updates.
File: src/app/features/dashboard/dashboard.component.ts
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { InvoiceStore } from '../../core/stores/invoice.store';
import { Invoice } from '../../core/models/invoice.model';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [RouterModule, TranslateModule, CurrencyPipe, DatePipe],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {
private readonly store = inject(InvoiceStore);
invoices = this.store.invoices; // signal<Invoice[]>
// demo: create a draft quickly
createSample() {
this.store.createDraft({
clientName: 'Acme Corp',
currency: 'USD',
items: [
{ id: crypto.randomUUID(), description: 'Design work', quantity: 3, unitPrice: 120, taxRate: 10 },
],
status: 'draft',
});
}
total(inv: Invoice): number {
// Avoid recomputing across the list by using store.totalAmount(inv.id) if you prefer computed-per-id
return this.store.totalAmount(inv.id)();
}
statusClass(status: Invoice['status']): string {
switch (status) {
case 'paid': return 'bg-green-100 text-green-700';
case 'sent': return 'bg-amber-100 text-amber-700';
default: return 'bg-gray-100 text-gray-700';
}
}
}Now update the file src/app/features/dashboard/dashboard.component.html.
<section class="max-w-5xl mx-auto p-6 space-y-4">
<header class="flex items-center gap-3">
<h2 class="text-2xl font-semibold">{{ 'dashboard.title' | translate }}</h2>
<a routerLink="/invoice/new" class="btn ml-auto">
{{ 'invoice.actions.create' | translate }}
</a>
<button class="btn-secondary" type="button" (click)="createSample()">+ Sample</button>
</header>
@if (invoices().length === 0) {
<div class="rounded-xl border border-dashed p-8 text-gray-600">
{{ 'dashboard.empty' | translate }}
</div>
} @else {
<ul class="space-y-3">
@for (inv of invoices(); track inv.id) {
<li class="rounded-xl border p-4 hover:shadow-sm transition">
<div class="flex items-center gap-3">
<div class="font-semibold">{{ inv.number }}</div>
<div class="text-gray-600">•</div>
<div class="text-gray-800">{{ inv.clientName || '—' }}</div>
<div class="text-gray-500 ml-auto flex items-center gap-3">
<span class="px-2 py-1 rounded-md text-xs" [class]="statusClass(inv.status)">
{{ ('status.' + inv.status) | translate }}
</span>
<span class="text-sm">
{{ inv.issueDate | date:'mediumDate' }}
</span>
<strong class="ml-2">
{{ total(inv) | currency: inv.currency:'symbol-narrow' }}
</strong>
</div>
</div>
</li>
}
</ul>
}
</section>5. Update the root component & language switcher 🔗
Wire up the app-wide language controls so every screen (Dashboard, Invoice Edit) updates instantly with ngx-translate. This uses Angular standalone components and a small service to manage runtime i18n.
File: src/app/app.ts
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { LanguageService } from './core/language.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, TranslateModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
private readonly lang = inject(LanguageService);
setLang(l: 'en' | 'fr') { this.lang.use(l); }
}
In app.html, the header displays the translated title and language buttons, while <router-outlet> loads the active view. Each button calls setLang() to toggle between English and French.
<header class="flex items-center gap-3 p-4 border-b">
<h1 class="text-xl font-semibold">{{ 'app.title' | translate }}</h1>
<div class="ml-auto flex items-center gap-2">
<button type="button" class="btn" (click)="setLang('en')">EN</button>
<button type="button" class="btn-secondary" (click)="setLang('fr')">FR</button>
</div>
</header>
<main class="p-4">
<router-outlet></router-outlet>
</main>
The LanguageService registers available locales, remembers the last selected language, and updates the <html lang>attribute. This keeps translations consistent across routes and sessions.
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
type Lang = 'en' | 'fr';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private readonly storageKey = 'lang';
private readonly supported: readonly Lang[] = ['en', 'fr'] as const;
constructor(private t: TranslateService) {}
init(): void {
this.t.addLangs([...this.supported]);
const saved = (localStorage.getItem(this.storageKey) as Lang | null) ?? this.matchNavigator();
this.use(saved);
}
use(lang: string): void {
const chosen: Lang =
(this.supported as readonly string[]).includes(lang as Lang) ? (lang as Lang) : 'en';
localStorage.setItem(this.storageKey, chosen);
document.documentElement.lang = chosen;
this.t.use(chosen);
}
private matchNavigator(): Lang {
const base = (navigator.language || navigator.languages?.[0] || 'en').slice(0, 2) as Lang;
return (this.supported as readonly string[]).includes(base) ? base : 'en';
}
}Together, these three files finalize your localization flow. When the user clicks EN or FR, the entire interface Dashboard, Invoice Edit, and all status labels updates instantly.
6. Add locale-aware formatting 🔗
Next, create a src/app/core/locale-format.service.ts file to handle language-aware number and date formatting across the app. This service listens to the active language from ngx-translate using Angular signals, so all amounts and dates update automatically when users switch languages. It exposes two helpers: currency() for localized prices and dateISO() for readable dates, both used by the dashboard and upcoming invoice views.
import { Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
@Injectable({ providedIn: 'root' })
export class LocaleFormatService {
lang = signal('en');
constructor(private t: TranslateService) {
this.lang.set(this.t.currentLang || 'en');
this.t.onLangChange.subscribe(e => this.lang.set(e.lang));
}
currency(amount: number, currency: string): string {
return new Intl.NumberFormat(this.lang(), { style: 'currency', currency }).format(amount);
}
dateISO(isoDate: string): string {
return new Intl.DateTimeFormat(this.lang(), { dateStyle: 'medium' }).format(new Date(isoDate));
}
}Now update src/app/features/dashboard/dashboard.component.ts to use this service. Import LocaleFormatService, inject it inside the class, and replace the currency and date pipes with the new helper methods. This ensures that totals and issue dates reformat automatically whenever the language changes.
import { LocaleFormatService } from '../../core/locale-format.service';
Inject the service inside the class:
private readonly fmt = inject(LocaleFormatService);
Use this.fmt.currency() and this.fmt.dateISO() to format totals and issue dates:
import { LocaleFormatService } from '../../core/locale-format.service';
private readonly fmt = inject(LocaleFormatService);
{{ fmt.currency(total, inv.currency) }}
{{ fmt.dateISO(inv.issueDate) }}
7. Run & verify 🔗
npm start
- Open http://localhost:4200.
- Click + Sample — a demo invoice appears instantly.
- All text and status labels translate dynamically through ngx-translate, confirming your Angular localization and store setup work together correctly.
- Toggle EN/FR to confirm translations and status labels updates.
3️⃣ Step 3: Refactor the store 🔗
To keep the store maintainable and modular, we’ll extract helper functions and logic into smaller dedicated files. This makes it easier to reuse utilities across the app and keeps the core store focused on state management.
1. Create an ID generator 🔗
Start by creating a small ID generator in src/app/core/utils/id.ts. This helper provides a lightweight, consistent way to create unique identifiers throughout the app.
export const newId = (): string =>
globalThis.crypto?.randomUUID?.() ?? 'id-' + Math.random().toString(36).slice(2);
Next, open src/app/core/stores/invoice.store.ts, import the helper, and remove the inline ID logic.
import { newId } from '../utils/id';
Replace every reference to the old generator with newId(). This simple change ensures that all IDs follow a single standard:
sanitizeItem(...)sanitizeInvoice(...)createDraft(...)newLineItem(...)
2. Set up a shared time utility 🔗
Now let’s move timestamp handling into its own utility. Create src/app/core/utils/time.ts with:
export const isoNow = (): string => new Date().toISOString();
Then remove the old local helper entirely:
// const isoNow = () => new Date().toISOString();
Next, import it into the store and delete the inline version to keep date logic consistent and easy to test.
Now extract all financial calculations into a reusable module.
Create src/app/core/utils/money.ts and add:
import type { LineItem, Invoice } from '../models/invoice.model';
/** Calculate a single line item total (after discount, plus tax). */
export function lineTotal(li: LineItem): number {
const qty = Math.max(0, li.quantity || 0);
const price = Math.max(0, li.unitPrice || 0);
const gross = qty * price;
const discount = li.discountRate ? gross * (li.discountRate / 100) : 0;
const afterDiscount = gross - discount;
const tax = li.taxRate ? afterDiscount * (li.taxRate / 100) : 0;
return +(afterDiscount + tax).toFixed(2);
}
/** Sum all line items for an invoice. */
export function invoiceTotal(inv: Invoice): number {
return +inv.items.reduce((s, li) => s + lineTotal(li), 0).toFixed(2);
}
The store now relies on a shared time utility, keeping the codebase cleaner and easier to maintain.
3. Centralize the store's financial logic 🔗
Let’s clean up the financial logic in the store by moving all money-related calculations into their own utility file. Start by creating src/app/core/utils/money.ts and add the following code:
import type { LineItem, Invoice } from '../models/invoice.model';
/** Calculate a single line item total (after discount, plus tax). */
export function lineTotal(li: LineItem): number {
const qty = Math.max(0, li.quantity || 0);
const price = Math.max(0, li.unitPrice || 0);
const gross = qty * price;
const discount = li.discountRate ? gross * (li.discountRate / 100) : 0;
const afterDiscount = gross - discount;
const tax = li.taxRate ? afterDiscount * (li.taxRate / 100) : 0;
return +(afterDiscount + tax).toFixed(2);
}
/** Sum all line items for an invoice. */
export function invoiceTotal(inv: Invoice): number {
return +inv.items.reduce((s, li) => s + lineTotal(li), 0).toFixed(2);
}Next, open src/app/core/stores/invoice.store.ts and update it to use these new helpers.
Add the following import near the top of the file:
import { lineTotal, invoiceTotal } from '../utils/money';
Then remove the old inline calculation functions entirely:
function lineTotal(li: LineItem): number { /* ... */ }
function invoiceTotal(inv: Invoice): number { /* ... */ }
This refactor keeps your store lean and focused while centralizing all currency and total calculations in a single reusable utility.
4. Set up serialization 🔗
File: src/app/core/persistence/invoice.serialization.ts
import type { Invoice, LineItem } from '../models/invoice.model';
import { newId } from '../utils/id';
import { isoNow } from '../utils/time';
function num(v: unknown, fallback: number): number {
const n = (typeof v === 'number' || typeof v === 'string') ? Number(v) : NaN;
return Number.isFinite(n) ? n : fallback;
}
export function sanitizeItem(raw: unknown): LineItem {
const r = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
return {
id: String(r.id ?? newId()),
description: String(r.description ?? ''),
quantity: num(r.quantity, 1),
unitPrice: num(r.unitPrice, 0),
taxRate: num(r.taxRate, 0),
discountRate: num(r.discountRate, 0),
};
}
const VALID_STATUS = new Set<Invoice['status']>(['draft', 'sent', 'paid']);
export function sanitizeInvoice(raw: unknown): Invoice | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const itemsRaw = Array.isArray(r.items) ? r.items : [];
const items = itemsRaw.map(sanitizeItem);
const status = VALID_STATUS.has(r.status as Invoice['status'])
? (r.status as Invoice['status'])
: 'draft';
return {
id: String(r.id ?? newId()),
number: String(r.number ?? 'INV-XXXX'),
clientName: String(r.clientName ?? ''),
clientEmail: r.clientEmail ? String(r.clientEmail) : undefined,
issueDate: String(r.issueDate ?? new Date().toISOString().slice(0, 10)),
dueDate: r.dueDate ? String(r.dueDate) : undefined,
currency: String(r.currency ?? 'USD'),
items,
notes: r.notes ? String(r.notes) : undefined,
status,
createdAt: String(r.createdAt ?? isoNow()),
updatedAt: String(r.updatedAt ?? isoNow()),
};
}Then update the store to use the serializer. Replace the store file with the version below. Behavior stays the same; the only change is that parsing/validation is delegated to the serializers.
src/app/core/stores/invoice.store.ts
import { Injectable, computed, effect, signal } from '@angular/core';
import { Invoice, InvoiceStatus, LineItem } from '../models/invoice.model';
import { newId } from '../utils/id';
import { isoNow } from '../utils/time';
import { invoiceTotal } from '../utils/money';
import { sanitizeInvoice } from '../persistence/invoice.serialization';
// nstants
const STORAGE_KEY = 'invoice.store.v1';
// load
function load(): Invoice[] {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (!Array.isArray(raw)) return [];
return raw.map(sanitizeInvoice).filter(Boolean) as Invoice[];
} catch {
return [];
}
}
// store
@Injectable({ providedIn: 'root' })
export class InvoiceStore {
private readonly _invoices = signal<Invoice[]>(load());
// throttle persistence to avoid excessive writes
private persistTimer: any = null;
private readonly _persist = effect(() => {
const snapshot = this._invoices();
clearTimeout(this.persistTimer);
this.persistTimer = setTimeout(() => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch {}
}, 120);
});
// precompute totals map for O(1) lookup
private readonly totalsById = computed(() => {
const map = new Map<string, number>();
for (const inv of this._invoices()) map.set(inv.id, invoiceTotal(inv));
return map;
});
// selectors
readonly invoices = this._invoices.asReadonly();
byId = (id: string) => computed(() => this._invoices().find(i => i.id === id) ?? null);
totalAmount = (id: string) => computed(() => this.totalsById().get(id) ?? 0);
totalCount = computed(() => this._invoices().length);
totalByStatus = (status: InvoiceStatus) =>
computed(() => this._invoices().filter(i => i.status === status).length);
// mutations
createDraft(partial?: Partial<Invoice>): Invoice {
const id = newId();
const created = isoNow();
const number = this.nextNumber();
const invoice: Invoice = {
id,
number,
clientName: partial?.clientName ?? '',
clientEmail: partial?.clientEmail,
issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),
dueDate: partial?.dueDate,
currency: partial?.currency ?? 'USD',
items: partial?.items ?? [this.newLineItem()],
notes: partial?.notes,
status: partial?.status ?? 'draft',
createdAt: created,
updatedAt: created,
};
this._invoices.update(arr => [invoice, ...arr]);
return invoice;
}
update(id: string, changes: Partial<Invoice>): void {
this._invoices.update(arr =>
arr.map(inv => (inv.id === id ? { ...inv, ...changes, updatedAt: isoNow() } : inv)),
);
}
remove(id: string): void {
this._invoices.update(arr => arr.filter(inv => inv.id !== id));
}
// line items
newLineItem(): LineItem {
return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };
}
addLineItem(invoiceId: string, li?: Partial<LineItem>): void {
this._invoices.update(arr =>
arr.map(inv =>
inv.id === invoiceId
? {
...inv,
items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items],
updatedAt: isoNow(),
}
: inv,
),
);
}
updateLineItem(invoiceId: string, itemId: string, changes: Partial<LineItem>): void {
this._invoices.update(arr =>
arr.map(inv => {
if (inv.id !== invoiceId) return inv;
const items = inv.items.map(it => (it.id === itemId ? { ...it, ...changes } : it));
return { ...inv, items, updatedAt: isoNow() };
}),
);
}
removeLineItem(invoiceId: string, itemId: string): void {
this._invoices.update(arr =>
arr.map(inv => {
if (inv.id !== invoiceId) return inv;
const items = inv.items.filter(it => it.id !== itemId);
return { ...inv, items, updatedAt: isoNow() };
}),
);
}
setStatus(id: string, status: InvoiceStatus): void {
this.update(id, { status });
}
// utilities
private nextNumber(): string {
const seq = this._invoices().length + 1;
return `INV-${String(seq).padStart(4, '0')}`;
}
}The goal is to keep the store “loose”: state and mutations live in one place, while parsing/validation and core calculations are modular and reusable.
5. Register and connect the new repository 🔗
To finish the persistence setup, register your new repository provider inside the app configuration. Thanks to this, Angular will inject the correct implementation (LocalStorageInvoiceRepository) wherever the INVOICE_REPOSITORY token is requested.
Create src/app/core/persistence/invoice.repository.ts
import { InjectionToken } from '@angular/core';
import type { Invoice } from '../models/invoice.model';
import { sanitizeInvoice } from './invoice.serialization';
export interface InvoiceRepository {
load(): Invoice[];
save(data: Invoice[]): void;
}
export const INVOICE_REPOSITORY = new InjectionToken<InvoiceRepository>('INVOICE_REPOSITORY');
export class LocalStorageInvoiceRepository implements InvoiceRepository {
private readonly KEY = 'invoice.store.v2';
load(): Invoice[] {
try {
const raw = localStorage.getItem(this.KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
// Accept v1 (array) or v2 ({ version, data })
const arr = Array.isArray(parsed)
? parsed
: (parsed && typeof parsed === 'object' && Array.isArray((parsed as any).data))
? (parsed as any).data
: [];
const out: Invoice[] = [];
for (const it of arr) {
const inv = sanitizeInvoice(it);
if (inv) out.push(inv);
}
return out;
} catch {
return [];
}
}
save(data: Invoice[]): void {
try {
const payload = { version: 2, data };
localStorage.setItem(this.KEY, JSON.stringify(payload));
} catch {
// ignore quota/security errors
}
}
}Update src/app/app.config.ts by adding the provider below your existing imports and configuration:
// ...existing imports...
import { INVOICE_REPOSITORY, LocalStorageInvoiceRepository } from './core/persistence/invoice.repository';
export const appConfig: ApplicationConfig = {
providers: [
// ...existing providers...
{ provide: INVOICE_REPOSITORY, useClass: LocalStorageInvoiceRepository },
],
};
This registration connects your dependency injection system to the repository class, allowing your store and future services to interact cleanly with invoice data through a unified interface, rather than hard-coding persistence logic.
Update the store to use the repository src/app/core/stores/invoice.store.ts:
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Invoice, InvoiceStatus, LineItem } from '../models/invoice.model';
import { newId } from '../utils/id';
import { isoNow } from '../utils/time';
import { invoiceTotal } from '../utils/money';
import { INVOICE_REPOSITORY, InvoiceRepository } from '../persistence/invoice.repository';
@Injectable({ providedIn: 'root' })
export class InvoiceStore {
// repo abstraction (can be swapped via DI)
private readonly repo: InvoiceRepository = inject(INVOICE_REPOSITORY);
// state
private readonly _invoices = signal<Invoice[]>(this.repo.load());
// throttle persistence via repo to avoid excessive writes
private persistTimer: any = null;
private readonly _persist = effect(() => {
const snapshot = this._invoices();
clearTimeout(this.persistTimer);
this.persistTimer = setTimeout(() => {
this.repo.save(snapshot);
}, 120);
});
// precompute totals map for O(1) lookup
private readonly totalsById = computed(() => {
const map = new Map<string, number>();
for (const inv of this._invoices()) map.set(inv.id, invoiceTotal(inv));
return map;
});
// selectors
readonly invoices = this._invoices.asReadonly();
byId = (id: string) => computed(() => this._invoices().find(i => i.id === id) ?? null);
totalAmount = (id: string) => computed(() => this.totalsById().get(id) ?? 0);
totalCount = computed(() => this._invoices().length);
totalByStatus = (status: InvoiceStatus) =>
computed(() => this._invoices().filter(i => i.status === status).length);
// mutations
createDraft(partial?: Partial<Invoice>): Invoice {
const id = newId();
const created = isoNow();
const number = this.nextNumber();
const invoice: Invoice = {
id,
number,
clientName: partial?.clientName ?? '',
clientEmail: partial?.clientEmail,
issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),
dueDate: partial?.dueDate,
currency: partial?.currency ?? 'USD',
items: partial?.items ?? [this.newLineItem()],
notes: partial?.notes,
status: partial?.status ?? 'draft',
createdAt: created,
updatedAt: created,
};
this._invoices.update(arr => [invoice, ...arr]);
return invoice;
}
update(id: string, changes: Partial<Invoice>): void {
this._invoices.update(arr =>
arr.map(inv => (inv.id === id ? { ...inv, ...changes, updatedAt: isoNow() } : inv)),
);
}
remove(id: string): void {
this._invoices.update(arr => arr.filter(inv => inv.id !== id));
}
// line items
newLineItem(): LineItem {
return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };
}
addLineItem(invoiceId: string, li?: Partial<LineItem>): void {
this._invoices.update(arr =>
arr.map(inv =>
inv.id === invoiceId
? {
...inv,
items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items],
updatedAt: isoNow(),
}
: inv,
),
);
}
updateLineItem(invoiceId: string, itemId: string, changes: Partial<LineItem>): void {
this._invoices.update(arr =>
arr.map(inv => {
if (inv.id !== invoiceId) return inv;
const items = inv.items.map(it => (it.id === itemId ? { ...it, ...changes } : it));
return { ...inv, items, updatedAt: isoNow() };
}),
);
}
removeLineItem(invoiceId: string, itemId: string): void {
this._invoices.update(arr =>
arr.map(inv => {
if (inv.id !== invoiceId) return inv;
const items = inv.items.filter(it => it.id !== itemId);
return { ...inv, items, updatedAt: isoNow() };
}),
);
}
setStatus(id: string, status: InvoiceStatus): void {
this.update(id, { status });
}
// utilities
private nextNumber(): string {
const seq = this._invoices().length + 1;
return `INV-${String(seq).padStart(4, '0')}`;
}
}Then bind the repository in the app configuration:
import { INVOICE_REPOSITORY, LocalStorageInvoiceRepository } from './core/persistence/invoice.repository';
export const appConfig: ApplicationConfig = {
providers: [
...
// Repository binding
{ provide: INVOICE_REPOSITORY, useClass: LocalStorageInvoiceRepository },
],
};
The store now depends on an abstract InvoiceRepository, LocalStorageInvoiceRepository implements it, and the app config provides the binding so Angular’s DI injects the correct implementation everywhere. This keeps your state layer clean, testable, and ready for future backends.
4️⃣ Step 4: Invoice editor form 🔗
Now it's time to replace the generated TypeScript file with this implementation (strongly-typed Reactive Forms, Tailwind-ready, i18n-friendly, and using your shared money utilities):
src/app/features/invoice-edit/invoice-edit.component.ts
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import {
ReactiveFormsModule,
NonNullableFormBuilder,
FormGroup,
FormControl,
FormArray,
Validators,
} from '@angular/forms';
import { lineTotal, invoiceTotal } from '../../core/utils/money';
// Strongly-typed item group
type ItemControls = {
description: FormControl<string>;
quantity: FormControl<number>;
unitPrice: FormControl<number>;
taxRate: FormControl<number>;
discountRate: FormControl<number>;
};
type ItemGroup = FormGroup<ItemControls>;
// Root form controls
type InvoiceFormControls = {
clientName: FormControl<string>;
clientEmail: FormControl<string | null>;
issueDate: FormControl<string>;
dueDate: FormControl<string | null>;
currency: FormControl<'USD' | 'EUR' | 'XAF'>;
notes: FormControl<string | null>;
items: FormArray<ItemGroup>;
};
type InvoiceForm = FormGroup<InvoiceFormControls>;
@Component({
selector: 'app-invoice-edit',
standalone: true,
imports: [CommonModule, RouterModule, TranslateModule, ReactiveFormsModule],
templateUrl: './invoice-edit.component.html',
styleUrl: './invoice-edit.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceEditComponent {
private fb = inject(NonNullableFormBuilder);
// Build a new item row with typed controls
private createItemGroup(): ItemGroup {
return this.fb.group<ItemControls>({
description: this.fb.control('', { validators: [] }),
quantity: this.fb.control(1, { validators: [Validators.min(0)] }),
unitPrice: this.fb.control(0, { validators: [Validators.min(0)] }),
taxRate: this.fb.control(0, { validators: [Validators.min(0), Validators.max(100)] }),
discountRate: this.fb.control(0, { validators: [Validators.min(0), Validators.max(100)] }),
});
}
// Root form with strict types (non-nullable where appropriate)
form: InvoiceForm = this.fb.group<InvoiceFormControls>({
clientName: this.fb.control('', { validators: [Validators.required] }),
clientEmail: new FormControl<string | null>(null, { nonNullable: false, validators: [Validators.email] }),
issueDate: this.fb.control(new Date().toISOString().slice(0, 10)),
dueDate: new FormControl<string | null>(null, { nonNullable: false }),
currency: this.fb.control<'USD' | 'EUR' | 'XAF'>('USD'),
notes: new FormControl<string | null>(null, { nonNullable: false }),
items: this.fb.array<ItemGroup>([this.createItemGroup()]),
});
// items helpers
get items(): FormArray<ItemGroup> {
return this.form.controls.items;
}
addItem(): void {
this.items.push(this.createItemGroup());
}
removeItem(i: number): void {
this.items.removeAt(i);
}
// totals
lineTotalAt(index: number): number {
const g = this.items.at(index).getRawValue();
return lineTotal({
id: 'tmp',
description: g.description,
quantity: g.quantity,
unitPrice: g.unitPrice,
taxRate: g.taxRate,
discountRate: g.discountRate,
});
}
grandTotal(): number {
const v = this.form.getRawValue();
return invoiceTotal({
id: 'tmp',
number: 'INV-TMP',
clientName: v.clientName,
clientEmail: v.clientEmail ?? undefined,
issueDate: v.issueDate,
dueDate: v.dueDate ?? undefined,
currency: v.currency,
items: v.items.map(it => {
const row = it;
return {
id: 'tmp',
description: row.description,
quantity: row.quantity,
unitPrice: row.unitPrice,
taxRate: row.taxRate,
discountRate: row.discountRate,
};
}),
notes: v.notes ?? undefined,
status: 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
// actions
save(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// For now, just demo; next micro-step will persist via InvoiceStore.
// Typed value:
const value = this.form.getRawValue();
console.log('[invoice-edit] value', value);
alert('Form captured (no persistence yet). Next step: connect to store.');
}
}
Update the src/app/features/invoice-edit/invoice-edit.component.htmltemplate, which lays out the client details, line items, and totals for the Invoice Editor using Tailwind utilities and ReactiveFormsModule bindings. It supports adding/removing items, shows each row’s computed total, and displays the grand total with simple, accessible form controls.
<form class="max-w-4xl mx-auto p-6 space-y-6" [formGroup]="form" (ngSubmit)="save()">
<h2 class="text-2xl font-semibold">{{ 'invoice.edit.title' | translate }}</h2>
<!-- Client block -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.clientName' | translate }}</span>
<input type="text" formControlName="clientName" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.clientEmail' | translate }}</span>
<input type="email" formControlName="clientEmail" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.issueDate' | translate }}</span>
<input type="date" formControlName="issueDate" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.dueDate' | translate }}</span>
<input type="date" formControlName="dueDate" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.currency' | translate }}</span>
<select formControlName="currency" class="rounded-lg border p-2">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="XAF">XAF</option>
</select>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<span class="text-sm text-gray-600">{{ 'invoice.form.notes' | translate }}</span>
<textarea formControlName="notes" rows="3" class="rounded-lg border p-2"></textarea>
</label>
</div>
<!-- Items -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ 'invoice.form.items' | translate }}</h3>
<button type="button" class="btn" (click)="addItem()">{{ 'invoice.actions.addItem' | translate }}</button>
</div>
<div formArrayName="items" class="space-y-2">
<div *ngFor="let g of items.controls; let i = index" [formGroupName]="i"
class="grid grid-cols-12 gap-2 items-center border rounded-lg p-3">
<input class="col-span-5 rounded-lg border p-2" type="text" placeholder="{{ 'invoice.form.item.description' | translate }}"
formControlName="description" />
<input class="col-span-2 rounded-lg border p-2" type="number" min="0" step="1"
formControlName="quantity" placeholder="{{ 'invoice.form.item.quantity' | translate }}" />
<input class="col-span-2 rounded-lg border p-2" type="number" min="0" step="0.01"
formControlName="unitPrice" placeholder="{{ 'invoice.form.item.unitPrice' | translate }}" />
<input class="col-span-1 rounded-lg border p-2" type="number" min="0" max="100" step="0.1"
formControlName="taxRate" placeholder="{{ 'invoice.form.item.taxRate' | translate }}" />
<input class="col-span-1 rounded-lg border p-2" type="number" min="0" max="100" step="0.1"
formControlName="discountRate" placeholder="{{ 'invoice.form.item.discountRate' | translate }}" />
<div class="col-span-12 md:col-span-10 text-sm text-gray-600 md:text-right">
{{ 'invoice.form.item.total' | translate }}:
<strong>{{ lineTotalAt(i) }}</strong>
</div>
<div class="col-span-12 md:col-span-2 flex justify-end">
<button type="button" class="btn-secondary" (click)="removeItem(i)">
{{ 'invoice.actions.removeItem' | translate }}
</button>
</div>
</div>
</div>
</div>
<!-- Totals + actions -->
<div class="flex items-center justify-between border-t pt-4">
<div class="text-lg">
{{ 'invoice.form.total' | translate }}:
<strong>{{ grandTotal() }}</strong>
</div>
<div class="flex gap-2">
<a routerLink="/" class="btn-secondary">{{ 'nav.back' | translate }}</a>
<button type="submit" class="btn">{{ 'invoice.actions.save' | translate }}</button>
</div>
</div>
</form>
Then replace the src/app/features/invoice-edit/invoice-edit.html with the version below. It binds a typed Reactive Form, uses Tailwind for layout, supports add/remove rows, shows each row’s computed total, and displays the grand total.
<form class="max-w-4xl mx-auto p-6 space-y-6" [formGroup]="form" (ngSubmit)="save()">
<h2 class="text-2xl font-semibold">{{ 'invoice.edit.title' | translate }}</h2>
<!-- Client block -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.clientName' | translate }}</span>
<input type="text" formControlName="clientName" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.clientEmail' | translate }}</span>
<input type="email" formControlName="clientEmail" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.issueDate' | translate }}</span>
<input type="date" formControlName="issueDate" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.dueDate' | translate }}</span>
<input type="date" formControlName="dueDate" class="rounded-lg border p-2" />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-gray-600">{{ 'invoice.form.currency' | translate }}</span>
<select formControlName="currency" class="rounded-lg border p-2">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="XAF">XAF</option>
</select>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<span class="text-sm text-gray-600">{{ 'invoice.form.notes' | translate }}</span>
<textarea formControlName="notes" rows="3" class="rounded-lg border p-2"></textarea>
</label>
</div>
<!-- Items -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ 'invoice.form.items' | translate }}</h3>
<button type="button" class="btn" (click)="addItem()">{{ 'invoice.actions.addItem' | translate }}</button>
</div>
<div formArrayName="items" class="space-y-2">
<div *ngFor="let g of items.controls; let i = index" [formGroupName]="i"
class="grid grid-cols-12 gap-2 items-center border rounded-lg p-3">
<input class="col-span-5 rounded-lg border p-2" type="text"
placeholder="{{ 'invoice.form.item.description' | translate }}"
formControlName="description" />
<input class="col-span-2 rounded-lg border p-2" type="number" min="0" step="1"
formControlName="quantity" placeholder="{{ 'invoice.form.item.quantity' | translate }}" />
<input class="col-span-2 rounded-lg border p-2" type="number" min="0" step="0.01"
formControlName="unitPrice" placeholder="{{ 'invoice.form.item.unitPrice' | translate }}" />
<input class="col-span-1 rounded-lg border p-2" type="number" min="0" max="100" step="0.1"
formControlName="taxRate" placeholder="{{ 'invoice.form.item.taxRate' | translate }}" />
<input class="col-span-1 rounded-lg border p-2" type="number" min="0" max="100" step="0.1"
formControlName="discountRate" placeholder="{{ 'invoice.form.item.discountRate' | translate }}" />
<div class="col-span-12 md:col-span-10 text-sm text-gray-600 md:text-right">
{{ 'invoice.form.item.total' | translate }}:
<strong>{{ lineTotalAt(i) }}</strong>
</div>
<div class="col-span-12 md:col-span-2 flex justify-end">
<button type="button" class="btn-secondary" (click)="removeItem(i)">
{{ 'invoice.actions.removeItem' | translate }}
</button>
</div>
</div>
</div>
</div>
<!-- Totals + actions -->
<div class="flex items-center justify-between border-t pt-4">
<div class="text-lg">
{{ 'invoice.form.total' | translate }}:
<strong>{{ grandTotal() }}</strong>
</div>
<div class="flex gap-2">
<a routerLink="/" class="btn-secondary">{{ 'nav.back' | translate }}</a>
<button type="submit" class="btn">{{ 'invoice.actions.save' | translate }}</button>
</div>
</div>
</form>
Don’t forget to update the translations:
{
"invoice": {
"form": {
"clientName": "Client name",
"clientEmail": "Client email",
"issueDate": "Issue date",
"dueDate": "Due date",
"currency": "Currency",
"notes": "Notes",
"items": "Items",
"item": {
"description": "Description",
"quantity": "Qty",
"unitPrice": "Unit price",
"taxRate": "Tax %",
"discountRate": "Discount %",
"total": "Line total"
},
"total": "Total"
},
"actions": {
"addItem": "Add item",
"removeItem": "Remove",
"save": "Save"
}
}
}```tsx
{
"invoice": {
"form": {
"clientName": "Nom du client",
"clientEmail": "Email du client",
"issueDate": "Date d'émission",
"dueDate": "Date d'échéance",
"currency": "Devise",
"notes": "Notes",
"items": "Articles",
"item": {
"description": "Description",
"quantity": "Qté",
"unitPrice": "Prix unitaire",
"taxRate": "TVA %",
"discountRate": "Remise %",
"total": "Total ligne"
},
"total": "Total"
},
"actions": {
"addItem": "Ajouter un article",
"removeItem": "Supprimer",
"save": "Enregistrer"
}
}
}
```Now wire the Invoice Editor to the store, create on /invoice/new, update on /invoice/:id.
Only one file changes. The editor now loads an existing invoice (when :id is present), patches the form, and saves either a new draft or an update to the store.
src/app/features/invoice-edit/invoice-edit.component.ts
...
import { lineTotal, invoiceTotal } from '../../core/utils/money';
import { InvoiceStore } from '../../core/stores/invoice.store';
import type { Invoice } from '../../core/models/invoice.model';
....
@Component({
selector: 'app-invoice-edit',
standalone: true,
imports: [CommonModule, RouterModule, TranslateModule, ReactiveFormsModule],
templateUrl: './invoice-edit.html',
styleUrl: './invoice-edit.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceEditComponent {
...
private route = inject(ActivatedRoute);
private store = inject(InvoiceStore);
// when editing, keep refs to id + current invoice
private editingId: string | null = null;
private current: Invoice | null = null;
...
constructor() {
// detect /invoice/new vs /invoice/:id and hydrate if editing
this.route.paramMap.subscribe(p => {
const id = p.get('id');
this.editingId = id;
if (id) {
const inv = this.store.byId(id)();
this.current = inv ?? null;
if (inv) this.setFormFromInvoice(inv);
} else {
this.current = null; // creating new
}
});
}
...
// ---------- hydrate form for edit ----------
private setFormFromInvoice(inv: Invoice): void {
this.form.patchValue({
clientName: inv.clientName,
clientEmail: inv.clientEmail ?? null,
issueDate: inv.issueDate,
dueDate: inv.dueDate ?? null,
currency: inv.currency,
notes: inv.notes ?? null,
});
this.items.clear();
inv.items.forEach(it => {
const g = this.createItemGroup();
g.patchValue({
description: it.description,
quantity: it.quantity,
unitPrice: it.unitPrice,
taxRate: it.taxRate ?? 0,
discountRate: it.discountRate ?? 0,
});
this.items.push(g);
});
}
// replace the previous save() placeholder with create/update via store
save(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const v = this.form.getRawValue();
// rebuild items; preserve existing IDs when editing
const items = v.items.map((row, idx) => ({
id: this.current?.items[idx]?.id ?? this.store.newLineItem().id,
description: row.description,
quantity: row.quantity,
unitPrice: row.unitPrice,
taxRate: row.taxRate,
discountRate: row.discountRate,
}));
if (this.editingId) {
this.store.update(this.editingId, {
clientName: v.clientName,
clientEmail: v.clientEmail ?? undefined,
issueDate: v.issueDate,
dueDate: v.dueDate ?? undefined,
currency: v.currency,
notes: v.notes ?? undefined,
items,
});
} else {
this.store.createDraft({
clientName: v.clientName,
clientEmail: v.clientEmail ?? undefined,
issueDate: v.issueDate,
dueDate: v.dueDate ?? undefined,
currency: v.currency,
notes: v.notes ?? undefined,
items,
status: 'draft',
});
}
}
}That’s all you need to switch between create (/invoice/new) and edit (/invoice/:id) with the store, without dumping the whole file.
Let’s make rows editable and add inline actions.
1. Make invoice number a link 🔗
src/app/features/dashboard/dashboard.component.html
- <div class="font-semibold">{{ inv.number }}</div>
+ <a [routerLink]="['/invoice', inv.id]" class="font-semibold hover:underline">
+ {{ inv.number }}
+ </a>
2. Add row actions (Edit / Mark Sent / Mark Paid / Delete) 🔗
Go tosrc/app/features/dashboard/dashboard.component.html inside the same @for row and append this to the right-side info block (after total):
<!-- actions -->
<div class="flex items-center gap-2 ml-4">
<a [routerLink]="['/invoice', inv.id]" class="btn-secondary">
{{ 'invoice.actions.edit' | translate }}
</a>
@if (inv.status === 'draft') {
<button type="button" class="btn-secondary" (click)="markAsSent(inv.id)">
{{ 'invoice.actions.markSent' | translate }}
</button>
} @else if (inv.status === 'sent') {
<button type="button" class="btn-secondary" (click)="markAsPaid(inv.id)">
{{ 'invoice.actions.markPaid' | translate }}
</button>
}
<button type="button" class="btn-secondary" (click)="remove(inv.id)">
{{ 'invoice.actions.delete' | translate }}
</button>
</div>3. Add the handlers 🔗
Add these methods inside the DashboardComponent class src/app/features/dashboard/dashboard.ts.
markAsSent(id: string): void {
this.store.setStatus(id, 'sent');
}
markAsPaid(id: string): void {
this.store.setStatus(id, 'paid');
}
remove(id: string): void {
if (confirm('Delete this invoice?')) {
this.store.remove(id);
}
}
4. Update strings 🔗
public/assets/i18n/en.json
{
"invoice": {
"actions": {
"edit": "Edit",
"markSent": "Mark as sent",
"markPaid": "Mark as paid",
"delete": "Delete"
}
}
}
public/assets/i18n/fr.json
{
"invoice": {
"actions": {
"edit": "Modifier",
"markSent": "Marquer comme envoyée",
"markPaid": "Marquer comme payée",
"delete": "Supprimer"
}
}
}
Now the invoice ID are clickable, rows have clear actions, and status transitions happen with one click, all persisted via your repository-backed store. To finish off this step, run a quick check with npm start to test that the edit, language switch, invoice status and delete operations work well.
Let’s enable PWA support cleanly in the next step.
5️⃣ Step 5: Feature core-PWA scaffold 🔗
We’ll enable installable, offline-first behavior and lay the groundwork for small UX helpers (online/offline signal and “Install app” prompt). The idea is to keep it minimal and production-ready.
1. Install PWA support 🔗
Run the Angular schematic via your project script:
npm run ng -- add @angular/pwa
This adds @angular/service-worker, creates ngsw-config.json, drops public/manifest.webmanifest and icons, and wires the worker.
2. Test in production mode 🔗
Build and serve the production output so the Service Worker can activate:
npm run build -- --configuration=production
npx http-server ./dist/invoice-pwa/browser -p 4200 -c-1
Open http://localhost:4200. In DevTools > Application > Service Workers, confirm ngsw-worker.js is activated. Toggle Offline in the Network tab and reload; the app should still load.
3. Create a tiny PWA service 🔗
This service exposes three simple signals you can bind to the UI: canInstall (show an “Install app” button), isOnline(online/offline badge), and isStandalone (running as an installed PWA). It also provides an install() method that triggers the browser’s install prompt.
File: src/app/core/pwa/pwa.service.ts
import { Injectable, signal, computed } from '@angular/core';
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
};
@Injectable({ providedIn: 'root' })
export class PwaService {
private deferred: BeforeInstallPromptEvent | null = null;
// shows the “Install” button when true
readonly canInstall = signal(false);
// are we already installed?
readonly isStandalone = computed(() => {
// iOS Safari
const iosStandalone = (navigator as any).standalone === true;
// All modern browsers
const displayModeStandalone = window.matchMedia?.('(display-mode: standalone)').matches;
return iosStandalone || displayModeStandalone;
});
constructor() {
// Fired when the browser thinks the app is installable
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault(); // don't show the mini-infobar
this.deferred = e as BeforeInstallPromptEvent;
this.canInstall.set(!this.isStandalone());
});
// Fired after a successful install
window.addEventListener('appinstalled', () => {
this.deferred = null;
this.canInstall.set(false);
});
}
async promptInstall(): Promise<void> {
if (!this.deferred) return;
this.canInstall.set(false);
await this.deferred.prompt();
try {
await this.deferred.userChoice; // optional: inspect outcome
} finally {
this.deferred = null;
}
}
}4. Update the component class 🔗
File: src/app/app.ts
...
import { PwaService } from './core/pwa/pwa.service';
...
export class App {
private readonly lang = inject(LanguageService);
readonly pwa = inject(PwaService);
setLang(l: 'en' | 'fr') {
this.lang.use(l);
}
install() {
this.pwa.promptInstall();
}
}
5. Add the button to the template 🔗
Add the install button next to EN/FR in src/app/app.html.
<header class="flex items-center gap-3 p-4 border-b">
<h1 class="text-xl font-semibold">{{ 'app.title' | translate }}</h1>
<div class="ml-auto flex items-center gap-2">
<!-- Install button appears only when available and not already installed -->
@if (pwa.canInstall() && !pwa.isStandalone()) {
<button type="button" class="btn" (click)="install()">
{{ 'app.install' | translate }}
</button>
}
...
</div>
</header>Add translation key on public/assets/i18n/en.json:
{
"app": {
"install": "Install app"
}
}
public/assets/i18n/fr.json
{
"app": {
"install": "Installer l’application"
}
}
Now test the button. Make sure you’re on the production build served over HTTP(s).
npm run build -- --configuration=production
npx http-server ./dist/invoice-pwa/browser -p 4200 -c-1
- Open
http://localhost:4200. - You should see the Install app button when:
- Not already installed.
- Browser deems it installable (has manifest, SW active, visited at least once).
3. Click Install app > the native install dialog appears.
4. After installing, the button disappears.
6. Add connectivity toasts (offline & back online) 🔗
Here you’ll display a small banner when the app goes offline and a quick green flash when it comes back online. This improves UX by giving clear, instant feedback about network status.
It only takes four lightweight parts: a network status service, a toast banner, a quick wire-up in the app shell, and two i18n entries. Follow these steps:
- Create the network service
Tracks online/offline state using signals, and flashes a short Back online message after reconnection, too.
File: src/app/core/network/network.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class NetworkService {
// true = online, false = offline
readonly online = signal<boolean>(navigator.onLine);
// flash "Back online" for a few seconds after reconnection
readonly flashOnline = signal<boolean>(false);
private timer: any = null;
constructor() {
window.addEventListener('online', () => {
this.online.set(true);
this.flashOnline.set(true);
clearTimeout(this.timer);
this.timer = setTimeout(() => this.flashOnline.set(false), 3000);
});
window.addEventListener('offline', () => {
this.online.set(false);
this.flashOnline.set(false);
clearTimeout(this.timer);
});
}
}- Expose the service in the app shell
Expose the network status in the app shell so the template (and the offline toast) can read it.
File: src/app/app.ts
...
import { PwaService } from './core/pwa/pwa.service';
import { NetworkService } from './core/network/network.service';
...
export class App {
...
readonly net = inject(NetworkService);
...
}- Add toast banners to the template
Place the toaster exactly at the end of app.html after </main>:
<!-- Offline / Online toasts -->
@if (!net.online()) {
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 bg-red-600 text-white px-4 py-2 rounded-lg shadow
flex items-center gap-2 z-50"
role="status" aria-live="polite">
{{ 'app.offline' | translate }}
</div>
}
@if (net.flashOnline()) {
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded-lg shadow
flex items-center gap-2 z-50"
role="status" aria-live="polite">
{{ 'app.backOnline' | translate }}
</div>
}Like always, add the translations:
public/assets/i18n/en.json
{
"app": {
"offline": "You’re offline. Some features may be unavailable.",
"backOnline": "Back online"
}
}
public/assets/i18n/fr.json
{
"app": {
"offline": "Vous êtes hors ligne. Certaines fonctionnalités peuvent être indisponibles.",
"backOnline": "De retour en ligne"
}
}
Now let's test:
- In DevTools > Network > set throttling to Offline > the red toast appears.
- If you switch back to Online > a green “Back online” toast flashes for ~3s.
Next, we’ll extend this feature core by adding new version available, search, filter, sort, and CSV export/import logic to the dashboard.
7. New version available toast 🔗
Here you will show a small toast when a fresh build is ready. One tap, it reloads into the new version. Note that this runs only on production builds.
- Create the update service
src/app/core/pwa/update.service.ts
import { Injectable, signal } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
@Injectable({ providedIn: 'root' })
export class UpdateService {
/** Show the “Update available” toast */
readonly updateAvailable = signal<boolean>(false);
/** Disable the button and show progress text while activating */
readonly installing = signal<boolean>(false);
constructor(private sw: SwUpdate) {
// SW is only enabled in production; guard in dev
if (!this.sw.isEnabled) return;
// Listen for version events; when a new version is ready, show the toast
this.sw.versionUpdates.subscribe(evt => {
if ((evt as any).type === 'VERSION_READY') {
this.updateAvailable.set(true);
}
// Optional: handle failures
if ((evt as any).type === 'VERSION_INSTALLATION_FAILED') {
// Could log or surface a subtle warning if you want
}
});
// Also check on app focus (useful if the tab was idle)
window.addEventListener('focus', () => {
this.checkForUpdates();
});
// Initial check shortly after boot
setTimeout(() => this.checkForUpdates(), 5_000);
// Periodic check every 6 hours
setInterval(() => this.checkForUpdates(), 6 * 60 * 60 * 1000);
}
async checkForUpdates(): Promise<void> {
if (!this.sw.isEnabled) return;
try {
await this.sw.checkForUpdate();
} catch {
// ignore network errors
}
}
/** Activate the new version and reload the app */
async activateAndReload(): Promise<void> {
if (!this.sw.isEnabled) return;
this.installing.set(true);
try {
await this.sw.activateUpdate();
} finally {
// Reload to load the fresh version (even if activateUpdate failed, reload is harmless)
document.location.reload();
}
}
}- Expose it in the root component
Inject the service and add a tiny handler for the button.
File: src/app/app.ts
...
import { UpdateService } from './core/pwa/update.service';
...
export class App {
readonly upd = inject(UpdateService);
reloadApp() { this.upd.activateAndReload(); }
}- Add the update toast
Place the code below at the very end of the template, after your offline/online toasts.
File: src/app/app.html
<!-- Update available toast -->
@if (upd.updateAvailable()) {
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white px-4 py-2 rounded-lg shadow
flex items-center gap-3 z-50"
role="status" aria-live="polite">
{{ 'app.updateAvailable' | translate }}
<button class="btn ml-2"
[disabled]="upd.installing()"
(click)="reloadApp()">
@if (upd.installing()) {
{{ 'app.updating' | translate }}
} @else {
{{ 'app.reload' | translate }}
}
</button>
</div>
}And don’t forget to update your translations!
File: public/assets/i18n/en.json
{
"app": {
"updateAvailable": "A new version is available.",
"reload": "Reload",
"updating": "Updating…"
}
}
File: public/assets/i18n/fr.json
{
"app": {
"updateAvailable": "Une nouvelle version est disponible.",
"reload": "Recharger",
"updating": "Mise à jour…"
}
}
Build, serve, and then watch the toast appear.
# Build v1
npm run build -- --configuration=production
npx http-server ./dist/invoice-pwa/browser -p 4200 -c-1
When the app checks for updates on focus after approximately 5s periodically, the blue toast will show. Click Reload to activate and jump to the new version.
Up to now, the app allows you to create, edit, and manage invoices, but there’s no dedicated way to view a finalized invoice in a clean, print-friendly layout. Users need a professional, read-only page they can show clients, download as PDF, or print directly from the browser.
So now, you will create the invoice view to display the selected invoice with proper formatting, localized currency and date styles, as well as optional notes.
🧾 Generate the view invoice component 🔗
In this section you will create the invoice view to see the detail of an individual invoice when a user clicks on a specific view.
npm run ng -- g c features/invoice-view/invoice-view --standalone --flat --skip-tests
Add its route: src/app/app.routes.ts.
{
path: 'invoice/:id/view',
loadComponent: () =>
import('./features/invoice-view/invoice-view').then(m => m.InvoiceViewComponent),
},
Create the View component (read-only, print-ready):
src/app/features/invoice-view/invoice-view.component.ts
import { Component, ChangeDetectionStrategy, inject, OnInit, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { InvoiceStore } from '../../core/stores/invoice.store';
import type { Invoice, LineItem } from '../../core/models/invoice.model';
import { LocaleFormatService } from '../../core/locale-format.service';
import { lineTotal, invoiceTotal } from '../../core/utils/money';
@Component({
selector: 'app-invoice-view',
standalone: true,
imports: [CommonModule, RouterModule, TranslateModule],
templateUrl: './invoice-view.component.html',
styleUrl: './invoice-view.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceViewComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(InvoiceStore);
private readonly fmt = inject(LocaleFormatService);
// signal to the selected invoice (reactive)
invoice = computed<Invoice | null>(() => {
const id = this.route.snapshot.paramMap.get('id');
return id ? this.store.byId(id)() : null;
});
ngOnInit(): void {
if (!this.invoice()) this.router.navigateByUrl('/');
}
// formatting helpers
date(d: string) { return this.fmt.dateISO(d); }
money(amount: number, cur: string) { return this.fmt.currency(amount, cur); }
lineTotal(li: LineItem) { return lineTotal(li); }
grand(inv: Invoice) { return invoiceTotal(inv); }
print(): void { window.print(); }
back(): void { this.router.navigateByUrl('/'); }
}Add this code in src/app/features/invoice-view/invoice-view.component.html.
<!-- Controls (hidden in print) -->
<div class="no-print max-w-4xl mx-auto p-4 flex items-center gap-2">
<a routerLink="/" class="btn-secondary">{{ 'nav.back' | translate }}</a>
<button class="btn" type="button" (click)="print()">
{{ 'invoice.view.print' | translate }}
</button>
</div>
<!-- Printable page -->
<section class="sheet max-w-4xl mx-auto bg-white text-gray-900 shadow p-8">
@if (invoice(); as inv) {
<header class="flex items-start justify-between mb-8">
<div>
<h1 class="text-2xl font-semibold">{{ 'invoice.view.title' | translate }} {{ inv.number }}</h1>
<div class="text-sm text-gray-600">
<div>{{ 'invoice.view.issueDate' | translate }}: {{ date(inv.issueDate) }}</div>
@if (inv.dueDate) {
<div>{{ 'invoice.view.dueDate' | translate }}: {{ date(inv.dueDate!) }}</div>
}
<div>{{ 'invoice.view.status' | translate }}: {{ ('status.' + inv.status) | translate }}</div>
</div>
</div>
<!-- Minimal “Bill To” block -->
<div class="text-right">
<div class="uppercase text-xs text-gray-500">{{ 'invoice.view.billTo' | translate }}</div>
<div class="font-medium">{{ inv.clientName || '—' }}</div>
@if (inv.clientEmail) { <div class="text-sm text-gray-600">{{ inv.clientEmail }}</div> }
</div>
</header>
<!-- Items table -->
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-gray-300 text-left">
<th class="py-2 pr-2 w-7/12">{{ 'invoice.form.item.description' | translate }}</th>
<th class="py-2 pr-2 w-1/12 text-right">{{ 'invoice.form.item.quantity' | translate }}</th>
<th class="py-2 pr-2 w-2/12 text-right">{{ 'invoice.form.item.unitPrice' | translate }}</th>
<th class="py-2 pr-2 w-1/12 text-right">{{ 'invoice.form.item.taxRate' | translate }}</th>
<th class="py-2 pl-2 w-2/12 text-right">{{ 'invoice.form.item.total' | translate }}</th>
</tr>
</thead>
<tbody>
@for (it of inv.items; track it.id) {
<tr class="border-b border-gray-100">
<td class="py-2 pr-2 align-top">
<div class="font-medium">{{ it.description || '—' }}</div>
@if (it.discountRate && it.discountRate > 0) {
<div class="text-xs text-gray-600">
{{ 'invoice.view.discount' | translate }}: {{ it.discountRate }}%
</div>
}
</td>
<td class="py-2 pr-2 text-right align-top">{{ it.quantity }}</td>
<td class="py-2 pr-2 text-right align-top">{{ money(it.unitPrice, inv.currency) }}</td>
<td class="py-2 pr-2 text-right align-top">{{ it.taxRate || 0 }}%</td>
<td class="py-2 pl-2 text-right align-top">
{{ money(lineTotal(it), inv.currency) }}
</td>
</tr>
}
</tbody>
</table>
<!-- Notes + totals -->
<div class="flex flex-col md:flex-row gap-6 mt-6">
<div class="md:w-1/2">
@if (inv.notes) {
<div class="uppercase text-xs text-gray-500 mb-1">{{ 'invoice.view.notes' | translate }}</div>
<div class="whitespace-pre-line">{{ inv.notes }}</div>
}
</div>
<div class="md:w-1/2">
<div class="flex justify-between text-lg font-medium border-t pt-4">
<span>{{ 'invoice.form.total' | translate }}</span>
<span>{{ money(grand(inv), inv.currency) }}</span>
</div>
</div>
</div>
}
</section>And add print style so the PDF looks good:
src/app/features/invoice-view/invoice-view.component.scss
/* Hide elements with .no-print when printing */
@media print {
.no-print { display: none !important; }
html, body { background: white !important; }
.sheet {
box-shadow: none !important;
margin: 0 !important;
width: auto !important;
padding: 0.5in !important; /* print margins */
}
}
/* On screen */
.sheet { border-radius: 0.75rem; }
Now, update your translations:
public/assets/i18n/en.json
{
"invoice": {
"view": {
"title": "Invoice",
"issueDate": "Issue date",
"dueDate": "Due date",
"status": "Status",
"billTo": "Bill to",
"notes": "Notes",
"print": "Print / Save as PDF",
"discount": "Discount"
}
}
}
public/assets/i18n/fr.json
{
"invoice": {
"view": {
"title": "Facture",
"issueDate": "Date d'émission",
"dueDate": "Date d'échéance",
"status": "Statut",
"billTo": "Destinataire",
"notes": "Notes",
"print": "Imprimer / Enregistrer en PDF",
"discount": "Remise"
}
}
}
Then add the“View” action on each row in src/app/features/dashboard/dashboard.ts.
view(inv: Invoice) {
this.router.navigate(['/invoice', inv.id, 'view']);
}
Drop the button in your actions block in src/app/features/dashboard/dashboard.html.
<button type="button" class="btn-secondary" (click)="view(inv)">
{{ 'invoice.actions.view' | translate }}
</button>
And translations in public/assets/i18n/en.json...
{ "invoice": { "actions": { "view": "View" } } }
...and public/assets/i18n/fr.json.
{ "invoice": { "actions": { "view": "Voir" } } }
Finally, run a quick test by starting the app with npm start, then open /invoice/:id/view to verify that the page displays and prints cleanly. From the dashboard, click View. It should open the same screen where the Print / Save as PDF action is working properly.
Our last step today will be adding Search / Filter / Sort to the dashboard.
🔎 Search, filter and sort feature 🔗
1. Dashboard component 🔗
In your src/app/features/dashboard/dashboard.component.ts:
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
import { RouterModule, Router } from '@angular/router';
Inject Router:
private readonly router = inject(Router);
Add UI state signals for search text, status, sort:
type StatusFilter = 'all' | 'draft' | 'sent' | 'paid';
type SortKey = 'dateDesc' | 'dateAsc' | 'amountDesc' | 'amountAsc';
readonly query = signal<string>('');
readonly status = signal<StatusFilter>('all');
readonly sort = signal<SortKey>('dateDesc');
Derived view:
readonly view = computed(() => {
const q = this.query().trim().toLowerCase();
const st = this.status();
const sortKey = this.sort();
let rows = this.invoices();
if (st !== 'all') rows = rows.filter(r => r.status === st);
if (q) {
rows = rows.filter(r =>
r.number.toLowerCase().includes(q) ||
r.clientName.toLowerCase().includes(q) ||
(r.clientEmail?.toLowerCase().includes(q) ?? false) ||
(r.notes?.toLowerCase().includes(q) ?? false)
);
}
const byAmount = (r: Invoice) => invoiceTotal(r);
const byDate = (r: Invoice) => r.issueDate; // ISO sorts lexicographically
rows = [...rows];
switch (sortKey) {
case 'amountDesc': rows.sort((a,b) => byAmount(b) - byAmount(a)); break;
case 'amountAsc': rows.sort((a,b) => byAmount(a) - byAmount(b)); break;
case 'dateAsc': rows.sort((a,b) => byDate(a).localeCompare(byDate(b))); break;
case 'dateDesc':
default: rows.sort((a,b) => byDate(b).localeCompare(byDate(a))); break;
}
return rows;
});Add row action handlers:
viewInvoice(inv: Invoice) { this.router.navigate(['/invoice', inv.id, 'view']); }
edit(inv: Invoice) { this.router.navigate(['/invoice', inv.id]); }
markSent(inv: Invoice) { if (inv.status !== 'sent') this.store.setStatus(inv.id, 'sent'); }
markPaid(inv: Invoice) { if (inv.status !== 'paid') this.store.setStatus(inv.id, 'paid'); }
remove(inv: Invoice) { if (confirm(`Delete ${inv.number}? This cannot be undone.`)) this.store.remove(inv.id); }
// top-bar inputs → signals
onSearch(v: string) { this.query.set(v); }
onStatusChange(v: string) { this.status.set((v as StatusFilter) || 'all'); }
onSortChange(v: string) { this.sort.set((v as SortKey) || 'dateDesc'); }Then update the dashboard template on src/app/features/dashboard/dashboard.html.
<header class="flex flex-col gap-3 md:flex-row md:items-center">
<h2 class="text-2xl font-semibold">{{ 'dashboard.title' | translate }}</h2>
<div class="md:ml-auto grid grid-cols-1 md:grid-cols-3 gap-2 items-center">
<!-- Search -->
<input
#q type="search"
class="rounded-lg border p-2"
[value]="query()"
(input)="onSearch(q.value)"
[placeholder]="'dashboard.filters.searchPlaceholder' | translate" />
<!-- Status -->
<select
#statusSel class="rounded-lg border p-2"
[value]="status()"
(change)="onStatusChange(statusSel.value)">
<option value="all">{{ 'status.all' | translate }}</option>
<option value="draft">{{ 'status.draft' | translate }}</option>
<option value="sent">{{ 'status.sent' | translate }}</option>
<option value="paid">{{ 'status.paid' | translate }}</option>
</select>
<!-- Sort -->
<select
#sortSel class="rounded-lg border p-2"
[value]="sort()"
(change)="onSortChange(sortSel.value)">
<option value="dateDesc">{{ 'dashboard.filters.sort.dateDesc' | translate }}</option>
<option value="dateAsc">{{ 'dashboard.filters.sort.dateAsc' | translate }}</option>
<option value="amountDesc">{{ 'dashboard.filters.sort.amountDesc' | translate }}</option>
<option value="amountAsc">{{ 'dashboard.filters.sort.amountAsc' | translate }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<a routerLink="/invoice/new" class="btn">{{ 'invoice.actions.create' | translate }}</a>
<button class="btn-secondary" type="button" (click)="createSample()">+ Sample</button>
</div>
</header>Insert the result count + empty states above your list:
@if (view().length > 0) {
<div class="text-sm text-gray-500">
{{ 'dashboard.resultsPlural' | translate : { count: view().length } }}
@if (query().length) { — {{ 'dashboard.searchTerm' | translate }}: “{{ query() }}” }
</div>
}
@if (invoices().length === 0) {
<div class="rounded-xl border border-dashed p-8 text-gray-600">
{{ 'dashboard.empty' | translate }}
</div>
} @else if (view().length === 0) {
<div class="rounded-xl border border-dashed p-8 text-gray-700 space-y-1">
<div class="font-semibold">{{ 'dashboard.noResultsTitle' | translate }}</div>
<div>{{ 'dashboard.noResultsBody' | translate }}</div>
@if (query().length) {
<div class="text-sm text-gray-500">
{{ 'dashboard.searchTerm' | translate }}: “{{ query() }}”
</div>
}
</div>
}Use the filtered list (view()) and updated actions inside your existing list:
<ul class="space-y-3">
@for (inv of view(); track inv.id) {
<!-- … existing row header with number/client/date/total … -->
<div class="mt-3 flex items-center gap-2">
<button type="button" class="btn-secondary" (click)="viewInvoice(inv)">
{{ 'invoice.actions.view' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="edit(inv)">
{{ 'invoice.actions.edit' | translate }}
</button>
@if (inv.status !== 'sent') {
<button type="button" class="btn-secondary" (click)="markSent(inv)">
{{ 'invoice.actions.markSent' | translate }}
</button>
}
@if (inv.status !== 'paid') {
<button type="button" class="btn-secondary" (click)="markPaid(inv)">
{{ 'invoice.actions.markPaid' | translate }}
</button>
}
<buttontype="button"
class="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-400/40"
(click)="remove(inv)">
{{ 'invoice.actions.delete' | translate }}
</button>
</div>
}
</ul>
Then update your translations:
public/assets/i18n/en.json
{
"status": {
"all": "All",
"draft": "Draft",
"sent": "Sent",
"paid": "Paid"
},
"dashboard": {
"resultsPlural": "{{count}} result(s)",
"noResultsTitle": "Invoice not found",
"noResultsBody": "No invoices match your search or filters.",
"searchTerm": "Search",
"filters": {
"searchPlaceholder": "Search by number, client, email, notes…",
"sort": {
"dateDesc": "Newest first",
"dateAsc": "Oldest first",
"amountDesc": "Amount: high → low",
"amountAsc": "Amount: low → high"
}
}
}
}
public/assets/i18n/fr.json
{
"status": {
"all": "Tous",
"draft": "Brouillon",
"sent": "Envoyée",
"paid": "Payée"
},
"dashboard": {
"resultsPlural": "{{count}} résultat(s)",
"noResultsTitle": "Facture introuvable",
"noResultsBody": "Aucune facture ne correspond à votre recherche ou à vos filtres.",
"searchTerm": "Recherche",
"filters": {
"searchPlaceholder": "Rechercher par numéro, client, email, notes…",
"sort": {
"dateDesc": "Plus récentes d'abord",
"dateAsc": "Plus anciennes d'abord",
"amountDesc": "Montant : élevé → faible",
"amountAsc": "Montant : faible → élevé"
}
}
}
}
Try typing in the search box and observe the list updates instantly! Play with filters or sorting to see invoices reshuffle, then clear everything to get back to your cozy “Newest first” view.
2. Add an export method 🔗
Add the code blow inside the InvoiceStore class in src/app/core/stores/invoice.store.ts.
/** ---- backup/export ---- */
exportJSON(pretty = true): string {
const payload = { version: 2, data: this._invoices() };
return JSON.stringify(payload, null, pretty ? 2 : 0);
}
Add this helper in src/app/core/utils/files.ts:
export function downloadText(
filename: string,
text: string,
mime = 'application/json;charset=utf-8'
) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
Next, add the import at the top and then the method inside the class in src/app/features/dashboard/dashboard.component.ts:
// add import
import { downloadText } from '../../core/utils/files';
// inside DashboardComponent
exportBackup() {
const json = this.store.exportJSON(true);
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); // YYYY-MM-DD-HH-MM-SS
downloadText(`invoices-backup-${stamp}.json`, json);
}
Add now the export button in src/app/features/dashboard/dashboard.component.html:
<button class="btn-secondary" type="button" (click)="exportBackup()">
{{ 'dashboard.actions.export' | translate }}
</button>
public/assets/i18n/en.json
{
"dashboard": {
"actions": {
"export": "Exporter en JSON"
}
}
}
public/assets/i18n/fr.json
{
"dashboard": {
"actions": {
"export": "Export JSON"
}
}
}
3. Add Import JSON 🔗
Here, add a simple way for users to restore saved invoices by importing their JSON backups. They can either replace all data or merge it with existing invoices
Add these inside the InvoiceStore class in src/app/core/stores/invoice.store.ts:
/** ---- restore/import helpers ---- */
setAll(list: Invoice[]): void {
this._invoices.set([...list]);
}
mergeAll(list: Invoice[]): void {
const map = new Map<string, Invoice>();
for (const inv of this._invoices()) map.set(inv.id, inv);
for (const inv of list) map.set(inv.id, inv); // imported wins on id collision
this._invoices.set([...map.values()]);
}
The setAll replaces everything in one go; mergeAll keeps existing invoices and lets imported ones win when IDs collide.
Add these imports at the top alongside your existing ones in src/app/features/dashboard/dashboard.component.ts:
import { ViewChild, ElementRef } from '@angular/core';
import { sanitizeInvoice } from '../../core/persistence/invoice.serialization';
Add a reference to the hidden input inside the class:
@ViewChild('importInput') importInput?: ElementRef<HTMLInputElement>;
Include these two small methods with your other actions:
triggerImport(el: HTMLInputElement) {
el.value = ''; // allow re-selecting the same file
el.click();
}
async handleImport(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
try {
const text = await file.text();
const parsed: unknown = JSON.parse(text);
// Accept v1 (array) or v2 ({version, data: []})
const rawList: unknown[] =
Array.isArray(parsed)
? parsed
: (parsed && typeof parsed === 'object' && Array.isArray((parsed as any)['data']))
? (parsed as any)['data']
: [];
const imported = rawList
.map(sanitizeInvoice)
.filter((x): x is NonNullable<ReturnType<typeof sanitizeInvoice>> => !!x);
if (imported.length === 0) {
alert('Import failed: no valid invoices found in file.');
return;
}
const replace = confirm(
`Found ${imported.length} invoices.\\n\\nOK = Replace ALL existing invoices\\nCancel = Merge (imported overwrite by id)`
);
if (replace) {
this.store.setAll(imported);
alert('Restore complete: replaced all invoices.');
} else {
this.store.mergeAll(imported);
alert('Restore complete: merged invoices.');
}
} catch (e) {
console.error(e);
alert('Import failed: invalid JSON or unreadable file.');
} finally {
if (this.importInput?.nativeElement) this.importInput.nativeElement.value = '';
}
}
Let hide file input for import button in the header actions (next to Create / + Sample / Export) of the src/app/features/dashboard/dashboard.component.html:
<!-- Hidden file input for import -->
<input
#importInput
type="file"
accept="application/json"
class="hidden"
(change)="handleImport(importInput.files)" />
<button class="btn-secondary" type="button" (click)="triggerImport(importInput)">
{{ 'dashboard.actions.import' | translate }}
</button>
Add the import text on public/assets/i18n/en.json:
{
"dashboard": {
"actions": {
"import": "Import JSON"
}
}
}
And on public/assets/i18n/fr.json:
{
"dashboard": {
"actions": {
"import": "Importer JSON"
}
}
}
Open the app and run a quick test to make sure the import works.
Let’s round out backups with a clean CSV export. No need to use libraries: just two neat files you can open in Excel/Sheets. One for invoices, one for line items.
4. Export as CSV 🔗
Let’s make it easy for users to work with their data outside the app.
With a single click, they’ll be able to export all invoices and line items as CSV files, perfect for quick reviews, reports, or even Excel and Google Sheets.
Create a CSV helper first:
src/app/core/utils/csv.ts
function csvEscape(value: unknown): string {
const s = value === undefined || value === null ? '' : String(value);
if (/[",\\r\\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
/** Build a CSV string from an array of plain objects using the provided column order. */
export function toCsv(columns: string[], rows: Array<Record<string, unknown>>): string {
const header = columns.join(',');
const data = rows.map(r => columns.map(c => csvEscape(r[c])).join(','));
return [header, ...data].join('\\r\\n');
}
This tiny helper takes any list of objects and turns it into a clean, spreadsheet-ready CSV file. It automatically escapes commas and quotes so everything opens correctly in Excel or Sheets.
Add these imports at the top in src/app/features/dashboard/dashboard.component.ts:
import { toCsv } from '../../core/utils/csv';
import { lineTotal, invoiceTotal } from '../../core/utils/money';
Then, inside your DashboardComponent, add this method:
// ---------- export CSV ----------
exportCsv() {
const list = this.invoices();
// Invoices CSV (one row per invoice; totals are raw numbers in base currency)
const invCols = [
'id','number','clientName','clientEmail','issueDate','dueDate',
'currency','status','itemsCount','total','createdAt','updatedAt'
];
const invRows = list.map(inv => ({
id: inv.id,
number: inv.number,
clientName: inv.clientName,
clientEmail: inv.clientEmail ?? '',
issueDate: inv.issueDate,
dueDate: inv.dueDate ?? '',
currency: inv.currency,
status: inv.status,
itemsCount: inv.items.length,
total: invoiceTotal(inv), // numeric, not localized
createdAt: inv.createdAt,
updatedAt: inv.updatedAt,
}));
const invoicesCsv = toCsv(invCols, invRows);
// Items CSV (one row per line item; includes invoiceId)
const itemCols = [
'invoiceId','itemId','description','quantity','unitPrice','taxRate','discountRate','lineTotal'
];
const itemRows = list.flatMap(inv =>
inv.items.map(it => ({
invoiceId: inv.id,
itemId: it.id,
description: it.description,
quantity: it.quantity,
unitPrice: it.unitPrice,
taxRate: it.taxRate ?? 0,
discountRate: it.discountRate ?? 0,
lineTotal: lineTotal(it), // numeric, not localized
}))
);
const itemsCsv = toCsv(itemCols, itemRows);
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
downloadText(`invoices-${stamp}.csv`, invoicesCsv, 'text/csv;charset=utf-8');
downloadText(`invoice_items-${stamp}.csv`, itemsCsv, 'text/csv;charset=utf-8');
}This exports two CSV files: one summarizing each invoice and another listing all individual line items. Keeping the numbers raw makes them easier to analyze or chart later in any spreadsheet.
Let add the UI button in src/app/features/dashboard/dashboard.component.html in the header actions (next to Create / + Sample / Export JSON / Import JSON):
<button class="btn-secondary" type="button" (click)="exportCsv()">
{{ 'dashboard.actions.exportCsv' | translate }}
</button>
Update your translations:
public/assets/i18n/en.json
{
"dashboard": {
"actions": {
"exportCsv": "Export CSV"
}
}
}
public/assets/i18n/fr.json
{
"dashboard": {
"actions": {
"exportCsv": "Exporter CSV"
}
}
}
Open the app, hit Export CSV, and voilà! Two neat files land on your computer (invoices-YYYY-MM-DD-HH-MM-SS.csvand invoice_items-YYYY-MM-DD-HH-MM-SS.csv). Drop them into Excel or Google Sheets and play around: sort, filter, or build quick reports in seconds.
➡️ What's next? 🔗
This wraps up the second part of your journey where we built the foundation of the app and set up translation services to make it bilingual and user-friendly. In the next part, we’ll take things further by integrating the Angular app with Localazy, unlocking the real advantages of localization: faster translations, easier updates, and a smoother multilingual experience for every user.



