Skip to content
This repository was archived by the owner on Feb 24, 2026. It is now read-only.

Commit 2fa79ab

Browse files
authored
feat: generate invoice items by project hours (#125)
1 parent 0a12591 commit 2fa79ab

23 files changed

Lines changed: 266 additions & 66 deletions

File tree

apps/gauzy/src/app/pages/invoices/invoice-add/by-role/invoice-add-by-role.component.html

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -189,24 +189,9 @@ <h4>{{ (isEstimate ? 'INVOICES_PAGE.ADD_ESTIMATE' : 'INVOICES_PAGE.ADD_INVOICE')
189189
</div>
190190
</ng-container>
191191

192-
<ng-container *ngIf="isEmployeeHourTable && selectedEmployee">
193-
<div class="col-sm-6">
194-
<div class="form-group">
195-
<label for="selectedDateRange" class="label"
196-
>{{ 'INVOICES_PAGE.SELECT_DATE_RANGE' | translate }}
197-
</label>
198-
<ngx-date-range-picker
199-
[arrows]="false"
200-
[firstDayOfWeek]="organization.startWeekOn"
201-
class="date-range-selector"
202-
></ngx-date-range-picker>
203-
</div>
204-
</div>
205-
</ng-container>
206-
207192
<div class="col-sm-6" *ngIf="isProjectHourTable">
208193
<div class="form-group">
209-
<label for="inputProject" class="label">
194+
<label for="inputProject" class="label required-asterisk">
210195
{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_PROJECTS' | translate }}
211196
</label>
212197
<ng-select
@@ -218,6 +203,7 @@ <h4>{{ (isEstimate ? 'INVOICES_PAGE.ADD_ESTIMATE' : 'INVOICES_PAGE.ADD_INVOICE')
218203
(change)="selectProject($event)"
219204
[multiple]="true"
220205
appendTo="body"
206+
class="project-hours"
221207
>
222208
<ng-template ng-option-tmp let-item="item" let-index="index">
223209
{{ item.name }}
@@ -228,8 +214,30 @@ <h4>{{ (isEstimate ? 'INVOICES_PAGE.ADD_ESTIMATE' : 'INVOICES_PAGE.ADD_INVOICE')
228214
</div>
229215
</ng-template>
230216
</ng-select>
217+
<div
218+
*ngIf="form.get('project')?.errors?.required && form.get('project')?.touched"
219+
class="invalid-feedback d-block"
220+
>
221+
{{ 'INVOICES_PAGE.VALIDATION.REQUIRED_PROJECT' | translate }}
222+
</div>
231223
</div>
232224
</div>
225+
226+
<ng-container *ngIf="(isEmployeeHourTable || isProjectHourTable) && selectedEmployee">
227+
<div class="col-sm-6">
228+
<div class="form-group">
229+
<label for="selectedDateRange" class="label"
230+
>{{ 'INVOICES_PAGE.SELECT_DATE_RANGE' | translate }}
231+
</label>
232+
<ngx-date-range-picker
233+
[arrows]="false"
234+
[firstDayOfWeek]="organization.startWeekOn"
235+
class="date-range-selector"
236+
></ngx-date-range-picker>
237+
</div>
238+
</div>
239+
</ng-container>
240+
233241
<div class="col-sm-6" *ngIf="isTaskHourTable">
234242
<div class="form-group">
235243
<label for="inputTask" class="label">

apps/gauzy/src/app/pages/invoices/invoice-add/by-role/invoice-add-by-role.component.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,14 @@ $shadow: 0 0 0 nb-theme(button-outline-width)
3131
color: nb-theme(button-outline-basic-disabled-text-color);
3232
box-shadow: unset;
3333
}
34+
35+
:host ::ng-deep .ng-select.project-hours.ng-select-multiple {
36+
.ng-select-container {
37+
min-height: 42px !important;
38+
height: auto !important;
39+
}
40+
41+
input {
42+
height: 100% !important;
43+
}
44+
}

apps/gauzy/src/app/pages/invoices/invoice-add/by-role/invoice-add-by-role.component.ts

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
IReportDayData,
2222
IReportDayGroupByEmployee,
2323
IGetEmployeeHourlyRateInput,
24-
IEmployeeHourlyRate
24+
IEmployeeHourlyRate,
25+
IReportDayGroupByProject
2526
} from '@gauzy/contracts';
2627
import { filter, tap } from 'rxjs/operators';
2728
import { compareDate, distinctUntilChange, extractNumber, isEmpty, isNotEmpty } from '@gauzy/ui-core/common';
@@ -52,6 +53,7 @@ import { InvoiceExpensesSelectorComponent } from '../../table-components/invoice
5253
import {
5354
InvoiceApplyTaxDiscountComponent,
5455
InvoiceProductsSelectorComponent,
56+
InvoiceProjectFilterComponent,
5557
InvoiceProjectsSelectorComponent,
5658
InvoiceTasksSelectorComponent
5759
} from '../../table-components';
@@ -231,6 +233,26 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
231233
selectedEmployee: [{ value: this.selectedEmployee?.name, disabled: true }],
232234
tags: []
233235
});
236+
237+
this.subscribeInvoiceTypeChanges();
238+
}
239+
240+
private subscribeInvoiceTypeChanges() {
241+
this.form
242+
.get('invoiceType')
243+
?.valueChanges.pipe(untilDestroyed(this))
244+
.subscribe((value) => {
245+
const projectControl = this.form.get('project');
246+
247+
if (value === InvoiceTypeEnum.BY_PROJECT_HOURS) {
248+
projectControl?.setValidators(Validators.required);
249+
} else {
250+
projectControl?.clearValidators();
251+
projectControl?.setValue(null);
252+
}
253+
254+
projectControl?.updateValueAndValidity();
255+
});
234256
}
235257

236258
loadSmartTable() {
@@ -286,8 +308,17 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
286308
component: InvoiceProjectsSelectorComponent
287309
},
288310
valuePrepareFunction: (cell) => {
289-
const project = cell;
290-
return `${project.name}`;
311+
return cell?.name ?? '';
312+
},
313+
filter: {
314+
type: 'custom',
315+
component: InvoiceProjectFilterComponent
316+
},
317+
filterFunction: (value, search) => {
318+
if (!search || !value) {
319+
return true;
320+
}
321+
return value.name.toLowerCase().includes(search.toLowerCase());
291322
}
292323
};
293324
break;
@@ -714,7 +745,7 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
714745
}
715746
}
716747

