From a70fd9e33f3137adca51e636ce548806ea977d1d Mon Sep 17 00:00:00 2001 From: Marvin Kweyu Date: Fri, 10 Jul 2026 15:19:18 +0300 Subject: [PATCH] feat: project creation from quote --- .../src/invoices/entities/invoice.entity.ts | 8 + apps/api/src/invoices/invoices.service.ts | 19 ++- .../app/domains/invoices/invoice.models.ts | 6 +- .../src/app/layout/notifications-menu.ts | 6 +- .../src/app/pages/payments/payments.html | 152 +++++++++++------- .../client/src/app/pages/payments/payments.ts | 16 +- apps/client/src/app/pages/quotes/quotes.html | 22 +++ apps/client/src/app/pages/quotes/quotes.ts | 67 +++++++- .../client/src/app/pages/reports/reports.html | 14 +- 9 files changed, 224 insertions(+), 86 deletions(-) diff --git a/apps/api/src/invoices/entities/invoice.entity.ts b/apps/api/src/invoices/entities/invoice.entity.ts index 4245f8d..caa5b39 100644 --- a/apps/api/src/invoices/entities/invoice.entity.ts +++ b/apps/api/src/invoices/entities/invoice.entity.ts @@ -7,6 +7,11 @@ export class InvoiceItemEntity extends LineItemEntity { invoiceId: string; } +export class InvoiceProjectEntity { + @ApiProperty() + name: string; +} + export class InvoiceEntity { @ApiProperty({ format: 'uuid' }) id: string; @@ -48,6 +53,9 @@ export class InvoiceEntity { @ApiPropertyOptional({ nullable: true, type: String, format: 'uuid' }) projectId: string | null; + @ApiPropertyOptional({ nullable: true, type: InvoiceProjectEntity }) + project: InvoiceProjectEntity | null; + @ApiProperty({ type: [InvoiceItemEntity] }) items: InvoiceItemEntity[]; diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index df13b93..c78e103 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -31,7 +31,10 @@ import { Decimal } from '@prisma/client/runtime/client'; import { ConversionRate } from './dto/conversion-date.dto'; import { ExchangeRatesEntity } from './entities/exchange-rates.entity'; -export type InvoiceWithItems = Invoice & { items: InvoiceItem[] }; +export type InvoiceWithItems = Invoice & { + items: InvoiceItem[]; + project: { name: string } | null; +}; @Injectable() export class InvoicesService { @@ -58,7 +61,7 @@ export class InvoicesService { number: number ?? (await this.nextNumber()), items: { create: items }, }, - include: { items: true }, + include: { items: true, project: { select: { name: true } } }, }); } @@ -77,7 +80,7 @@ export class InvoicesService { ...(projectId ? { projectId } : {}), ...(status ? { status } : {}), }, - include: { items: true }, + include: { items: true, project: { select: { name: true } } }, }, { cursor, @@ -92,7 +95,7 @@ export class InvoicesService { async findOne(ownerId: string, id: string): Promise { const invoice = await this.prisma.invoice.findFirst({ where: { id, client: { workspace: { ownerId } } }, - include: { items: true }, + include: { items: true, project: { select: { name: true } } }, }); if (!invoice) throw new NotFoundException('Invoice not found'); return invoice; @@ -112,7 +115,7 @@ export class InvoicesService { ...data, ...(items ? { items: { deleteMany: {}, create: items } } : {}), }, - include: { items: true }, + include: { items: true, project: { select: { name: true } } }, }); } @@ -128,7 +131,11 @@ export class InvoicesService { const invoice = await this.prisma.invoice.update({ where: { id }, data: { status: InvoiceStatus.sent }, - include: { items: true, client: true }, + include: { + items: true, + client: true, + project: { select: { name: true } }, + }, }); const { client, ...rest } = invoice; this.events.emit(InvoiceEvents.SENT, { diff --git a/apps/client/src/app/domains/invoices/invoice.models.ts b/apps/client/src/app/domains/invoices/invoice.models.ts index 01707a0..9485cad 100644 --- a/apps/client/src/app/domains/invoices/invoice.models.ts +++ b/apps/client/src/app/domains/invoices/invoice.models.ts @@ -1,10 +1,7 @@ import { PaginationQuery } from '../../core/http/api.types'; import { Currency, LineItemRequest } from '../shared/models'; -export type { - ReconciliationEntry, - ReconciliationKind, -} from '../shared/models'; +export type { ReconciliationEntry, ReconciliationKind } from '../shared/models'; export type InvoiceStatus = | 'draft' @@ -36,6 +33,7 @@ export interface Invoice { notes: string | null; /** Last time a due/overdue reminder email went out. */ lastRemindedAt: string | null; + project: { name: string } | null; clientId: string; projectId: string | null; items: InvoiceItem[]; diff --git a/apps/client/src/app/layout/notifications-menu.ts b/apps/client/src/app/layout/notifications-menu.ts index 0b9174c..45321b7 100644 --- a/apps/client/src/app/layout/notifications-menu.ts +++ b/apps/client/src/app/layout/notifications-menu.ts @@ -51,7 +51,7 @@ import { aria-label="Close notifications" >
}
-
+
@if (loading()) {

Loading…

} @else if (notifications().length === 0) {

- Nothing yet — sent invoices, quotes and shared documents show up + Nothing yet - sent invoices, quotes and shared documents show up here.

} @else { diff --git a/apps/client/src/app/pages/payments/payments.html b/apps/client/src/app/pages/payments/payments.html index 56b2690..63dedf2 100644 --- a/apps/client/src/app/pages/payments/payments.html +++ b/apps/client/src/app/pages/payments/payments.html @@ -3,7 +3,13 @@ title="Payments" [description]="filtered().length + ' payments · ' + money(total()) + ' total'" > - @@ -39,66 +45,66 @@ All methods @for (m of methodOptions; track m[0]) { - {{ m[1] }} + {{ m[1] }} }
@if (loading()) { - - } @else { - @if (filtered().length === 0) { -
-
- -
-

No payments yet

- + + } @else { @if (filtered().length === 0) { +
+
+
+

No payments yet

+ +
} @else { -
-
    - @for (p of filtered(); track p.id) { -
  • - -
  • - } -
-
- } - } +
+
    + @for (p of filtered(); track p.id) { +
  • + +
  • + } +
+
+ } } @for (c of clients(); track c.id) { - {{ c.company || c.name }} + {{ c.company || c.name }} } - + Unlinked @for (i of clientInvoices; track i.id) { - {{ i.number }} + + + {{ i.project ? i.number + ' (' + i.project.name + ')' : + i.number }} + + } @@ -143,7 +160,13 @@
- + @@ -161,13 +184,17 @@
- + @for (m of methodOptions; track m[0]) { - {{ m[1] }} + {{ m[1] }} } @@ -188,7 +215,12 @@ /> - +
diff --git a/apps/client/src/app/pages/payments/payments.ts b/apps/client/src/app/pages/payments/payments.ts index b537ac1..37af7f4 100644 --- a/apps/client/src/app/pages/payments/payments.ts +++ b/apps/client/src/app/pages/payments/payments.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, - PLATFORM_ID, computed, inject, signal, @@ -166,7 +165,9 @@ export class Payments { (!term || (p.reference || '').toLowerCase().includes(term) || (clientMap[p.clientId]?.name || '').toLowerCase().includes(term) || - (clientMap[p.clientId]?.company || '').toLowerCase().includes(term)), + (clientMap[p.clientId]?.company || '') + .toLowerCase() + .includes(term)), ) .slice() .sort((a, b) => (a.date < b.date ? 1 : -1)); @@ -182,7 +183,10 @@ export class Payments { } protected openNew(): void { - this.model.set({ ...emptyPayment(), clientId: this.clients()[0]?.id ?? '' }); + this.model.set({ + ...emptyPayment(), + clientId: this.clients()[0]?.id ?? '', + }); this.isNew = true; this.sheetOpen.set(true); } @@ -260,7 +264,11 @@ export class Payments { protected invoiceNumber(id: string | null | undefined): string { if (!id) return ''; - return this.invoices().find((i) => i.id === id)?.number ?? ''; + const invoice = this.invoices().find((i) => i.id === id); + if (!invoice) return ''; + return invoice.project + ? `${invoice.number} (${invoice.project.name})` + : invoice.number; } protected label(method: PaymentMethod): string { diff --git a/apps/client/src/app/pages/quotes/quotes.html b/apps/client/src/app/pages/quotes/quotes.html index f3c626c..9d35eef 100644 --- a/apps/client/src/app/pages/quotes/quotes.html +++ b/apps/client/src/app/pages/quotes/quotes.html @@ -213,4 +213,26 @@
+ + + +
+

Create a project from this quote?

+

+ {{ acceptedQuote()?.number || 'This quote' }} was just accepted for + {{ clientLabel(acceptedQuote()?.clientId ?? '') }}. Spin up a + matching project now, or skip and do it later from Projects. +

+
+
+ + +
+
+
diff --git a/apps/client/src/app/pages/quotes/quotes.ts b/apps/client/src/app/pages/quotes/quotes.ts index 92822e1..a610280 100644 --- a/apps/client/src/app/pages/quotes/quotes.ts +++ b/apps/client/src/app/pages/quotes/quotes.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, - PLATFORM_ID, computed, inject, signal, @@ -10,12 +9,15 @@ import { import { FormField, form, required } from '@angular/forms/signals'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePlus, lucideSearch, lucideSend } from '@ng-icons/lucide'; +import { BrnAlertDialogContent } from '@spartan-ng/brain/alert-dialog'; +import { HlmAlertDialogImports } from '@spartan-ng/helm/alert-dialog'; import { HlmButton } from '@spartan-ng/helm/button'; import { HlmInput } from '@spartan-ng/helm/input'; import { HlmSelectImports } from '@spartan-ng/helm/select'; import { HlmTextarea } from '@spartan-ng/helm/textarea'; import { apiErrorMessage } from '../../core/http'; import { ApiClient, ClientsApiService } from '../../domains/clients'; +import { ProjectsApiService } from '../../domains/projects'; import { CreateQuoteRequest, Quote, @@ -74,6 +76,8 @@ const emptyQuote = (): QuoteForm => ({ imports: [ FormField, NgIcon, + BrnAlertDialogContent, + HlmAlertDialogImports, HlmButton, HlmInput, HlmTextarea, @@ -92,6 +96,7 @@ const emptyQuote = (): QuoteForm => ({ export class Quotes { private readonly quotesApi = inject(QuotesApiService); private readonly clientsApi = inject(ClientsApiService); + private readonly projectsApi = inject(ProjectsApiService); private readonly toast = inject(ToastService); protected readonly loading = signal(true); @@ -103,6 +108,8 @@ export class Quotes { protected readonly sheetOpen = signal(false); protected readonly saving = signal(false); protected readonly sending = signal(false); + protected readonly projectDialogOpen = signal(false); + protected readonly acceptedQuote = signal(null); protected isNew = true; protected readonly model = signal(emptyQuote()); @@ -161,7 +168,11 @@ export class Quotes { (status === 'all' || q.status === status) && (!term || q.number.toLowerCase().includes(term) || - (clientMap[q.clientId]?.company || clientMap[q.clientId]?.name || '') + ( + clientMap[q.clientId]?.company || + clientMap[q.clientId]?.name || + '' + ) .toLowerCase() .includes(term)), ) @@ -242,6 +253,10 @@ export class Quotes { this.sheetOpen.set(false); if (this.isNew) this.toast.created('Quote'); else this.toast.updated('Quote'); + if (quote.status === 'accepted') { + this.acceptedQuote.set(quote); + this.projectDialogOpen.set(true); + } }, error: (err) => { this.saving.set(false); @@ -295,6 +310,54 @@ export class Quotes { })); } + protected skipProjectCreation(ctx: { close: () => void }): void { + ctx.close(); + this.projectDialogOpen.set(false); + this.acceptedQuote.set(null); + } + + /** Spins up a project for the just-accepted quote, named from its items and client. */ + protected confirmProjectCreation(ctx: { close: () => void }): void { + ctx.close(); + this.projectDialogOpen.set(false); + const quote = this.acceptedQuote(); + this.acceptedQuote.set(null); + if (!quote) return; + const client = this.clientMap()[quote.clientId]; + this.projectsApi + .create({ + name: this.randomProjectName(quote, client), + clientId: quote.clientId, + startDate: toApiDate(new Date().toISOString().slice(0, 10)), + budget: this.total(quote), + description: `Created from quote ${quote.number}.`, + }) + .subscribe({ + next: (project) => { + this.toast.success( + 'Project created', + `${project.name} is ready to go.`, + ); + }, + error: (err) => { + this.toast.error('Could not create project', apiErrorMessage(err)); + }, + }); + } + + /** Picks a random line item to riff on for the new proj name. + * prolly create in transaction + */ + private randomProjectName(quote: Quote, client?: ApiClient): string { + const descriptions = quote.items.map((i) => i.description).filter(Boolean); + const pick = + descriptions.length > 0 + ? descriptions[Math.floor(Math.random() * descriptions.length)] + : 'New engagement'; + const clientName = client?.company || client?.name || 'client'; + return `${pick} - ${clientName}`; + } + protected onItemsChange(items: LineItemDraft[]): void { this.model.update((m) => ({ ...m, items })); } diff --git a/apps/client/src/app/pages/reports/reports.html b/apps/client/src/app/pages/reports/reports.html index 57105a7..d49b536 100644 --- a/apps/client/src/app/pages/reports/reports.html +++ b/apps/client/src/app/pages/reports/reports.html @@ -1,7 +1,7 @@
Top clients
{{ row.name }} - {{ - row.invoicedLabel - }} + {{ row.invoicedLabel }}
Expenses by category
{{ row.category }} - {{ - row.amountLabel - }} + {{ row.amountLabel }}