@sneat/extension-splitus-ui 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/{sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs → sneat-extension-splitus-ui-new-split-page.component-DqIE_0pp.mjs} +8 -8
- package/fesm2022/sneat-extension-splitus-ui-new-split-page.component-DqIE_0pp.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-sneat-extension-splitus-ui-CX1eiAAR.mjs +558 -0
- package/fesm2022/sneat-extension-splitus-ui-sneat-extension-splitus-ui-CX1eiAAR.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-split-details-page.component-fkoVkmXD.mjs +105 -0
- package/fesm2022/sneat-extension-splitus-ui-split-details-page.component-fkoVkmXD.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-split-status-view-CRt2FKg1.mjs +7 -0
- package/fesm2022/sneat-extension-splitus-ui-split-status-view-CRt2FKg1.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-splitus-home-page.component-tUSn27LG.mjs +100 -0
- package/fesm2022/sneat-extension-splitus-ui-splitus-home-page.component-tUSn27LG.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-start-split-page.component-Br8thaKs.mjs +74 -0
- package/fesm2022/sneat-extension-splitus-ui-start-split-page.component-Br8thaKs.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui.mjs +1 -468
- package/fesm2022/sneat-extension-splitus-ui.mjs.map +1 -1
- package/package.json +3 -2
- package/types/sneat-extension-splitus-ui.d.ts +45 -30
- package/fesm2022/sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs.map +0 -1
- package/fesm2022/sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs +0 -87
- package/fesm2022/sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs.map +0 -1
- package/fesm2022/sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs +0 -44
- package/fesm2022/sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs.map +0 -1
- package/fesm2022/sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs +0 -99
- package/fesm2022/sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs.map +0 -1
- package/fesm2022/sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs +0 -67
- package/fesm2022/sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs.map +0 -1
|
@@ -2,22 +2,21 @@ import * as i0 from '@angular/core';
|
|
|
2
2
|
import { Component } from '@angular/core';
|
|
3
3
|
import { IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent } from '@ionic/angular';
|
|
4
4
|
import { SpacePageBaseComponent, SpaceComponentBaseParams } from '@sneat/space-components';
|
|
5
|
+
import { SpaceServiceModule } from '@sneat/space-services';
|
|
5
6
|
import { ClassName } from '@sneat/ui';
|
|
6
|
-
import { NewSplitFormComponent } from './sneat-extension-splitus-ui.mjs';
|
|
7
|
+
import { N as NewSplitFormComponent } from './sneat-extension-splitus-ui-sneat-extension-splitus-ui-CX1eiAAR.mjs';
|
|
7
8
|
|
|
8
|
-
// Create-
|
|
9
|
-
// Exit: the hosted form redirects to `split/:splitID` (details) on success
|
|
10
|
-
// with replaceUrl; Back/cancel returns to `splits`.
|
|
9
|
+
// Create-bill page. The form redirects to the persisted bill on success.
|
|
11
10
|
class NewSplitPageComponent extends SpacePageBaseComponent {
|
|
12
11
|
constructor() {
|
|
13
12
|
super();
|
|
14
|
-
this.$defaultBackUrlSpacePath.set('
|
|
13
|
+
this.$defaultBackUrlSpacePath.set('bills');
|
|
15
14
|
}
|
|
16
15
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
17
16
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.3", type: NewSplitPageComponent, isStandalone: true, selector: "sneat-splitus-new-split-page", providers: [
|
|
18
17
|
{ provide: ClassName, useValue: 'NewSplitPageComponent' },
|
|
19
18
|
SpaceComponentBaseParams,
|
|
20
|
-
], usesInheritance: true, ngImport: i0, template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New
|
|
19
|
+
], usesInheritance: true, ngImport: i0, template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New bill</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <sneat-splitus-new-split-form\n [space]=\"space\"\n [currentUserID]=\"currentUserId\"\n />\n</ion-content>\n", dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonBackButton, selector: "ion-back-button" }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"], outputs: ["ionScrollStart", "ionScroll", "ionScrollEnd"] }, { kind: "ngmodule", type: SpaceServiceModule }, { kind: "component", type: NewSplitFormComponent, selector: "sneat-splitus-new-split-form", inputs: ["space", "currentUserID"] }] });
|
|
21
20
|
}
|
|
22
21
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitPageComponent, decorators: [{
|
|
23
22
|
type: Component,
|
|
@@ -28,12 +27,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
|
|
|
28
27
|
IonBackButton,
|
|
29
28
|
IonTitle,
|
|
30
29
|
IonContent,
|
|
30
|
+
SpaceServiceModule,
|
|
31
31
|
NewSplitFormComponent,
|
|
32
32
|
], providers: [
|
|
33
33
|
{ provide: ClassName, useValue: 'NewSplitPageComponent' },
|
|
34
34
|
SpaceComponentBaseParams,
|
|
35
|
-
], template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New
|
|
35
|
+
], template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New bill</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <sneat-splitus-new-split-form\n [space]=\"space\"\n [currentUserID]=\"currentUserId\"\n />\n</ion-content>\n" }]
|
|
36
36
|
}], ctorParameters: () => [] });
|
|
37
37
|
|
|
38
38
|
export { NewSplitPageComponent };
|
|
39
|
-
//# sourceMappingURL=sneat-extension-splitus-ui-new-split-page.component-
|
|
39
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-new-split-page.component-DqIE_0pp.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui-new-split-page.component-DqIE_0pp.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/pages/new-split/new-split-page.component.ts","../../../../../../libs/extensions/splitus/ui/src/lib/pages/new-split/new-split-page.component.html"],"sourcesContent":["import { Component } from '@angular/core';\nimport {\n IonBackButton,\n IonButtons,\n IonContent,\n IonHeader,\n IonTitle,\n IonToolbar,\n} from '@ionic/angular';\nimport {\n SpaceComponentBaseParams,\n SpacePageBaseComponent,\n} from '@sneat/space-components';\nimport { SpaceServiceModule } from '@sneat/space-services';\nimport { ClassName } from '@sneat/ui';\nimport { NewSplitFormComponent } from '../../components';\n\n// Create-bill page. The form redirects to the persisted bill on success.\n@Component({\n selector: 'sneat-splitus-new-split-page',\n templateUrl: './new-split-page.component.html',\n imports: [\n IonHeader,\n IonToolbar,\n IonButtons,\n IonBackButton,\n IonTitle,\n IonContent,\n SpaceServiceModule,\n NewSplitFormComponent,\n ],\n providers: [\n { provide: ClassName, useValue: 'NewSplitPageComponent' },\n SpaceComponentBaseParams,\n ],\n})\nexport class NewSplitPageComponent extends SpacePageBaseComponent {\n constructor() {\n super();\n this.$defaultBackUrlSpacePath.set('bills');\n }\n}\n","<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New bill</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <sneat-splitus-new-split-form\n [space]=\"space\"\n [currentUserID]=\"currentUserId\"\n />\n</ion-content>\n"],"names":[],"mappings":";;;;;;;;AAiBA;AAmBM,MAAO,qBAAsB,SAAQ,sBAAsB,CAAA;AAC/D,IAAA,WAAA,GAAA;AACE,QAAA,KAAK,EAAE;AACP,QAAA,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,OAAO,CAAC;IAC5C;uGAJW,qBAAqB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAArB,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,qBAAqB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,8BAAA,EAAA,SAAA,EALrB;AACT,YAAA,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,uBAAuB,EAAE;YACzD,wBAAwB;AACzB,SAAA,EAAA,eAAA,EAAA,IAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,EClCH,kYAeA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDOI,SAAS,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,aAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACT,UAAU,mFACV,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,UAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACV,aAAa,EAAA,QAAA,EAAA,iBAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACb,QAAQ,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACR,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,oBAAA,EAAA,iBAAA,EAAA,YAAA,EAAA,cAAA,EAAA,SAAA,EAAA,SAAA,CAAA,EAAA,OAAA,EAAA,CAAA,gBAAA,EAAA,WAAA,EAAA,cAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,EACV,kBAAkB,+BAClB,qBAAqB,EAAA,QAAA,EAAA,8BAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,eAAA,CAAA,EAAA,CAAA,EAAA,CAAA;;2FAOZ,qBAAqB,EAAA,UAAA,EAAA,CAAA;kBAlBjC,SAAS;AACE,YAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,8BAA8B,EAAA,OAAA,EAE/B;wBACP,SAAS;wBACT,UAAU;wBACV,UAAU;wBACV,aAAa;wBACb,QAAQ;wBACR,UAAU;wBACV,kBAAkB;wBAClB,qBAAqB;qBACtB,EAAA,SAAA,EACU;AACT,wBAAA,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,uBAAuB,EAAE;wBACzD,wBAAwB;AACzB,qBAAA,EAAA,QAAA,EAAA,kYAAA,EAAA;;;;;"}
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Injectable, inject, DestroyRef, signal, computed, Input, Component, InjectionToken } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
4
|
+
import * as i1 from '@angular/forms';
|
|
5
|
+
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
6
|
+
import { IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonItem, IonLabel, IonNote, IonSelect, IonSelectOption, IonInput, IonCheckbox, IonSegment, IonSegmentButton, IonButton, IonSpinner } from '@ionic/angular';
|
|
7
|
+
import { ErrorLogger } from '@sneat/core';
|
|
8
|
+
import { CONTACTUS_SPACE_SERVICE } from '@sneat/extension-contactus-contract';
|
|
9
|
+
import { SPLITUS_BILL_SERVICE_V1, SPLITUS_BILL_CONTRACT_VERSION } from '@sneat/extension-splitus-contract';
|
|
10
|
+
import { newRandomId } from '@sneat/random';
|
|
11
|
+
import { SpaceNavService } from '@sneat/space-services';
|
|
12
|
+
|
|
13
|
+
const DRAFT_SPLIT_STORAGE_KEY = 'sneat.splitus.draftSplit';
|
|
14
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
15
|
+
const CURRENCY_CODES = ['EUR', 'GBP', 'USD'];
|
|
16
|
+
function isDraftSplit(value) {
|
|
17
|
+
if (typeof value !== 'object' || value === null) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const v = value;
|
|
21
|
+
return (typeof v['billID'] === 'string' &&
|
|
22
|
+
v['billID'].length > 0 &&
|
|
23
|
+
typeof v['title'] === 'string' &&
|
|
24
|
+
typeof v['amount'] === 'string' &&
|
|
25
|
+
CURRENCY_CODES.includes(v['currencyCode']) &&
|
|
26
|
+
typeof v['createdAt'] === 'number');
|
|
27
|
+
}
|
|
28
|
+
class DraftSplitStore {
|
|
29
|
+
save(draft) {
|
|
30
|
+
const full = { ...draft, createdAt: Date.now() };
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(DRAFT_SPLIT_STORAGE_KEY, JSON.stringify(full));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Storage unavailable (private mode, quota) — fail open, never block
|
|
36
|
+
// navigation over a lost draft.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
read() {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = localStorage.getItem(DRAFT_SPLIT_STORAGE_KEY);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (!raw) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (!isDraftSplit(parsed) || Date.now() - parsed.createdAt > MAX_AGE_MS) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
hasPending() {
|
|
63
|
+
return this.read() !== undefined;
|
|
64
|
+
}
|
|
65
|
+
clear() {
|
|
66
|
+
try {
|
|
67
|
+
localStorage.removeItem(DRAFT_SPLIT_STORAGE_KEY);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DraftSplitStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
74
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DraftSplitStore, providedIn: 'root' });
|
|
75
|
+
}
|
|
76
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DraftSplitStore, decorators: [{
|
|
77
|
+
type: Injectable,
|
|
78
|
+
args: [{ providedIn: 'root' }]
|
|
79
|
+
}] });
|
|
80
|
+
|
|
81
|
+
const DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.(\d{1,2}))?$/;
|
|
82
|
+
const ZERO = BigInt('0');
|
|
83
|
+
const HUNDRED = BigInt('100');
|
|
84
|
+
/** Parses a non-negative decimal string into exact integer hundredths. */
|
|
85
|
+
function parseDecimal(raw) {
|
|
86
|
+
const value = raw?.trim();
|
|
87
|
+
if (!value) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const match = DECIMAL_PATTERN.exec(value);
|
|
91
|
+
if (!match) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const [whole = '', fraction = ''] = value.split('.');
|
|
95
|
+
return BigInt(whole) * HUNDRED + BigInt(fraction.padEnd(2, '0') || '0');
|
|
96
|
+
}
|
|
97
|
+
/** Formats exact integer hundredths as a two-decimal wire string. */
|
|
98
|
+
function formatHundredths(value) {
|
|
99
|
+
const sign = value < ZERO ? '-' : '';
|
|
100
|
+
const absolute = value < ZERO ? -value : value;
|
|
101
|
+
const whole = absolute / HUNDRED;
|
|
102
|
+
const fraction = (absolute % HUNDRED).toString().padStart(2, '0');
|
|
103
|
+
return `${sign}${whole.toString()}.${fraction}`;
|
|
104
|
+
}
|
|
105
|
+
/** Formats basis points as a trimmed percent value. */
|
|
106
|
+
function formatPercentValue(value) {
|
|
107
|
+
const formatted = formatHundredths(value);
|
|
108
|
+
if (formatted.endsWith('.00')) {
|
|
109
|
+
return formatted.slice(0, -3);
|
|
110
|
+
}
|
|
111
|
+
return formatted.endsWith('0') ? formatted.slice(0, -1) : formatted;
|
|
112
|
+
}
|
|
113
|
+
const positiveExactDecimalValidator = (control) => {
|
|
114
|
+
const value = parseDecimal(control.value);
|
|
115
|
+
return value !== undefined && value > ZERO ? null : { exactDecimal: true };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Live reconciliation of custom shares against the expense total.
|
|
120
|
+
*
|
|
121
|
+
* - `equally`: always ok — there are no share inputs; the client calculates
|
|
122
|
+
* exact allocations before submitting the bill.
|
|
123
|
+
* - `exact-amount`: every share must be entered and sum exactly to the total.
|
|
124
|
+
* - `percentage`: every percentage must be entered and sum exactly to 100%.
|
|
125
|
+
*
|
|
126
|
+
* @param totalHundredths the expense total in hundredths (cents), if entered.
|
|
127
|
+
* @param shares raw share inputs, one per participant (payer included).
|
|
128
|
+
*/
|
|
129
|
+
function reconcileShares(mode, totalHundredths, shares) {
|
|
130
|
+
if (mode === 'equally') {
|
|
131
|
+
return { ok: true };
|
|
132
|
+
}
|
|
133
|
+
const parsed = shares.map(parseDecimal);
|
|
134
|
+
if (parsed.some((v) => v === undefined)) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: mode === 'percentage'
|
|
138
|
+
? 'Enter a percentage for every participant.'
|
|
139
|
+
: 'Enter a share for every participant.',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const entered = parsed.reduce((sum, value) => sum + value, BigInt('0'));
|
|
143
|
+
if (mode === 'percentage') {
|
|
144
|
+
if (entered === BigInt('10000')) {
|
|
145
|
+
return { ok: true };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: `Percentages sum to ${formatPercentValue(entered)}% — they must sum to exactly 100%.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// exact-amount
|
|
153
|
+
if (totalHundredths === undefined) {
|
|
154
|
+
return { ok: false, error: 'Enter the expense total first.' };
|
|
155
|
+
}
|
|
156
|
+
if (entered === totalHundredths) {
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
const delta = entered - totalHundredths;
|
|
160
|
+
const direction = delta < BigInt('0')
|
|
161
|
+
? `${formatHundredths(-delta)} short of`
|
|
162
|
+
: `${formatHundredths(delta)} over`;
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: `Shares sum to ${formatHundredths(entered)} — ${direction} the ${formatHundredths(totalHundredths)} total.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractServerMessage(err) {
|
|
170
|
+
if (typeof err !== 'object' || !err) {
|
|
171
|
+
return typeof err === 'string' && err.trim() ? err.trim() : undefined;
|
|
172
|
+
}
|
|
173
|
+
const e = err;
|
|
174
|
+
if (typeof e.error === 'string' && e.error.trim()) {
|
|
175
|
+
return e.error.trim();
|
|
176
|
+
}
|
|
177
|
+
if (typeof e.error === 'object' && e.error) {
|
|
178
|
+
const message = e.error.message;
|
|
179
|
+
if (typeof message === 'string' && message.trim()) {
|
|
180
|
+
return message.trim();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return typeof e.message === 'string' && e.message.trim()
|
|
184
|
+
? e.message.trim()
|
|
185
|
+
: undefined;
|
|
186
|
+
}
|
|
187
|
+
function distribute(total, weights) {
|
|
188
|
+
const weightTotal = weights.reduce((sum, value) => sum + value, 0n);
|
|
189
|
+
if (weightTotal <= 0n) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const amounts = weights.map((weight) => (total * weight) / weightTotal);
|
|
193
|
+
let remainder = total - amounts.reduce((sum, value) => sum + value, 0n);
|
|
194
|
+
for (let index = 0; remainder > 0n; index = (index + 1) % amounts.length) {
|
|
195
|
+
amounts[index] += 1n;
|
|
196
|
+
remainder -= 1n;
|
|
197
|
+
}
|
|
198
|
+
return amounts;
|
|
199
|
+
}
|
|
200
|
+
class NewSplitFormComponent {
|
|
201
|
+
errorLogger = inject(ErrorLogger);
|
|
202
|
+
splitusService = inject(SPLITUS_BILL_SERVICE_V1);
|
|
203
|
+
contactusSpaceService = inject(CONTACTUS_SPACE_SERVICE);
|
|
204
|
+
spaceNavService = inject(SpaceNavService);
|
|
205
|
+
destroyRef = inject(DestroyRef);
|
|
206
|
+
draftSplitStore = inject(DraftSplitStore);
|
|
207
|
+
$resumedFromDraft = signal(false, /* @ts-ignore */
|
|
208
|
+
...(ngDevMode ? [{ debugName: "$resumedFromDraft" }] : /* istanbul ignore next */ []));
|
|
209
|
+
$billID = signal(newRandomId({ len: 20 }), /* @ts-ignore */
|
|
210
|
+
...(ngDevMode ? [{ debugName: "$billID" }] : /* istanbul ignore next */ []));
|
|
211
|
+
draftChecked = false;
|
|
212
|
+
$space = signal(undefined, /* @ts-ignore */
|
|
213
|
+
...(ngDevMode ? [{ debugName: "$space" }] : /* istanbul ignore next */ []));
|
|
214
|
+
set space(value) {
|
|
215
|
+
this.$space.set(value);
|
|
216
|
+
this.watchContacts(value?.id);
|
|
217
|
+
this.tryResumeDraft();
|
|
218
|
+
}
|
|
219
|
+
$currentUserID = signal(undefined, /* @ts-ignore */
|
|
220
|
+
...(ngDevMode ? [{ debugName: "$currentUserID" }] : /* istanbul ignore next */ []));
|
|
221
|
+
set currentUserID(value) {
|
|
222
|
+
this.$currentUserID.set(value);
|
|
223
|
+
}
|
|
224
|
+
tryResumeDraft() {
|
|
225
|
+
if (this.draftChecked) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.draftChecked = true;
|
|
229
|
+
const draft = this.draftSplitStore.read();
|
|
230
|
+
if (!draft) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.$billID.set(draft.billID);
|
|
234
|
+
this.title.setValue(draft.title);
|
|
235
|
+
this.currency.setValue(draft.currencyCode);
|
|
236
|
+
this.amount.setValue(draft.amount);
|
|
237
|
+
this.$resumedFromDraft.set(true);
|
|
238
|
+
}
|
|
239
|
+
dismissDraftBanner() {
|
|
240
|
+
this.$resumedFromDraft.set(false);
|
|
241
|
+
this.draftSplitStore.clear();
|
|
242
|
+
}
|
|
243
|
+
contactsSubscription;
|
|
244
|
+
watchedSpaceID;
|
|
245
|
+
$contacts = signal(undefined, /* @ts-ignore */
|
|
246
|
+
...(ngDevMode ? [{ debugName: "$contacts" }] : /* istanbul ignore next */ []));
|
|
247
|
+
$contactsError = signal(undefined, /* @ts-ignore */
|
|
248
|
+
...(ngDevMode ? [{ debugName: "$contactsError" }] : /* istanbul ignore next */ []));
|
|
249
|
+
watchContacts(spaceID) {
|
|
250
|
+
if (spaceID === this.watchedSpaceID) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.watchedSpaceID = spaceID;
|
|
254
|
+
this.contactsSubscription?.unsubscribe();
|
|
255
|
+
this.$contacts.set(undefined);
|
|
256
|
+
this.$contactsError.set(undefined);
|
|
257
|
+
if (!spaceID) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.contactsSubscription = this.contactusSpaceService
|
|
261
|
+
.watchContactBriefs(spaceID)
|
|
262
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
263
|
+
.subscribe({
|
|
264
|
+
next: (contacts) => this.$contacts.set(contacts),
|
|
265
|
+
error: (err) => {
|
|
266
|
+
this.$contactsError.set('Failed to load contacts.');
|
|
267
|
+
this.errorLogger.logError(err, 'Failed to load space contacts', {
|
|
268
|
+
show: false,
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
$payer = computed(() => {
|
|
274
|
+
const userID = this.$currentUserID();
|
|
275
|
+
const contact = this.$contacts()?.find((item) => item.brief?.userID === userID);
|
|
276
|
+
return contact
|
|
277
|
+
? {
|
|
278
|
+
id: contact.id,
|
|
279
|
+
title: contact.brief?.title || contact.brief?.shortTitle || 'Your contact',
|
|
280
|
+
}
|
|
281
|
+
: undefined;
|
|
282
|
+
}, /* @ts-ignore */
|
|
283
|
+
...(ngDevMode ? [{ debugName: "$payer" }] : /* istanbul ignore next */ []));
|
|
284
|
+
$participantChoices = computed(() => {
|
|
285
|
+
const payerID = this.$payer()?.id;
|
|
286
|
+
return this.$contacts()
|
|
287
|
+
?.filter((contact) => contact.id !== payerID)
|
|
288
|
+
.map((contact) => ({
|
|
289
|
+
id: contact.id,
|
|
290
|
+
title: contact.brief?.title || contact.brief?.shortTitle || 'Unnamed contact',
|
|
291
|
+
}));
|
|
292
|
+
}, /* @ts-ignore */
|
|
293
|
+
...(ngDevMode ? [{ debugName: "$participantChoices" }] : /* istanbul ignore next */ []));
|
|
294
|
+
$selectedContactIDs = signal([], /* @ts-ignore */
|
|
295
|
+
...(ngDevMode ? [{ debugName: "$selectedContactIDs" }] : /* istanbul ignore next */ []));
|
|
296
|
+
onParticipantToggle(contactID, checked) {
|
|
297
|
+
this.$selectedContactIDs.update((ids) => {
|
|
298
|
+
const without = ids.filter((id) => id !== contactID);
|
|
299
|
+
return checked ? [...without, contactID] : without;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
onParticipantChecked(contactID, event) {
|
|
303
|
+
const checked = !!event.detail?.checked;
|
|
304
|
+
this.onParticipantToggle(contactID, checked);
|
|
305
|
+
}
|
|
306
|
+
title = new FormControl('', { nonNullable: true });
|
|
307
|
+
currency = new FormControl('EUR', {
|
|
308
|
+
nonNullable: true,
|
|
309
|
+
});
|
|
310
|
+
amount = new FormControl('', {
|
|
311
|
+
nonNullable: true,
|
|
312
|
+
validators: [positiveExactDecimalValidator],
|
|
313
|
+
});
|
|
314
|
+
billKind = new FormControl('general', {
|
|
315
|
+
nonNullable: true,
|
|
316
|
+
});
|
|
317
|
+
utilityKind = new FormControl('electricity', { nonNullable: true });
|
|
318
|
+
providerName = new FormControl('', { nonNullable: true });
|
|
319
|
+
periodStart = new FormControl('', { nonNullable: true });
|
|
320
|
+
periodEnd = new FormControl('', { nonNullable: true });
|
|
321
|
+
$amount = toSignal(this.amount.valueChanges, {
|
|
322
|
+
initialValue: this.amount.value,
|
|
323
|
+
});
|
|
324
|
+
$billKind = toSignal(this.billKind.valueChanges, {
|
|
325
|
+
initialValue: this.billKind.value,
|
|
326
|
+
});
|
|
327
|
+
$periodStart = toSignal(this.periodStart.valueChanges, {
|
|
328
|
+
initialValue: this.periodStart.value,
|
|
329
|
+
});
|
|
330
|
+
$periodEnd = toSignal(this.periodEnd.valueChanges, {
|
|
331
|
+
initialValue: this.periodEnd.value,
|
|
332
|
+
});
|
|
333
|
+
currencies = [
|
|
334
|
+
'EUR',
|
|
335
|
+
'GBP',
|
|
336
|
+
'USD',
|
|
337
|
+
];
|
|
338
|
+
utilityKinds = [
|
|
339
|
+
{ value: 'electricity', title: 'Electricity' },
|
|
340
|
+
{ value: 'gas', title: 'Gas' },
|
|
341
|
+
{ value: 'water', title: 'Water' },
|
|
342
|
+
{ value: 'internet', title: 'Internet' },
|
|
343
|
+
{ value: 'other', title: 'Other' },
|
|
344
|
+
];
|
|
345
|
+
$splitMode = signal('equally', /* @ts-ignore */
|
|
346
|
+
...(ngDevMode ? [{ debugName: "$splitMode" }] : /* istanbul ignore next */ []));
|
|
347
|
+
onSplitModeChange(event) {
|
|
348
|
+
const value = event.detail?.value;
|
|
349
|
+
if (value === 'equally' || value === 'exact-amount' || value === 'percentage') {
|
|
350
|
+
this.$splitMode.set(value);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
$shareValues = signal({}, /* @ts-ignore */
|
|
354
|
+
...(ngDevMode ? [{ debugName: "$shareValues" }] : /* istanbul ignore next */ []));
|
|
355
|
+
shareValue(contactID) {
|
|
356
|
+
return this.$shareValues()[contactID] ?? '';
|
|
357
|
+
}
|
|
358
|
+
onShareInput(contactID, event) {
|
|
359
|
+
const value = event.detail?.value;
|
|
360
|
+
this.$shareValues.update((values) => ({
|
|
361
|
+
...values,
|
|
362
|
+
[contactID]: value ?? '',
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
$shareRows = computed(() => {
|
|
366
|
+
const payer = this.$payer();
|
|
367
|
+
if (!payer) {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
const choices = this.$participantChoices() ?? [];
|
|
371
|
+
const selected = this.$selectedContactIDs();
|
|
372
|
+
return [payer, ...choices.filter((choice) => selected.includes(choice.id))];
|
|
373
|
+
}, /* @ts-ignore */
|
|
374
|
+
...(ngDevMode ? [{ debugName: "$shareRows" }] : /* istanbul ignore next */ []));
|
|
375
|
+
$reconciliation = computed(() => reconcileShares(this.$splitMode(), parseDecimal(this.$amount()), this.$shareRows().map((row) => this.$shareValues()[row.id])), /* @ts-ignore */
|
|
376
|
+
...(ngDevMode ? [{ debugName: "$reconciliation" }] : /* istanbul ignore next */ []));
|
|
377
|
+
$utilityValid = computed(() => {
|
|
378
|
+
if (this.$billKind() !== 'utility') {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
const periodStart = this.$periodStart();
|
|
382
|
+
const periodEnd = this.$periodEnd();
|
|
383
|
+
return !!periodStart && !!periodEnd && periodEnd >= periodStart;
|
|
384
|
+
}, /* @ts-ignore */
|
|
385
|
+
...(ngDevMode ? [{ debugName: "$utilityValid" }] : /* istanbul ignore next */ []));
|
|
386
|
+
$canSubmit = computed(() => {
|
|
387
|
+
const total = parseDecimal(this.$amount());
|
|
388
|
+
return (!!this.$currentUserID() &&
|
|
389
|
+
!!this.$payer() &&
|
|
390
|
+
total !== undefined &&
|
|
391
|
+
total > 0n &&
|
|
392
|
+
this.$selectedContactIDs().length > 0 &&
|
|
393
|
+
this.$reconciliation().ok &&
|
|
394
|
+
this.$utilityValid());
|
|
395
|
+
}, /* @ts-ignore */
|
|
396
|
+
...(ngDevMode ? [{ debugName: "$canSubmit" }] : /* istanbul ignore next */ []));
|
|
397
|
+
$submitting = signal(false, /* @ts-ignore */
|
|
398
|
+
...(ngDevMode ? [{ debugName: "$submitting" }] : /* istanbul ignore next */ []));
|
|
399
|
+
$submitError = signal(undefined, /* @ts-ignore */
|
|
400
|
+
...(ngDevMode ? [{ debugName: "$submitError" }] : /* istanbul ignore next */ []));
|
|
401
|
+
owedAllocations(total) {
|
|
402
|
+
const rows = this.$shareRows();
|
|
403
|
+
const mode = this.$splitMode();
|
|
404
|
+
let amounts;
|
|
405
|
+
if (mode === 'exact-amount') {
|
|
406
|
+
amounts = rows.map((row) => parseDecimal(this.$shareValues()[row.id]) ?? 0n);
|
|
407
|
+
}
|
|
408
|
+
else if (mode === 'percentage') {
|
|
409
|
+
amounts = distribute(total, rows.map((row) => parseDecimal(this.$shareValues()[row.id]) ?? 0n));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
amounts = distribute(total, rows.map(() => 1n));
|
|
413
|
+
}
|
|
414
|
+
const billID = this.$billID();
|
|
415
|
+
return rows.map((row, index) => ({
|
|
416
|
+
allocationID: `owed-${billID}-${index + 1}`,
|
|
417
|
+
contactID: row.id,
|
|
418
|
+
amount: formatHundredths(amounts[index]),
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
submit() {
|
|
422
|
+
this.amount.markAsTouched();
|
|
423
|
+
if (this.$submitting() || !this.$canSubmit()) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const space = this.$space();
|
|
427
|
+
const userID = this.$currentUserID();
|
|
428
|
+
const payer = this.$payer();
|
|
429
|
+
const total = parseDecimal(this.$amount());
|
|
430
|
+
if (!space?.id || !userID || !payer || total === undefined) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const billID = this.$billID();
|
|
434
|
+
const billKind = this.billKind.value;
|
|
435
|
+
const request = {
|
|
436
|
+
contractVersion: SPLITUS_BILL_CONTRACT_VERSION,
|
|
437
|
+
spaceID: space.id,
|
|
438
|
+
billID,
|
|
439
|
+
recorderUserID: userID,
|
|
440
|
+
title: this.title.value.trim() || undefined,
|
|
441
|
+
billKind,
|
|
442
|
+
currency: this.currency.value,
|
|
443
|
+
actualAmount: formatHundredths(total),
|
|
444
|
+
paidAllocations: [
|
|
445
|
+
{
|
|
446
|
+
allocationID: `paid-${billID}-1`,
|
|
447
|
+
contactID: payer.id,
|
|
448
|
+
amount: formatHundredths(total),
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
owedAllocations: this.owedAllocations(total),
|
|
452
|
+
utility: billKind === 'utility'
|
|
453
|
+
? {
|
|
454
|
+
utilityKind: this.utilityKind.value,
|
|
455
|
+
providerName: this.providerName.value.trim() || undefined,
|
|
456
|
+
period: {
|
|
457
|
+
startDate: this.periodStart.value,
|
|
458
|
+
endDate: this.periodEnd.value,
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
: undefined,
|
|
462
|
+
};
|
|
463
|
+
this.$submitting.set(true);
|
|
464
|
+
this.$submitError.set(undefined);
|
|
465
|
+
this.splitusService.createBill(request).subscribe({
|
|
466
|
+
next: (response) => {
|
|
467
|
+
this.draftSplitStore.clear();
|
|
468
|
+
this.spaceNavService.navigateForwardToSpacePage(space, `bill/${response.bill.billID}`, { replaceUrl: true });
|
|
469
|
+
},
|
|
470
|
+
error: (err) => {
|
|
471
|
+
this.$submitting.set(false);
|
|
472
|
+
const serverMessage = extractServerMessage(err);
|
|
473
|
+
this.$submitError.set(serverMessage
|
|
474
|
+
? `Failed to create bill: ${serverMessage}`
|
|
475
|
+
: 'Failed to create bill. Please try again.');
|
|
476
|
+
this.errorLogger.logError(err, 'Failed to create Splitus bill', {
|
|
477
|
+
show: false,
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
483
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: NewSplitFormComponent, isStandalone: true, selector: "sneat-splitus-new-split-form", inputs: { space: "space", currentUserID: "currentUserID" }, ngImport: i0, template: "@if ($resumedFromDraft()) {\n <ion-card color=\"light\">\n <ion-card-content style=\"display: flex; align-items: center; justify-content: space-between; gap: 8px\">\n <ion-note color=\"medium\">Picking up your bill from earlier \u2014 edit anything below.</ion-note>\n <ion-button fill=\"clear\" size=\"small\" (click)=\"dismissDraftBanner()\">Dismiss</ion-button>\n </ion-card-content>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Bill</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. August electricity\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select data-testid=\"bill-kind\" label=\"Bill type\" interface=\"popover\" [formControl]=\"billKind\">\n <ion-select-option value=\"general\">General expense</ion-select-option>\n <ion-select-option value=\"utility\">Utility bill</ion-select-option>\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-select label=\"Currency\" interface=\"popover\" [formControl]=\"currency\">\n @for (currencyCode of currencies; track currencyCode) {\n <ion-select-option [value]=\"currencyCode\">{{ currencyCode }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount paid\"\n labelPlacement=\"stacked\"\n type=\"text\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">Enter an amount greater than zero.</ion-note>\n }\n</ion-card>\n\n@if ($billKind() === 'utility') {\n <ion-card>\n <ion-card-header>\n <ion-card-title>Utility details</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-select data-testid=\"utility-kind\" label=\"Utility\" interface=\"popover\" [formControl]=\"utilityKind\">\n @for (kind of utilityKinds; track kind.value) {\n <ion-select-option [value]=\"kind.value\">{{ kind.title }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n data-testid=\"provider-name\"\n label=\"Provider (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"providerName\"\n placeholder=\"e.g. Electric Ireland\"\n />\n </ion-item>\n <ion-item>\n <ion-input data-testid=\"period-start\" label=\"Period starts\" labelPlacement=\"stacked\" type=\"date\" [formControl]=\"periodStart\" />\n </ion-item>\n <ion-item>\n <ion-input data-testid=\"period-end\" label=\"Period ends\" labelPlacement=\"stacked\" type=\"date\" [formControl]=\"periodEnd\" />\n </ion-item>\n @if (periodStart.value && periodEnd.value && periodEnd.value < periodStart.value) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">The period end must be on or after its start.</ion-note>\n }\n <ion-item lines=\"none\">\n <ion-note color=\"medium\" class=\"ion-text-wrap\">\n This bill is recorded as a standalone occurrence. Recurring bill schedules will use Calendarius when that flow is available.\n </ion-note>\n </ion-item>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Housemates</ion-card-title>\n </ion-card-header>\n @if ($payer(); as payer) {\n <ion-item>\n <ion-label>{{ payer.title }} (you paid)</ion-label>\n <ion-note slot=\"end\" color=\"medium\">always included</ion-note>\n </ion-item>\n } @else if ($contacts()) {\n <ion-item lines=\"none\">\n <ion-label color=\"danger\" class=\"ion-text-wrap\">\n Your Contactus person is missing from this space. Add or link your person before recording a bill.\n </ion-label>\n </ion-item>\n }\n @for (contact of $participantChoices() ?? []; track contact.id) {\n <ion-item>\n <ion-checkbox\n labelPlacement=\"end\"\n justify=\"start\"\n [checked]=\"$selectedContactIDs().includes(contact.id)\"\n (ionChange)=\"onParticipantChecked(contact.id, $event)\"\n >{{ contact.title }}</ion-checkbox>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n @if ($contactsError()) {\n <ion-label color=\"danger\">{{ $contactsError() }}</ion-label>\n } @else if (!$contacts()) {\n <ion-label color=\"medium\">Loading real space contacts\u2026</ion-label>\n } @else {\n <ion-label color=\"medium\" class=\"ion-text-wrap\">\n No other contacts yet. Add your housemates to this space first.\n </ion-label>\n }\n </ion-item>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Who owes what</ion-card-title>\n </ion-card-header>\n <ion-segment [value]=\"$splitMode()\" (ionChange)=\"onSplitModeChange($event)\">\n <ion-segment-button value=\"equally\"><ion-label>Equally</ion-label></ion-segment-button>\n <ion-segment-button value=\"exact-amount\"><ion-label>Amounts</ion-label></ion-segment-button>\n <ion-segment-button value=\"percentage\"><ion-label>Percentages</ion-label></ion-segment-button>\n </ion-segment>\n\n @if ($splitMode() !== 'equally') {\n @for (row of $shareRows(); track row.id) {\n <ion-item>\n <ion-input\n [label]=\"row.title\"\n labelPlacement=\"stacked\"\n type=\"text\"\n inputmode=\"decimal\"\n [placeholder]=\"$splitMode() === 'percentage' ? '0' : '0.00'\"\n [value]=\"shareValue(row.id)\"\n (ionInput)=\"onShareInput(row.id, $event)\"\n />\n @if ($splitMode() === 'percentage') { <ion-note slot=\"end\">%</ion-note> }\n </ion-item>\n }\n @if ($reconciliation(); as reconciliation) {\n <ion-note [color]=\"reconciliation.ok ? 'success' : 'danger'\" class=\"ion-padding-start\">\n {{ reconciliation.ok ? 'Shares reconcile to the total. \u2713' : reconciliation.error }}\n </ion-note>\n }\n } @else {\n <ion-item lines=\"none\">\n <ion-note color=\"medium\">The total is divided exactly between you and the selected housemates.</ion-note>\n </ion-item>\n }\n</ion-card>\n\n<div class=\"ion-padding\">\n <ion-button expand=\"block\" color=\"primary\" [disabled]=\"$submitting() || !$canSubmit()\" (click)=\"submit()\">\n @if ($submitting()) {\n <ion-spinner name=\"dots\" slot=\"start\" />\n Recording\u2026\n } @else {\n Record bill\n }\n </ion-button>\n @if ($submitError()) { <ion-note color=\"danger\">{{ $submitError() }}</ion-note> }\n</div>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonCard, selector: "ion-card", inputs: ["button", "color", "disabled", "download", "href", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonCardHeader, selector: "ion-card-header", inputs: ["color", "mode", "translucent"] }, { kind: "component", type: IonCardTitle, selector: "ion-card-title", inputs: ["color", "mode"] }, { kind: "component", type: IonCardContent, selector: "ion-card-content", inputs: ["mode"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonLabel, selector: "ion-label", inputs: ["color", "mode", "position"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonSelect, selector: "ion-select", inputs: ["cancelText", "color", "compareWith", "disabled", "errorText", "expandedIcon", "fill", "helperText", "interface", "interfaceOptions", "justify", "label", "labelPlacement", "mode", "multiple", "name", "okText", "placeholder", "selectedText", "shape", "toggleIcon", "value"] }, { kind: "component", type: IonSelectOption, selector: "ion-select-option", inputs: ["description", "disabled", "justify", "labelPlacement", "mode", "value"] }, { kind: "component", type: IonInput, selector: "ion-input", inputs: ["accept", "autocapitalize", "autocomplete", "autocorrect", "autofocus", "clearInput", "clearOnEdit", "color", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "max", "maxlength", "min", "minlength", "mode", "multiple", "name", "pattern", "placeholder", "readonly", "required", "shape", "size", "spellcheck", "step", "type", "value"] }, { kind: "component", type: IonCheckbox, selector: "ion-checkbox", inputs: ["checked", "color", "disabled", "errorText", "helperText", "indeterminate", "justify", "labelPlacement", "mode", "name", "value"] }, { kind: "component", type: IonSegment, selector: "ion-segment", inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] }, { kind: "component", type: IonSegmentButton, selector: "ion-segment-button", inputs: ["contentId", "disabled", "layout", "mode", "type", "value"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"], outputs: ["ionFocus", "ionBlur"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }] });
|
|
484
|
+
}
|
|
485
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitFormComponent, decorators: [{
|
|
486
|
+
type: Component,
|
|
487
|
+
args: [{ selector: 'sneat-splitus-new-split-form', imports: [
|
|
488
|
+
ReactiveFormsModule,
|
|
489
|
+
IonCard,
|
|
490
|
+
IonCardHeader,
|
|
491
|
+
IonCardTitle,
|
|
492
|
+
IonCardContent,
|
|
493
|
+
IonItem,
|
|
494
|
+
IonLabel,
|
|
495
|
+
IonNote,
|
|
496
|
+
IonSelect,
|
|
497
|
+
IonSelectOption,
|
|
498
|
+
IonInput,
|
|
499
|
+
IonCheckbox,
|
|
500
|
+
IonSegment,
|
|
501
|
+
IonSegmentButton,
|
|
502
|
+
IonButton,
|
|
503
|
+
IonSpinner,
|
|
504
|
+
], template: "@if ($resumedFromDraft()) {\n <ion-card color=\"light\">\n <ion-card-content style=\"display: flex; align-items: center; justify-content: space-between; gap: 8px\">\n <ion-note color=\"medium\">Picking up your bill from earlier \u2014 edit anything below.</ion-note>\n <ion-button fill=\"clear\" size=\"small\" (click)=\"dismissDraftBanner()\">Dismiss</ion-button>\n </ion-card-content>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Bill</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. August electricity\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select data-testid=\"bill-kind\" label=\"Bill type\" interface=\"popover\" [formControl]=\"billKind\">\n <ion-select-option value=\"general\">General expense</ion-select-option>\n <ion-select-option value=\"utility\">Utility bill</ion-select-option>\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-select label=\"Currency\" interface=\"popover\" [formControl]=\"currency\">\n @for (currencyCode of currencies; track currencyCode) {\n <ion-select-option [value]=\"currencyCode\">{{ currencyCode }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount paid\"\n labelPlacement=\"stacked\"\n type=\"text\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">Enter an amount greater than zero.</ion-note>\n }\n</ion-card>\n\n@if ($billKind() === 'utility') {\n <ion-card>\n <ion-card-header>\n <ion-card-title>Utility details</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-select data-testid=\"utility-kind\" label=\"Utility\" interface=\"popover\" [formControl]=\"utilityKind\">\n @for (kind of utilityKinds; track kind.value) {\n <ion-select-option [value]=\"kind.value\">{{ kind.title }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n data-testid=\"provider-name\"\n label=\"Provider (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"providerName\"\n placeholder=\"e.g. Electric Ireland\"\n />\n </ion-item>\n <ion-item>\n <ion-input data-testid=\"period-start\" label=\"Period starts\" labelPlacement=\"stacked\" type=\"date\" [formControl]=\"periodStart\" />\n </ion-item>\n <ion-item>\n <ion-input data-testid=\"period-end\" label=\"Period ends\" labelPlacement=\"stacked\" type=\"date\" [formControl]=\"periodEnd\" />\n </ion-item>\n @if (periodStart.value && periodEnd.value && periodEnd.value < periodStart.value) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">The period end must be on or after its start.</ion-note>\n }\n <ion-item lines=\"none\">\n <ion-note color=\"medium\" class=\"ion-text-wrap\">\n This bill is recorded as a standalone occurrence. Recurring bill schedules will use Calendarius when that flow is available.\n </ion-note>\n </ion-item>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Housemates</ion-card-title>\n </ion-card-header>\n @if ($payer(); as payer) {\n <ion-item>\n <ion-label>{{ payer.title }} (you paid)</ion-label>\n <ion-note slot=\"end\" color=\"medium\">always included</ion-note>\n </ion-item>\n } @else if ($contacts()) {\n <ion-item lines=\"none\">\n <ion-label color=\"danger\" class=\"ion-text-wrap\">\n Your Contactus person is missing from this space. Add or link your person before recording a bill.\n </ion-label>\n </ion-item>\n }\n @for (contact of $participantChoices() ?? []; track contact.id) {\n <ion-item>\n <ion-checkbox\n labelPlacement=\"end\"\n justify=\"start\"\n [checked]=\"$selectedContactIDs().includes(contact.id)\"\n (ionChange)=\"onParticipantChecked(contact.id, $event)\"\n >{{ contact.title }}</ion-checkbox>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n @if ($contactsError()) {\n <ion-label color=\"danger\">{{ $contactsError() }}</ion-label>\n } @else if (!$contacts()) {\n <ion-label color=\"medium\">Loading real space contacts\u2026</ion-label>\n } @else {\n <ion-label color=\"medium\" class=\"ion-text-wrap\">\n No other contacts yet. Add your housemates to this space first.\n </ion-label>\n }\n </ion-item>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Who owes what</ion-card-title>\n </ion-card-header>\n <ion-segment [value]=\"$splitMode()\" (ionChange)=\"onSplitModeChange($event)\">\n <ion-segment-button value=\"equally\"><ion-label>Equally</ion-label></ion-segment-button>\n <ion-segment-button value=\"exact-amount\"><ion-label>Amounts</ion-label></ion-segment-button>\n <ion-segment-button value=\"percentage\"><ion-label>Percentages</ion-label></ion-segment-button>\n </ion-segment>\n\n @if ($splitMode() !== 'equally') {\n @for (row of $shareRows(); track row.id) {\n <ion-item>\n <ion-input\n [label]=\"row.title\"\n labelPlacement=\"stacked\"\n type=\"text\"\n inputmode=\"decimal\"\n [placeholder]=\"$splitMode() === 'percentage' ? '0' : '0.00'\"\n [value]=\"shareValue(row.id)\"\n (ionInput)=\"onShareInput(row.id, $event)\"\n />\n @if ($splitMode() === 'percentage') { <ion-note slot=\"end\">%</ion-note> }\n </ion-item>\n }\n @if ($reconciliation(); as reconciliation) {\n <ion-note [color]=\"reconciliation.ok ? 'success' : 'danger'\" class=\"ion-padding-start\">\n {{ reconciliation.ok ? 'Shares reconcile to the total. \u2713' : reconciliation.error }}\n </ion-note>\n }\n } @else {\n <ion-item lines=\"none\">\n <ion-note color=\"medium\">The total is divided exactly between you and the selected housemates.</ion-note>\n </ion-item>\n }\n</ion-card>\n\n<div class=\"ion-padding\">\n <ion-button expand=\"block\" color=\"primary\" [disabled]=\"$submitting() || !$canSubmit()\" (click)=\"submit()\">\n @if ($submitting()) {\n <ion-spinner name=\"dots\" slot=\"start\" />\n Recording\u2026\n } @else {\n Record bill\n }\n </ion-button>\n @if ($submitError()) { <ion-note color=\"danger\">{{ $submitError() }}</ion-note> }\n</div>\n" }]
|
|
505
|
+
}], propDecorators: { space: [{
|
|
506
|
+
type: Input,
|
|
507
|
+
args: [{ required: true }]
|
|
508
|
+
}], currentUserID: [{
|
|
509
|
+
type: Input
|
|
510
|
+
}] } });
|
|
511
|
+
|
|
512
|
+
// Splitus bill routes, mounted under space/:spaceType/:spaceID.
|
|
513
|
+
const spacePagesRoutes = [
|
|
514
|
+
{
|
|
515
|
+
path: 'bills',
|
|
516
|
+
data: { title: 'Bills' },
|
|
517
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-splitus-home-page.component-tUSn27LG.mjs').then((m) => m.SplitusHomePageComponent),
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
path: 'new-bill',
|
|
521
|
+
data: { title: 'New bill' },
|
|
522
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-new-split-page.component-DqIE_0pp.mjs').then((m) => m.NewSplitPageComponent),
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
path: 'bill/:billID',
|
|
526
|
+
data: { title: 'Bill' },
|
|
527
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-split-details-page.component-fkoVkmXD.mjs').then((m) => m.SplitDetailsPageComponent),
|
|
528
|
+
},
|
|
529
|
+
{ path: 'splits', pathMatch: 'full', redirectTo: 'bills' },
|
|
530
|
+
{ path: 'new-split', pathMatch: 'full', redirectTo: 'new-bill' },
|
|
531
|
+
{ path: 'split/:billID', redirectTo: 'bill/:billID' },
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
// Unguarded entry point for a first-time visitor to start a bill before
|
|
535
|
+
// signing in. Deliberately NOT space-scoped — kept separate from
|
|
536
|
+
// spacePagesRoutes, which mounts under space/:spaceType/:spaceID.
|
|
537
|
+
const startBillRoute = {
|
|
538
|
+
path: 'start-bill',
|
|
539
|
+
data: { title: 'Split a bill' },
|
|
540
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-start-split-page.component-Br8thaKs.mjs').then((m) => m.StartSplitPageComponent),
|
|
541
|
+
};
|
|
542
|
+
const legacyStartSplitRoute = {
|
|
543
|
+
path: 'start-split',
|
|
544
|
+
pathMatch: 'full',
|
|
545
|
+
redirectTo: 'start-bill',
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const SPLITUS_SETTLEMENT_URL_BUILDER = new InjectionToken('SPLITUS_SETTLEMENT_URL_BUILDER');
|
|
549
|
+
|
|
550
|
+
/** URL opened after an unauthenticated bill draft is saved. */
|
|
551
|
+
const SPLITUS_START_CONTINUATION_URL = new InjectionToken('SPLITUS_START_CONTINUATION_URL');
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Generated bundle index. Do not edit.
|
|
555
|
+
*/
|
|
556
|
+
|
|
557
|
+
export { DraftSplitStore as D, NewSplitFormComponent as N, SPLITUS_START_CONTINUATION_URL as S, parseDecimal as a, SPLITUS_SETTLEMENT_URL_BUILDER as b, DRAFT_SPLIT_STORAGE_KEY as c, startBillRoute as d, formatHundredths as f, legacyStartSplitRoute as l, positiveExactDecimalValidator as p, spacePagesRoutes as s };
|
|
558
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-sneat-extension-splitus-ui-CX1eiAAR.mjs.map
|