Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/api/src/invoices/entities/invoice.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];

Expand Down
19 changes: 13 additions & 6 deletions apps/api/src/invoices/invoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 } } },
});
}

Expand All @@ -77,7 +80,7 @@ export class InvoicesService {
...(projectId ? { projectId } : {}),
...(status ? { status } : {}),
},
include: { items: true },
include: { items: true, project: { select: { name: true } } },
},
{
cursor,
Expand All @@ -92,7 +95,7 @@ export class InvoicesService {
async findOne(ownerId: string, id: string): Promise<InvoiceWithItems> {
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;
Expand All @@ -112,7 +115,7 @@ export class InvoicesService {
...data,
...(items ? { items: { deleteMany: {}, create: items } } : {}),
},
include: { items: true },
include: { items: true, project: { select: { name: true } } },
});
}

Expand All @@ -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, {
Expand Down
6 changes: 2 additions & 4 deletions apps/client/src/app/domains/invoices/invoice.models.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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[];
Expand Down
6 changes: 3 additions & 3 deletions apps/client/src/app/layout/notifications-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
aria-label="Close notifications"
></button>
<div
class="absolute right-0 top-11 z-40 w-[340px] overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
class="fixed inset-x-3 top-16 z-40 overflow-hidden rounded-xl border border-border bg-background shadow-2xl sm:absolute sm:inset-x-auto sm:right-0 sm:top-11 sm:w-[340px]"
>
<div
class="flex items-center justify-between border-b border-border px-4 py-2.5"
Expand All @@ -68,14 +68,14 @@ import {
</button>
}
</div>
<div class="max-h-[380px] overflow-y-auto">
<div class="max-h-[60vh] overflow-y-auto sm:max-h-[380px]">
@if (loading()) {
<p class="px-4 py-6 text-center text-xs text-muted-foreground">
Loading…
</p>
} @else if (notifications().length === 0) {
<p class="px-4 py-6 text-center text-xs text-muted-foreground">
Nothing yet sent invoices, quotes and shared documents show up
Nothing yet - sent invoices, quotes and shared documents show up
here.
</p>
} @else {
Expand Down
152 changes: 92 additions & 60 deletions apps/client/src/app/pages/payments/payments.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
title="Payments"
[description]="filtered().length + ' payments · ' + money(total()) + ' total'"
>
<button actions hlmBtn size="sm" class="h-9 gap-1.5 shadow-[var(--shadow-glow)]" (click)="openNew()">
<button
actions
hlmBtn
size="sm"
class="h-9 gap-1.5 shadow-[var(--shadow-glow)]"
(click)="openNew()"
>
<ng-icon name="lucidePlus" size="16" />
Record payment
</button>
Expand Down Expand Up @@ -39,66 +45,66 @@
<hlm-select-content *hlmSelectPortal>
<hlm-select-item value="all">All methods</hlm-select-item>
@for (m of methodOptions; track m[0]) {
<hlm-select-item [value]="m[0]">{{ m[1] }}</hlm-select-item>
<hlm-select-item [value]="m[0]">{{ m[1] }}</hlm-select-item>
}
</hlm-select-content>
</hlm-select>
</div>

@if (loading()) {
<app-list-skeleton [rows]="5" />
} @else {
@if (filtered().length === 0) {
<div class="surface-card flex flex-col items-center justify-center px-6 py-16 text-center">
<div class="grid h-12 w-12 place-items-center rounded-xl bg-muted text-muted-foreground">
<span class="font-mono text-lg">∅</span>
</div>
<p class="mt-4 text-sm font-semibold">No payments yet</p>
<button hlmBtn size="sm" class="mt-4 gap-1.5" (click)="openNew()">
<ng-icon name="lucidePlus" size="16" />
Record payment
</button>
<app-list-skeleton [rows]="5" />
} @else { @if (filtered().length === 0) {
<div
class="surface-card flex flex-col items-center justify-center px-6 py-16 text-center"
>
<div
class="grid h-12 w-12 place-items-center rounded-xl bg-muted text-muted-foreground"
>
<span class="font-mono text-lg">∅</span>
</div>
<p class="mt-4 text-sm font-semibold">No payments yet</p>
<button hlmBtn size="sm" class="mt-4 gap-1.5" (click)="openNew()">
<ng-icon name="lucidePlus" size="16" />
Record payment
</button>
</div>
} @else {
<div class="surface-card overflow-hidden">
<ul class="divide-y divide-border">
@for (p of filtered(); track p.id) {
<li>
<button
type="button"
(click)="openEdit(p)"
class="flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-muted/40"
>
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-success/12 text-success">
<ng-icon name="lucideWallet" size="16" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ clientMap()[p.clientId]?.company || clientMap()[p.clientId]?.name || 'Unknown' }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ label(p.method) }}
@if (p.reference) {
· {{ p.reference }}
}
@if (p.invoiceId) {
· {{ invoiceNumber(p.invoiceId) }}
}
</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold tabular-nums text-success">
+{{ money(p.amount, p.currency) }}
</p>
<p class="text-[11px] text-muted-foreground">{{ day(p.date) }}</p>
</div>
</button>
</li>
}
</ul>
</div>
}
}
<div class="surface-card overflow-hidden">
<ul class="divide-y divide-border">
@for (p of filtered(); track p.id) {
<li>
<button
type="button"
(click)="openEdit(p)"
class="flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-muted/40"
>
<div
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-success/12 text-success"
>
<ng-icon name="lucideWallet" size="16" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ clientMap()[p.clientId]?.company ||
clientMap()[p.clientId]?.name || 'Unknown' }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ label(p.method) }} @if (p.reference) { · {{ p.reference }} }
@if (p.invoiceId) { · {{ invoiceNumber(p.invoiceId) }} }
</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold tabular-nums text-success">
+{{ money(p.amount, p.currency) }}
</p>
<p class="text-[11px] text-muted-foreground">{{ day(p.date) }}</p>
</div>
</button>
</li>
}
</ul>
</div>
} }