717-
private loadInvoiceTimeLogsData(): Observable<number> {
748+
private loadInvoiceTimeLogsDataByEmployee(): Observable<number> {
718749
const request: IGetInvoiceTimeLogs = {
719750
organizationId: this.organization?.id,
720751
tenantId: this.store.user.tenantId,
@@ -739,6 +770,35 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
739770
);
740771
}
741772

773+
private loadInvoiceTimeLogsDataByProjects(): Observable<{
774+
[projectId: string]: number;
775+
}> {
776+
const request: IGetInvoiceTimeLogs = {
777+
organizationId: this.organization?.id,
778+
tenantId: this.store.user.tenantId,
779+
startDate: this.selectedDateRange.startDate.toISOString(),
780+
endDate: this.selectedDateRange.endDate.toISOString(),
781+
employeeIds: [this.selectedEmployee?.employee?.id],
782+
groupBy: 'project',
783+
relations: ['project']
784+
};
785+
786+
return this.invoiceTimeLogsService.getInvoiceTimeLogs(request).pipe(
787+
map((data: IReportDayGroupByProject[]) => {
788+
const projectSums: { [projectId: string]: number } = {};
789+
790+
data.forEach((item) => {
791+
if (item.project) {
792+
projectSums[item.project.id] = this.hoursDurationFormatPipe.transform(item.sum);
793+
}
794+
});
795+
796+
return projectSums;
797+
}),
798+
untilDestroyed(this)
799+
);
800+
}
801+
742802
private loadInvoiceEmployeeRateData(): Observable<number> {
743803
const request: IGetEmployeeHourlyRateInput = {
744804
organizationId: this.organization.id,
@@ -785,15 +845,6 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
785845
this._getInvoiceNumber();
786846
}
787847

788-
private getAllProjects() {
789-
const { id: organizationId } = this.organization;
790-
const { tenantId } = this.store.user;
791-
792-
this.organizationProjectsService.getAll([], { organizationId, tenantId }).then(({ items }) => {
793-
this.projects = JSON.parse(JSON.stringify(items));
794-
});
795-
}
796-
797848
private getAllProducts() {
798849
const { id: organizationId } = this.organization;
799850
const { tenantId } = this.store.user;
@@ -820,7 +871,7 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
820871
});
821872
}
822873

823-
onTypeChange($event) {
874+
async onTypeChange($event) {
824875
this.invoiceType = $event;
825876

826877
this.isEmployeeHourTable = false;
@@ -835,7 +886,11 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
835886
break;
836887
case InvoiceTypeEnum.BY_PROJECT_HOURS:
837888
this.isProjectHourTable = true;
838-
this.getAllProjects();
889+
this.projects = await this.organizationProjectsService.getAssignedProjects(
890+
this.organization?.id,
891+
this.store.user?.tenantId,
892+
this.store.user?.employee?.id
893+
);
839894
break;
840895
case InvoiceTypeEnum.BY_TASK_HOURS:
841896
this.isTaskHourTable = true;
@@ -887,11 +942,11 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
887942
break;
888943
case InvoiceTypeEnum.BY_PROJECT_HOURS:
889944
if (isNotEmpty(this.selectedProjects)) {
945+
const { timeMap, rate } = await this.getProjectsData();
890946
for (const project of this.selectedProjects) {
891-
const data = this.createInvoiceData(project, fakePrice, fakeQuantity);
947+
const time = timeMap[project.id] ?? 0;
948+
const data = this.createInvoiceData(project, rate, time);
892949
invoiceData.push(data);
893-
fakePrice++;
894-
fakeQuantity++;
895950
}
896951
}
897952
break;
@@ -953,7 +1008,7 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
9531008
private getEmployeeData() {
9541009
return new Promise<{ time: number; rate: number }>((resolve, reject) => {
9551010
forkJoin({
956-
time: this.loadInvoiceTimeLogsData(),
1011+
time: this.loadInvoiceTimeLogsDataByEmployee(),
9571012
rate: this.loadInvoiceEmployeeRateData()
9581013
})
9591014
.pipe(untilDestroyed(this))
@@ -969,6 +1024,30 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
9691024
});
9701025
}
9711026

1027+
private getProjectsData() {
1028+
return new Promise<{
1029+
timeMap: {
1030+
[projectId: string]: number;
1031+
};
1032+
rate: number;
1033+
}>((resolve, reject) => {
1034+
forkJoin({
1035+
timeMap: this.loadInvoiceTimeLogsDataByProjects(),
1036+
rate: this.loadInvoiceEmployeeRateData()
1037+
})
1038+
.pipe(untilDestroyed(this))
1039+
.subscribe({
1040+
next: ({ timeMap, rate }) => {
1041+
resolve({ timeMap, rate });
1042+
},
1043+
error: (err) => {
1044+
this.toastrService.error(err?.message || 'An unknown error occurred');
1045+
reject(new Error(`Error: ${err?.message || 'An unknown error occurred'}`));
1046+
}
1047+
});
1048+
});
1049+
}
1050+
9721051
// Helper function to create invoice data objects
9731052
private createInvoiceData(selectedItem, price: number, quantity: number, currency?: string) {
9741053
return {
@@ -1098,7 +1177,6 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
10981177
selectedItem: newData.selectedItem !== undefined ? newData.selectedItem : lastSourceData.selectedItem
10991178
};
11001179
const quantityIsValid = /^\d*\.?\d+$/.test(newData.quantity) && !/^0\d+/.test(newData.quantity);
1101-
11021180
if (
11031181
quantityIsValid &&
11041182
Number.isFinite(+newData.quantity) &&
@@ -1107,6 +1185,7 @@ export class InvoiceAddByRoleComponent extends PaginationFilterBaseComponent imp
11071185
) {
11081186
newData = { ...newData, price: extractNumber(newData.price) };
11091187
const itemTotal = +newData.quantity * +extractNumber(newData.price);
1188+
newData.totalValue = itemTotal;
11101189
this.subtotal += itemTotal;
11111190
await event.confirm.resolve(newData);
11121191
await this.calculateTotal();

apps/gauzy/src/app/pages/invoices/invoice-edit/by-role/invoice-edit-by-role.component.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
InvoiceApplyTaxDiscountComponent,
3737
InvoiceExpensesSelectorComponent,
3838
InvoiceProductsSelectorComponent,
39+
InvoiceProjectFilterComponent,
3940
InvoiceProjectsSelectorComponent,
4041
InvoiceTasksSelectorComponent
4142
} from '../../table-components';
@@ -282,6 +283,16 @@ export class InvoiceEditByRoleComponent extends PaginationFilterBaseComponent im
282283
},
283284
valuePrepareFunction: (project: IOrganizationProject) => {
284285
return project?.name || '';
286+
},
287+
filter: {
288+
type: 'custom',
289+
component: InvoiceProjectFilterComponent
290+
},
291+
filterFunction: (value, search) => {
292+
if (!search || !value) {
293+
return true;
294+
}
295+
return value.name.toLowerCase().includes(search.toLowerCase());
285296
}
286297
};
287298
break;

apps/gauzy/src/app/pages/invoices/table-components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './invoice-paid.component';
55
export * from './invoice-product-selector.component';
66
export * from './invoice-project-selector.component';
77
export * from './invoice-tasks-selector.component';
8+
export * from './invoice-project-filter.component';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Component, Input, OnChanges } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
import { UntilDestroy } from '@ngneat/until-destroy';
4+
import { TranslateModule } from '@ngx-translate/core';
5+
import { DefaultFilter } from 'angular2-smart-table';
6+
7+
@UntilDestroy({ checkProperties: true })
8+
@Component({
9+
template: `
10+
<input
11+
nbInput
12+
fullWidth
13+
placeholder="{{ 'INVOICES_PAGE.INVOICE_ITEM.PROJECT' | translate }}"
14+
[(ngModel)]="query"
15+
(ngModelChange)="applyFilter($event)"
16+
/>
17+
`,
18+
styles: [],
19+
standalone: true,
20+
imports: [FormsModule, TranslateModule]
21+
})
22+
export class InvoiceProjectFilterComponent extends DefaultFilter implements OnChanges {
23+
@Input() query: string;
24+
25+
ngOnChanges() {
26+
// smart table require OnChanges
27+
}
28+
applyFilter(value: string) {
29+
this.query = value;
30+
this.setFilter();
31+
}
32+
}

0 commit comments

Comments
 (0)