<app-entity-sheet
[(open)]="sheetOpen"
Expand All @@ -122,28 +128,45 @@
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
@for (c of clients(); track c.id) {
<hlm-select-item [value]="c.id">{{ c.company || c.name }}</hlm-select-item>
<hlm-select-item [value]="c.id"
>{{ c.company || c.name }}</hlm-select-item
>
}
</hlm-select-content>
</hlm-select>
</app-field>
<app-field label="Invoice (optional)">
<hlm-select class="w-full" [formField]="f.invoiceId" [itemToString]="invoiceLabel">
<hlm-select
class="w-full"
[formField]="f.invoiceId"
[itemToString]="invoiceLabel"
>
<hlm-select-trigger class="w-full">
<hlm-select-value placeholder="Unlinked" />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
<hlm-select-item value="">Unlinked</hlm-select-item>
@for (i of clientInvoices; track i.id) {
<hlm-select-item [value]="i.id">{{ i.number }}</hlm-select-item>
<hlm-select-item [value]="i.id">
<span class="block truncate">
{{ i.project ? i.number + ' (' + i.project.name + ')' :
i.number }}
</span>
</hlm-select-item>
}
</hlm-select-content>
</hlm-select>
</app-field>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<app-field label="Amount" [error]="fieldError(f.amount())">
<input hlmInput type="number" step="0.01" class="w-full" [formField]="f.amount" />
<input
hlmInput
type="number"
step="0.01"
class="w-full"
[formField]="f.amount"
/>
</app-field>
<app-field label="Currency">
<hlm-select class="w-full" [formField]="f.currency">
Expand All @@ -161,13 +184,17 @@
</div>
<div class="grid gap-4 sm:grid-cols-2">
<app-field label="Method">
<hlm-select class="w-full" [formField]="f.method" [itemToString]="methodLabel">
<hlm-select
class="w-full"
[formField]="f.method"
[itemToString]="methodLabel"
>
<hlm-select-trigger class="w-full">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
@for (m of methodOptions; track m[0]) {
<hlm-select-item [value]="m[0]">{{ m[1] }}</hlm-select-item>
<hlm-select-item [value]="m[0]">{{ m[1] }}</hlm-select-item>
}
</hlm-select-content>
</hlm-select>
Expand All @@ -188,7 +215,12 @@
/>
</app-field>
<app-field label="Notes">
<textarea hlmTextarea rows="3" class="w-full" [formField]="f.notes"></textarea>
<textarea
hlmTextarea
rows="3"
class="w-full"
[formField]="f.notes"
></textarea>
</app-field>
</div>
</app-entity-sheet>
Expand Down
16 changes: 12 additions & 4 deletions apps/client/src/app/pages/payments/payments.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
PLATFORM_ID,
computed,
inject,
signal,
Expand Down Expand Up @@ -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));
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading