@syndew/oluso-workspace-ui 0.0.0-stage → 0.0.1
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/README.md +2 -2
- package/fesm2022/syndew-oluso-workspace-ui-hosted-document-page.component-BmifVN4H.mjs +884 -0
- package/fesm2022/syndew-oluso-workspace-ui-hosted-document-page.component-BmifVN4H.mjs.map +1 -0
- package/fesm2022/syndew-oluso-workspace-ui-magic-link-page.component-CBohrM7h.mjs +331 -0
- package/fesm2022/syndew-oluso-workspace-ui-magic-link-page.component-CBohrM7h.mjs.map +1 -0
- package/fesm2022/syndew-oluso-workspace-ui-notification-preferences-page.component-BaPXBEmc.mjs +346 -0
- package/fesm2022/syndew-oluso-workspace-ui-notification-preferences-page.component-BaPXBEmc.mjs.map +1 -0
- package/fesm2022/syndew-oluso-workspace-ui-unauthorized-page.component-CWYFN6mp.mjs +73 -0
- package/fesm2022/syndew-oluso-workspace-ui-unauthorized-page.component-CWYFN6mp.mjs.map +1 -0
- package/fesm2022/syndew-oluso-workspace-ui.mjs +5091 -0
- package/fesm2022/syndew-oluso-workspace-ui.mjs.map +1 -0
- package/help/en/workspace.dashboard.md +25 -0
- package/help/en/workspace.my-files.md +43 -0
- package/help/en/workspace.notifications.md +37 -0
- package/help/en/workspace.profile.md +26 -0
- package/package.json +32 -3
- package/types/syndew-oluso-workspace-ui.d.ts +922 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, ChangeDetectorRef, signal, computed, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { CommonModule } from '@angular/common';
|
|
4
|
+
import { HttpClient, HttpBackend } from '@angular/common/http';
|
|
5
|
+
import { LocaleDatePipe } from '@syndew/oluso-ui-core';
|
|
6
|
+
import { apiUrl } from './syndew-oluso-workspace-ui.mjs';
|
|
7
|
+
|
|
8
|
+
/** The unlock proof is kept per link in this browser so reopening does not ask again. */
|
|
9
|
+
function unlockStorageKey(token) {
|
|
10
|
+
return `oluso.link-unlock.${token.slice(0, 16)}`;
|
|
11
|
+
}
|
|
12
|
+
function readUnlock(token) {
|
|
13
|
+
try {
|
|
14
|
+
return localStorage.getItem(unlockStorageKey(token));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function storeUnlock(token, proof) {
|
|
21
|
+
try {
|
|
22
|
+
localStorage.setItem(unlockStorageKey(token), proof);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Private windows can refuse storage; the visitor unlocks again next time.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class HostedDocumentPageComponent {
|
|
29
|
+
/** Bypasses the app's interceptors: the token is the only credential, so no session token or tenant header rides along. */
|
|
30
|
+
http = new HttpClient(inject(HttpBackend));
|
|
31
|
+
cdr = inject(ChangeDetectorRef);
|
|
32
|
+
loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
33
|
+
loadError = signal(null, ...(ngDevMode ? [{ debugName: "loadError" }] : /* istanbul ignore next */ []));
|
|
34
|
+
preview = signal(null, ...(ngDevMode ? [{ debugName: "preview" }] : /* istanbul ignore next */ []));
|
|
35
|
+
document = signal(null, ...(ngDevMode ? [{ debugName: "document" }] : /* istanbul ignore next */ []));
|
|
36
|
+
downloading = signal(false, ...(ngDevMode ? [{ debugName: "downloading" }] : /* istanbul ignore next */ []));
|
|
37
|
+
submitting = signal(false, ...(ngDevMode ? [{ debugName: "submitting" }] : /* istanbul ignore next */ []));
|
|
38
|
+
pendingOption = signal(null, ...(ngDevMode ? [{ debugName: "pendingOption" }] : /* istanbul ignore next */ []));
|
|
39
|
+
comments = '';
|
|
40
|
+
fieldValues = {};
|
|
41
|
+
logoFailed = signal(false, ...(ngDevMode ? [{ debugName: "logoFailed" }] : /* istanbul ignore next */ []));
|
|
42
|
+
branding = computed(() => this.document()?.branding ?? this.preview()?.data?.branding ?? null, ...(ngDevMode ? [{ debugName: "branding" }] : /* istanbul ignore next */ []));
|
|
43
|
+
unlockEmail = '';
|
|
44
|
+
unlockCode = '';
|
|
45
|
+
unlockProof = null;
|
|
46
|
+
unlocking = signal(false, ...(ngDevMode ? [{ debugName: "unlocking" }] : /* istanbul ignore next */ []));
|
|
47
|
+
codeSent = signal(false, ...(ngDevMode ? [{ debugName: "codeSent" }] : /* istanbul ignore next */ []));
|
|
48
|
+
unlockMessage = signal(null, ...(ngDevMode ? [{ debugName: "unlockMessage" }] : /* istanbul ignore next */ []));
|
|
49
|
+
unlockFailed = signal(false, ...(ngDevMode ? [{ debugName: "unlockFailed" }] : /* istanbul ignore next */ []));
|
|
50
|
+
/**
|
|
51
|
+
* The tenant's colour in place of the host app's theme on this page: the
|
|
52
|
+
* primary palette is derived from the one accent colour the tenant set.
|
|
53
|
+
*/
|
|
54
|
+
brandStyle = computed(() => {
|
|
55
|
+
const accent = this.branding()?.accentColor;
|
|
56
|
+
if (!accent || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent))
|
|
57
|
+
return '';
|
|
58
|
+
return [
|
|
59
|
+
`--color-primary-50: color-mix(in srgb, ${accent} 8%, white)`,
|
|
60
|
+
`--color-primary-100: color-mix(in srgb, ${accent} 16%, white)`,
|
|
61
|
+
`--color-primary-500: color-mix(in srgb, ${accent} 85%, white)`,
|
|
62
|
+
`--color-primary-600: ${accent}`,
|
|
63
|
+
`--color-primary-700: color-mix(in srgb, ${accent} 82%, black)`,
|
|
64
|
+
].join('; ');
|
|
65
|
+
}, ...(ngDevMode ? [{ debugName: "brandStyle" }] : /* istanbul ignore next */ []));
|
|
66
|
+
actionResult = signal(null, ...(ngDevMode ? [{ debugName: "actionResult" }] : /* istanbul ignore next */ []));
|
|
67
|
+
actionError = signal(null, ...(ngDevMode ? [{ debugName: "actionError" }] : /* istanbul ignore next */ []));
|
|
68
|
+
token = '';
|
|
69
|
+
autoDownload = false;
|
|
70
|
+
ngOnInit() {
|
|
71
|
+
const fragment = readFragment();
|
|
72
|
+
this.token = fragment.get('tk') ?? '';
|
|
73
|
+
this.autoDownload = fragment.get('dl') === '1';
|
|
74
|
+
if (!this.token) {
|
|
75
|
+
this.loading.set(false);
|
|
76
|
+
this.loadError.set('This link is missing or malformed.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.unlockProof = readUnlock(this.token);
|
|
80
|
+
this.loadPreview();
|
|
81
|
+
}
|
|
82
|
+
loadPreview() {
|
|
83
|
+
this.http.post(`${apiUrl}/api/public/links/preview`, { token: this.token, unlock: this.unlockProof }).subscribe({
|
|
84
|
+
next: (dto) => {
|
|
85
|
+
this.preview.set(dto);
|
|
86
|
+
this.document.set(dto.data?.document ?? null);
|
|
87
|
+
if (!dto.locked && !dto.data?.document) {
|
|
88
|
+
this.loadError.set(dto.alreadyActionedReason || 'This link does not carry a document.');
|
|
89
|
+
}
|
|
90
|
+
this.loading.set(false);
|
|
91
|
+
this.cdr.markForCheck();
|
|
92
|
+
if (this.autoDownload && dto.data?.document) {
|
|
93
|
+
this.downloadPdf();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
error: (err) => {
|
|
97
|
+
this.loadError.set(err?.error?.message || 'This link is invalid, expired, or has already been used.');
|
|
98
|
+
this.loading.set(false);
|
|
99
|
+
this.cdr.markForCheck();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
inputValue(event) {
|
|
104
|
+
return event.target.value;
|
|
105
|
+
}
|
|
106
|
+
submitUnlock(event) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
this.unlock(this.codeSent() ? this.unlockCode.trim() : null);
|
|
109
|
+
}
|
|
110
|
+
resendCode() {
|
|
111
|
+
this.unlockCode = '';
|
|
112
|
+
this.unlock(null);
|
|
113
|
+
}
|
|
114
|
+
changeEmail() {
|
|
115
|
+
this.codeSent.set(false);
|
|
116
|
+
this.unlockCode = '';
|
|
117
|
+
this.unlockMessage.set(null);
|
|
118
|
+
this.unlockFailed.set(false);
|
|
119
|
+
}
|
|
120
|
+
unlock(code) {
|
|
121
|
+
if (this.unlocking())
|
|
122
|
+
return;
|
|
123
|
+
this.unlocking.set(true);
|
|
124
|
+
this.unlockMessage.set(null);
|
|
125
|
+
this.unlockFailed.set(false);
|
|
126
|
+
this.http
|
|
127
|
+
.post(`${apiUrl}/api/public/links/unlock`, { token: this.token, email: this.unlockEmail.trim(), code })
|
|
128
|
+
.subscribe({
|
|
129
|
+
next: (res) => {
|
|
130
|
+
this.unlocking.set(false);
|
|
131
|
+
if (res.unlock) {
|
|
132
|
+
this.unlockProof = res.unlock;
|
|
133
|
+
storeUnlock(this.token, res.unlock);
|
|
134
|
+
this.loading.set(true);
|
|
135
|
+
this.loadPreview();
|
|
136
|
+
}
|
|
137
|
+
else if (res.codeSent) {
|
|
138
|
+
this.codeSent.set(true);
|
|
139
|
+
this.unlockMessage.set(res.message ?? null);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
this.loadPreview();
|
|
143
|
+
}
|
|
144
|
+
this.cdr.markForCheck();
|
|
145
|
+
},
|
|
146
|
+
error: (err) => {
|
|
147
|
+
this.unlocking.set(false);
|
|
148
|
+
this.unlockFailed.set(true);
|
|
149
|
+
this.unlockMessage.set(err?.error?.message || err?.error?.Message || 'That did not work. Try again.');
|
|
150
|
+
this.cdr.markForCheck();
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
downloadPdf() {
|
|
155
|
+
if (this.downloading())
|
|
156
|
+
return;
|
|
157
|
+
this.downloading.set(true);
|
|
158
|
+
this.http
|
|
159
|
+
.post(`${apiUrl}/api/public/links/file`, { token: this.token, unlock: this.unlockProof }, { responseType: 'blob', observe: 'response' })
|
|
160
|
+
.subscribe({
|
|
161
|
+
next: (res) => {
|
|
162
|
+
const doc = this.document();
|
|
163
|
+
const name = fileNameFrom(res.headers.get('content-disposition')) ?? `${doc?.kind ?? 'document'}-${doc?.number ?? ''}.pdf`;
|
|
164
|
+
const url = URL.createObjectURL(res.body);
|
|
165
|
+
const a = window.document.createElement('a');
|
|
166
|
+
a.href = url;
|
|
167
|
+
a.download = name;
|
|
168
|
+
a.click();
|
|
169
|
+
URL.revokeObjectURL(url);
|
|
170
|
+
this.downloading.set(false);
|
|
171
|
+
this.cdr.markForCheck();
|
|
172
|
+
},
|
|
173
|
+
error: () => {
|
|
174
|
+
this.actionError.set('The PDF could not be downloaded.');
|
|
175
|
+
this.downloading.set(false);
|
|
176
|
+
this.cdr.markForCheck();
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
act(option) {
|
|
181
|
+
if (this.submitting())
|
|
182
|
+
return;
|
|
183
|
+
if (option.requiresComments || (option.fields?.length ?? 0) > 0) {
|
|
184
|
+
this.comments = '';
|
|
185
|
+
this.fieldValues = {};
|
|
186
|
+
this.pendingOption.set(option);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.submit(option);
|
|
190
|
+
}
|
|
191
|
+
cancelPending() {
|
|
192
|
+
this.pendingOption.set(null);
|
|
193
|
+
this.comments = '';
|
|
194
|
+
this.fieldValues = {};
|
|
195
|
+
}
|
|
196
|
+
onFieldInput(key, event) {
|
|
197
|
+
this.fieldValues = { ...this.fieldValues, [key]: event.target.value };
|
|
198
|
+
}
|
|
199
|
+
canSubmit(option) {
|
|
200
|
+
if (option.commentsRequired && !this.comments.trim())
|
|
201
|
+
return false;
|
|
202
|
+
return (option.fields ?? []).every((f) => !f.required || (this.fieldValues[f.key] ?? '').trim() !== '');
|
|
203
|
+
}
|
|
204
|
+
onCommentsInput(event) {
|
|
205
|
+
this.comments = event.target.value;
|
|
206
|
+
}
|
|
207
|
+
submit(option) {
|
|
208
|
+
if (this.submitting())
|
|
209
|
+
return;
|
|
210
|
+
if (!this.canSubmit(option))
|
|
211
|
+
return;
|
|
212
|
+
this.submitting.set(true);
|
|
213
|
+
this.actionError.set(null);
|
|
214
|
+
const comments = this.comments.trim() || null;
|
|
215
|
+
const fields = {};
|
|
216
|
+
for (const field of option.fields ?? []) {
|
|
217
|
+
const value = (this.fieldValues[field.key] ?? '').trim();
|
|
218
|
+
if (value)
|
|
219
|
+
fields[field.key] = value;
|
|
220
|
+
}
|
|
221
|
+
this.http
|
|
222
|
+
.post(`${apiUrl}/api/public/links/redeem`, { token: this.token, option: option.key, comments, fields, unlock: this.unlockProof })
|
|
223
|
+
.subscribe({
|
|
224
|
+
next: (res) => {
|
|
225
|
+
this.pendingOption.set(null);
|
|
226
|
+
this.comments = '';
|
|
227
|
+
this.fieldValues = {};
|
|
228
|
+
this.actionResult.set(res);
|
|
229
|
+
this.preview.update((p) => (p ? { ...p, options: [], alreadyActioned: true, alreadyActionedReason: res.message } : p));
|
|
230
|
+
this.submitting.set(false);
|
|
231
|
+
this.cdr.markForCheck();
|
|
232
|
+
},
|
|
233
|
+
error: (err) => {
|
|
234
|
+
this.actionError.set(err?.error?.message || err?.error?.error || 'The action could not be completed.');
|
|
235
|
+
this.submitting.set(false);
|
|
236
|
+
this.cdr.markForCheck();
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** Symbol formatting in the viewer's locale ("£1,234.56"); falls back to the code when the browser does not know it. */
|
|
241
|
+
money(amount, currency) {
|
|
242
|
+
try {
|
|
243
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency, currencyDisplay: 'narrowSymbol' }).format(amount);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
variantClass(variant) {
|
|
250
|
+
switch (variant) {
|
|
251
|
+
case 'danger':
|
|
252
|
+
return 'bg-red-600 hover:bg-red-700';
|
|
253
|
+
case 'secondary':
|
|
254
|
+
return 'bg-gray-600 hover:bg-gray-700';
|
|
255
|
+
default:
|
|
256
|
+
return 'bg-primary-600 hover:bg-primary-700';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: HostedDocumentPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
260
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: HostedDocumentPageComponent, isStandalone: true, selector: "workspace-hosted-document-page", ngImport: i0, template: `
|
|
261
|
+
<div class="min-h-screen bg-gray-100 py-8 px-4" [style]="brandStyle()">
|
|
262
|
+
<div class="mx-auto max-w-3xl">
|
|
263
|
+
@if (loading()) {
|
|
264
|
+
<div class="bg-white rounded-lg shadow p-8 text-center">
|
|
265
|
+
<div class="inline-block h-8 w-8 rounded-full border-2 border-primary-600 border-t-transparent animate-spin"></div>
|
|
266
|
+
<p class="mt-3 text-sm text-gray-500">Loading…</p>
|
|
267
|
+
</div>
|
|
268
|
+
} @else if (loadError()) {
|
|
269
|
+
<div class="bg-white rounded-lg shadow p-8 text-center">
|
|
270
|
+
<h1 class="text-lg font-semibold text-gray-900 mb-2">Link unavailable</h1>
|
|
271
|
+
<p class="text-sm text-gray-600">{{ loadError() }}</p>
|
|
272
|
+
</div>
|
|
273
|
+
} @else if (preview()?.locked) {
|
|
274
|
+
@if (branding()?.logoUrl && !logoFailed()) {
|
|
275
|
+
<div class="mb-4">
|
|
276
|
+
<img [src]="branding()!.logoUrl" alt="" (error)="logoFailed.set(true)" class="max-h-12 max-w-[12rem] object-contain" />
|
|
277
|
+
</div>
|
|
278
|
+
}
|
|
279
|
+
<div class="bg-white rounded-lg shadow p-6 sm:p-8 max-w-md">
|
|
280
|
+
<h1 class="text-lg font-semibold text-gray-900">{{ preview()?.title }}</h1>
|
|
281
|
+
<p class="mt-1 text-sm text-gray-600">
|
|
282
|
+
@if (codeSent()) {
|
|
283
|
+
Enter the 6-digit code we emailed to {{ unlockEmail }}.
|
|
284
|
+
} @else {
|
|
285
|
+
To keep it private, confirm the email address this was sent to.
|
|
286
|
+
}
|
|
287
|
+
</p>
|
|
288
|
+
<form class="mt-5 space-y-3" (submit)="submitUnlock($event)">
|
|
289
|
+
@if (!codeSent()) {
|
|
290
|
+
<div>
|
|
291
|
+
<label for="unlock-email" class="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
|
292
|
+
<input id="unlock-email" type="email" autocomplete="email" [value]="unlockEmail" (input)="unlockEmail = inputValue($event)"
|
|
293
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:ring-primary-500" />
|
|
294
|
+
</div>
|
|
295
|
+
} @else {
|
|
296
|
+
<div>
|
|
297
|
+
<label for="unlock-code" class="block text-sm font-medium text-gray-700 mb-1">Code</label>
|
|
298
|
+
<input id="unlock-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" [value]="unlockCode" (input)="unlockCode = inputValue($event)"
|
|
299
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm tracking-widest focus:border-primary-500 focus:ring-primary-500" />
|
|
300
|
+
</div>
|
|
301
|
+
}
|
|
302
|
+
@if (unlockMessage()) {
|
|
303
|
+
<p class="text-sm" [class]="unlockFailed() ? 'text-red-700' : 'text-gray-600'">{{ unlockMessage() }}</p>
|
|
304
|
+
}
|
|
305
|
+
<div class="flex items-center gap-3">
|
|
306
|
+
<button type="submit" [disabled]="unlocking() || (codeSent() ? unlockCode.trim().length !== 6 : !unlockEmail.trim())"
|
|
307
|
+
class="px-3 py-2 rounded-md text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50">
|
|
308
|
+
{{ unlocking() ? 'Checking…' : codeSent() ? 'Open' : preview()?.lockMode === 'code' ? 'Send code' : 'Open' }}
|
|
309
|
+
</button>
|
|
310
|
+
@if (codeSent()) {
|
|
311
|
+
<button type="button" (click)="resendCode()" [disabled]="unlocking()" class="text-sm text-gray-600 hover:underline">Send a new code</button>
|
|
312
|
+
<button type="button" (click)="changeEmail()" [disabled]="unlocking()" class="text-sm text-gray-600 hover:underline">Use a different email</button>
|
|
313
|
+
}
|
|
314
|
+
</div>
|
|
315
|
+
</form>
|
|
316
|
+
</div>
|
|
317
|
+
} @else if (document(); as doc) {
|
|
318
|
+
@if (doc.branding?.logoUrl && !logoFailed()) {
|
|
319
|
+
<div class="mb-4">
|
|
320
|
+
<img [src]="doc.branding!.logoUrl" [alt]="doc.company.name" (error)="logoFailed.set(true)" class="max-h-12 max-w-[12rem] object-contain" />
|
|
321
|
+
</div>
|
|
322
|
+
}
|
|
323
|
+
<!-- Phones: title, then the response buttons side by side, then the download full width. -->
|
|
324
|
+
<div class="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center sm:justify-between">
|
|
325
|
+
<div class="min-w-0">
|
|
326
|
+
<h1 class="text-xl font-semibold text-gray-900 break-words">{{ doc.kind }} {{ doc.number }}</h1>
|
|
327
|
+
@if (preview()?.summary) {
|
|
328
|
+
<p class="text-sm text-gray-600">{{ preview()?.summary }}</p>
|
|
329
|
+
}
|
|
330
|
+
</div>
|
|
331
|
+
<div class="grid grid-cols-2 gap-2 sm:flex sm:items-center sm:shrink-0">
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
(click)="downloadPdf()"
|
|
335
|
+
[disabled]="downloading()"
|
|
336
|
+
class="col-span-2 order-last sm:order-none px-3 py-2.5 sm:py-2 rounded-md text-sm font-medium border border-primary-600 text-primary-700 bg-white hover:bg-primary-50 disabled:opacity-50 whitespace-nowrap"
|
|
337
|
+
>
|
|
338
|
+
{{ downloading() ? 'Preparing…' : 'Download PDF' }}
|
|
339
|
+
</button>
|
|
340
|
+
@for (opt of preview()?.options ?? []; track opt.key) {
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
(click)="act(opt)"
|
|
344
|
+
[disabled]="submitting()"
|
|
345
|
+
class="px-3 py-2.5 sm:py-2 rounded-md text-sm font-medium text-white disabled:opacity-50 whitespace-nowrap"
|
|
346
|
+
[class.col-span-2]="(preview()?.options?.length ?? 0) === 1"
|
|
347
|
+
[class]="variantClass(opt.variant)"
|
|
348
|
+
>
|
|
349
|
+
{{ opt.label }}
|
|
350
|
+
</button>
|
|
351
|
+
}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
@if (pendingOption(); as pending) {
|
|
356
|
+
<div class="bg-gray-50 border border-gray-200 rounded-md p-4 mb-4 space-y-3">
|
|
357
|
+
@for (field of pending.fields ?? []; track field.key) {
|
|
358
|
+
<div>
|
|
359
|
+
<label [for]="'field-' + field.key" class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
|
360
|
+
<input
|
|
361
|
+
[id]="'field-' + field.key"
|
|
362
|
+
type="text"
|
|
363
|
+
[value]="fieldValues[field.key] ?? ''"
|
|
364
|
+
(input)="onFieldInput(field.key, $event)"
|
|
365
|
+
[attr.maxlength]="field.maxLength ?? 200"
|
|
366
|
+
[placeholder]="field.placeholder ?? ''"
|
|
367
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:ring-primary-500"
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
}
|
|
371
|
+
@if (pending.requiresComments) {
|
|
372
|
+
<div>
|
|
373
|
+
<label for="option-comments" class="block text-sm font-medium text-gray-700 mb-1">
|
|
374
|
+
{{ pending.label }} — add a note{{ pending.commentsRequired ? '' : ' (optional)' }}
|
|
375
|
+
</label>
|
|
376
|
+
<textarea
|
|
377
|
+
id="option-comments"
|
|
378
|
+
rows="3"
|
|
379
|
+
[value]="comments"
|
|
380
|
+
(input)="onCommentsInput($event)"
|
|
381
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:ring-primary-500"
|
|
382
|
+
placeholder="Let them know why"
|
|
383
|
+
></textarea>
|
|
384
|
+
</div>
|
|
385
|
+
}
|
|
386
|
+
<div class="flex items-center gap-2">
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
(click)="submit(pending)"
|
|
390
|
+
[disabled]="submitting() || !canSubmit(pending)"
|
|
391
|
+
class="px-3 py-2 rounded-md text-sm font-medium text-white disabled:opacity-50"
|
|
392
|
+
[class]="variantClass(pending.variant)"
|
|
393
|
+
>
|
|
394
|
+
{{ submitting() ? 'Sending…' : 'Confirm ' + pending.label.toLowerCase() }}
|
|
395
|
+
</button>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
(click)="cancelPending()"
|
|
399
|
+
[disabled]="submitting()"
|
|
400
|
+
class="px-3 py-2 rounded-md text-sm font-medium border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
401
|
+
>
|
|
402
|
+
Cancel
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
}
|
|
407
|
+
@if (actionResult(); as r) {
|
|
408
|
+
<div class="bg-emerald-50 border border-emerald-200 rounded-md p-4 mb-4 text-sm text-emerald-800">{{ r.message }}</div>
|
|
409
|
+
}
|
|
410
|
+
@if (actionError(); as err) {
|
|
411
|
+
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4 text-sm text-red-700">{{ err }}</div>
|
|
412
|
+
}
|
|
413
|
+
@if (preview()?.alreadyActioned) {
|
|
414
|
+
<div class="bg-gray-50 border border-gray-200 rounded-md p-4 mb-4 text-sm text-gray-600">
|
|
415
|
+
{{ preview()?.alreadyActionedReason || 'This document has already been actioned.' }}
|
|
416
|
+
</div>
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
<div class="bg-white rounded-lg shadow p-4 sm:p-8">
|
|
420
|
+
<div class="flex flex-col gap-4 mb-6 sm:flex-row sm:justify-between sm:gap-8 sm:mb-8">
|
|
421
|
+
<div class="text-sm">
|
|
422
|
+
<div class="text-base font-semibold text-gray-900">{{ doc.company.name }}</div>
|
|
423
|
+
@for (line of doc.company.addressLines; track $index) {
|
|
424
|
+
<div class="text-gray-600">{{ line }}</div>
|
|
425
|
+
}
|
|
426
|
+
@if (doc.company.taxId) {
|
|
427
|
+
<div class="text-gray-600">Tax ID: {{ doc.company.taxId }}</div>
|
|
428
|
+
}
|
|
429
|
+
</div>
|
|
430
|
+
<div class="text-sm sm:text-right">
|
|
431
|
+
<div class="text-2xl font-bold text-primary-700 uppercase">{{ doc.kind }}</div>
|
|
432
|
+
<div class="font-semibold text-gray-900">{{ doc.number }}</div>
|
|
433
|
+
<div class="text-gray-600 mt-1">Date: {{ doc.issueDate | localeDate: 'mediumDate' }}</div>
|
|
434
|
+
@if (doc.dueDate) {
|
|
435
|
+
<div class="text-gray-600">{{ doc.dueLabel }}: {{ doc.dueDate | localeDate: 'mediumDate' }}</div>
|
|
436
|
+
}
|
|
437
|
+
<div class="text-gray-500">Status: {{ doc.statusLabel || doc.status }}</div>
|
|
438
|
+
@if (doc.purchaseOrderNumber) {
|
|
439
|
+
<div class="text-gray-500">PO: {{ doc.purchaseOrderNumber }}</div>
|
|
440
|
+
}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div class="text-sm mb-6">
|
|
445
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">Bill to</div>
|
|
446
|
+
<div class="font-semibold text-gray-900">{{ doc.customer.name }}</div>
|
|
447
|
+
@if (doc.customer.contactName) {
|
|
448
|
+
<div class="text-gray-700">{{ doc.customer.contactName }}</div>
|
|
449
|
+
}
|
|
450
|
+
@for (line of doc.customer.addressLines; track $index) {
|
|
451
|
+
<div class="text-gray-600">{{ line }}</div>
|
|
452
|
+
}
|
|
453
|
+
@if (doc.customer.email) {
|
|
454
|
+
<div class="text-gray-600">{{ doc.customer.email }}</div>
|
|
455
|
+
}
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<!-- Phones keep two columns: quantity, price and tax move under the description. -->
|
|
459
|
+
<table class="w-full text-sm">
|
|
460
|
+
<thead>
|
|
461
|
+
<tr class="border-b border-gray-300 text-left text-gray-600">
|
|
462
|
+
<th class="hidden sm:table-cell py-2 pr-2 w-8">#</th>
|
|
463
|
+
<th class="py-2 pr-2">Description</th>
|
|
464
|
+
<th class="hidden sm:table-cell py-2 pr-2 text-right">Qty</th>
|
|
465
|
+
<th class="hidden sm:table-cell py-2 pr-2 text-right">Unit price</th>
|
|
466
|
+
<th class="hidden sm:table-cell py-2 pr-2 text-right">Tax</th>
|
|
467
|
+
<th class="py-2 text-right">Amount</th>
|
|
468
|
+
</tr>
|
|
469
|
+
</thead>
|
|
470
|
+
<tbody>
|
|
471
|
+
@for (line of doc.lines; track line.lineNumber) {
|
|
472
|
+
<tr class="border-b border-gray-100">
|
|
473
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-gray-500">{{ line.lineNumber }}</td>
|
|
474
|
+
<td class="py-2 pr-2">
|
|
475
|
+
<div class="text-gray-900">{{ line.description }}</div>
|
|
476
|
+
@if (line.productCode) {
|
|
477
|
+
<div class="text-xs text-gray-500">{{ line.productCode }}</div>
|
|
478
|
+
}
|
|
479
|
+
@if (line.discount > 0) {
|
|
480
|
+
<div class="text-xs text-gray-500">Discount {{ money(line.discount, doc.currencyCode) }}</div>
|
|
481
|
+
}
|
|
482
|
+
<div class="sm:hidden text-xs text-gray-500">
|
|
483
|
+
{{ line.quantity }} × {{ money(line.unitPrice, doc.currencyCode) }} · Tax {{ money(line.tax, doc.currencyCode) }}{{ line.taxRate ? ' (' + line.taxRate + '%)' : '' }}
|
|
484
|
+
</div>
|
|
485
|
+
</td>
|
|
486
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ line.quantity }}</td>
|
|
487
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ money(line.unitPrice, doc.currencyCode) }}</td>
|
|
488
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">
|
|
489
|
+
{{ money(line.tax, doc.currencyCode) }}
|
|
490
|
+
@if (line.taxRate) {
|
|
491
|
+
<div class="text-xs text-gray-500">{{ line.taxRate }}%</div>
|
|
492
|
+
}
|
|
493
|
+
</td>
|
|
494
|
+
<td class="py-2 text-right whitespace-nowrap align-top">{{ money(line.amount, doc.currencyCode) }}</td>
|
|
495
|
+
</tr>
|
|
496
|
+
}
|
|
497
|
+
@for (s of doc.surcharges; track $index) {
|
|
498
|
+
<tr class="border-b border-gray-100">
|
|
499
|
+
<td class="hidden sm:table-cell py-2 pr-2"></td>
|
|
500
|
+
<td class="py-2 pr-2 text-gray-900">
|
|
501
|
+
{{ s.description }}
|
|
502
|
+
<div class="sm:hidden text-xs text-gray-500">Tax {{ money(s.tax, doc.currencyCode) }}</div>
|
|
503
|
+
</td>
|
|
504
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">1</td>
|
|
505
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ money(s.unitPrice, doc.currencyCode) }}</td>
|
|
506
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ money(s.tax, doc.currencyCode) }}</td>
|
|
507
|
+
<td class="py-2 text-right whitespace-nowrap align-top">{{ money(s.amount, doc.currencyCode) }}</td>
|
|
508
|
+
</tr>
|
|
509
|
+
}
|
|
510
|
+
</tbody>
|
|
511
|
+
</table>
|
|
512
|
+
|
|
513
|
+
<div class="flex justify-end mt-6">
|
|
514
|
+
<div class="w-full sm:w-72 text-sm">
|
|
515
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Subtotal</span><span>{{ money(doc.subtotal, doc.currencyCode) }}</span></div>
|
|
516
|
+
@if (doc.discount > 0) {
|
|
517
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Discount</span><span>-{{ money(doc.discount, doc.currencyCode) }}</span></div>
|
|
518
|
+
}
|
|
519
|
+
@if (doc.fees > 0) {
|
|
520
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Fees</span><span>{{ money(doc.fees, doc.currencyCode) }}</span></div>
|
|
521
|
+
}
|
|
522
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Tax</span><span>{{ money(doc.tax, doc.currencyCode) }}</span></div>
|
|
523
|
+
<div class="flex justify-between py-1 border-b border-gray-100 font-semibold"><span>Total</span><span>{{ money(doc.total, doc.currencyCode) }}</span></div>
|
|
524
|
+
@if (doc.amountPaid > 0) {
|
|
525
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Paid</span><span>-{{ money(doc.amountPaid, doc.currencyCode) }}</span></div>
|
|
526
|
+
}
|
|
527
|
+
@if (doc.kind === 'Invoice') {
|
|
528
|
+
<div class="flex justify-between py-1 font-semibold text-primary-700"><span>Balance due</span><span>{{ money(doc.balanceDue, doc.currencyCode) }}</span></div>
|
|
529
|
+
}
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
@if (doc.paymentInstructions && doc.payable !== false) {
|
|
534
|
+
<div class="mt-8 text-sm">
|
|
535
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">How to pay</div>
|
|
536
|
+
<div class="text-gray-800 whitespace-pre-line">{{ doc.paymentInstructions }}</div>
|
|
537
|
+
</div>
|
|
538
|
+
}
|
|
539
|
+
@if (doc.notes) {
|
|
540
|
+
<div class="mt-6 text-sm">
|
|
541
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">Notes</div>
|
|
542
|
+
<div class="text-gray-800 whitespace-pre-line">{{ doc.notes }}</div>
|
|
543
|
+
</div>
|
|
544
|
+
}
|
|
545
|
+
@if (doc.terms) {
|
|
546
|
+
<div class="mt-6 text-sm">
|
|
547
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">Terms</div>
|
|
548
|
+
<div class="text-gray-800 whitespace-pre-line">{{ doc.terms }}</div>
|
|
549
|
+
</div>
|
|
550
|
+
}
|
|
551
|
+
@if (doc.footer) {
|
|
552
|
+
<div class="mt-8 pt-4 border-t border-gray-200 text-center text-xs text-gray-500">{{ doc.footer }}</div>
|
|
553
|
+
}
|
|
554
|
+
</div>
|
|
555
|
+
}
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "pipe", type: LocaleDatePipe, name: "localeDate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
559
|
+
}
|
|
560
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: HostedDocumentPageComponent, decorators: [{
|
|
561
|
+
type: Component,
|
|
562
|
+
args: [{
|
|
563
|
+
selector: 'workspace-hosted-document-page',
|
|
564
|
+
standalone: true,
|
|
565
|
+
imports: [CommonModule, LocaleDatePipe],
|
|
566
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
567
|
+
template: `
|
|
568
|
+
<div class="min-h-screen bg-gray-100 py-8 px-4" [style]="brandStyle()">
|
|
569
|
+
<div class="mx-auto max-w-3xl">
|
|
570
|
+
@if (loading()) {
|
|
571
|
+
<div class="bg-white rounded-lg shadow p-8 text-center">
|
|
572
|
+
<div class="inline-block h-8 w-8 rounded-full border-2 border-primary-600 border-t-transparent animate-spin"></div>
|
|
573
|
+
<p class="mt-3 text-sm text-gray-500">Loading…</p>
|
|
574
|
+
</div>
|
|
575
|
+
} @else if (loadError()) {
|
|
576
|
+
<div class="bg-white rounded-lg shadow p-8 text-center">
|
|
577
|
+
<h1 class="text-lg font-semibold text-gray-900 mb-2">Link unavailable</h1>
|
|
578
|
+
<p class="text-sm text-gray-600">{{ loadError() }}</p>
|
|
579
|
+
</div>
|
|
580
|
+
} @else if (preview()?.locked) {
|
|
581
|
+
@if (branding()?.logoUrl && !logoFailed()) {
|
|
582
|
+
<div class="mb-4">
|
|
583
|
+
<img [src]="branding()!.logoUrl" alt="" (error)="logoFailed.set(true)" class="max-h-12 max-w-[12rem] object-contain" />
|
|
584
|
+
</div>
|
|
585
|
+
}
|
|
586
|
+
<div class="bg-white rounded-lg shadow p-6 sm:p-8 max-w-md">
|
|
587
|
+
<h1 class="text-lg font-semibold text-gray-900">{{ preview()?.title }}</h1>
|
|
588
|
+
<p class="mt-1 text-sm text-gray-600">
|
|
589
|
+
@if (codeSent()) {
|
|
590
|
+
Enter the 6-digit code we emailed to {{ unlockEmail }}.
|
|
591
|
+
} @else {
|
|
592
|
+
To keep it private, confirm the email address this was sent to.
|
|
593
|
+
}
|
|
594
|
+
</p>
|
|
595
|
+
<form class="mt-5 space-y-3" (submit)="submitUnlock($event)">
|
|
596
|
+
@if (!codeSent()) {
|
|
597
|
+
<div>
|
|
598
|
+
<label for="unlock-email" class="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
|
599
|
+
<input id="unlock-email" type="email" autocomplete="email" [value]="unlockEmail" (input)="unlockEmail = inputValue($event)"
|
|
600
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:ring-primary-500" />
|
|
601
|
+
</div>
|
|
602
|
+
} @else {
|
|
603
|
+
<div>
|
|
604
|
+
<label for="unlock-code" class="block text-sm font-medium text-gray-700 mb-1">Code</label>
|
|
605
|
+
<input id="unlock-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" [value]="unlockCode" (input)="unlockCode = inputValue($event)"
|
|
606
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm tracking-widest focus:border-primary-500 focus:ring-primary-500" />
|
|
607
|
+
</div>
|
|
608
|
+
}
|
|
609
|
+
@if (unlockMessage()) {
|
|
610
|
+
<p class="text-sm" [class]="unlockFailed() ? 'text-red-700' : 'text-gray-600'">{{ unlockMessage() }}</p>
|
|
611
|
+
}
|
|
612
|
+
<div class="flex items-center gap-3">
|
|
613
|
+
<button type="submit" [disabled]="unlocking() || (codeSent() ? unlockCode.trim().length !== 6 : !unlockEmail.trim())"
|
|
614
|
+
class="px-3 py-2 rounded-md text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50">
|
|
615
|
+
{{ unlocking() ? 'Checking…' : codeSent() ? 'Open' : preview()?.lockMode === 'code' ? 'Send code' : 'Open' }}
|
|
616
|
+
</button>
|
|
617
|
+
@if (codeSent()) {
|
|
618
|
+
<button type="button" (click)="resendCode()" [disabled]="unlocking()" class="text-sm text-gray-600 hover:underline">Send a new code</button>
|
|
619
|
+
<button type="button" (click)="changeEmail()" [disabled]="unlocking()" class="text-sm text-gray-600 hover:underline">Use a different email</button>
|
|
620
|
+
}
|
|
621
|
+
</div>
|
|
622
|
+
</form>
|
|
623
|
+
</div>
|
|
624
|
+
} @else if (document(); as doc) {
|
|
625
|
+
@if (doc.branding?.logoUrl && !logoFailed()) {
|
|
626
|
+
<div class="mb-4">
|
|
627
|
+
<img [src]="doc.branding!.logoUrl" [alt]="doc.company.name" (error)="logoFailed.set(true)" class="max-h-12 max-w-[12rem] object-contain" />
|
|
628
|
+
</div>
|
|
629
|
+
}
|
|
630
|
+
<!-- Phones: title, then the response buttons side by side, then the download full width. -->
|
|
631
|
+
<div class="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center sm:justify-between">
|
|
632
|
+
<div class="min-w-0">
|
|
633
|
+
<h1 class="text-xl font-semibold text-gray-900 break-words">{{ doc.kind }} {{ doc.number }}</h1>
|
|
634
|
+
@if (preview()?.summary) {
|
|
635
|
+
<p class="text-sm text-gray-600">{{ preview()?.summary }}</p>
|
|
636
|
+
}
|
|
637
|
+
</div>
|
|
638
|
+
<div class="grid grid-cols-2 gap-2 sm:flex sm:items-center sm:shrink-0">
|
|
639
|
+
<button
|
|
640
|
+
type="button"
|
|
641
|
+
(click)="downloadPdf()"
|
|
642
|
+
[disabled]="downloading()"
|
|
643
|
+
class="col-span-2 order-last sm:order-none px-3 py-2.5 sm:py-2 rounded-md text-sm font-medium border border-primary-600 text-primary-700 bg-white hover:bg-primary-50 disabled:opacity-50 whitespace-nowrap"
|
|
644
|
+
>
|
|
645
|
+
{{ downloading() ? 'Preparing…' : 'Download PDF' }}
|
|
646
|
+
</button>
|
|
647
|
+
@for (opt of preview()?.options ?? []; track opt.key) {
|
|
648
|
+
<button
|
|
649
|
+
type="button"
|
|
650
|
+
(click)="act(opt)"
|
|
651
|
+
[disabled]="submitting()"
|
|
652
|
+
class="px-3 py-2.5 sm:py-2 rounded-md text-sm font-medium text-white disabled:opacity-50 whitespace-nowrap"
|
|
653
|
+
[class.col-span-2]="(preview()?.options?.length ?? 0) === 1"
|
|
654
|
+
[class]="variantClass(opt.variant)"
|
|
655
|
+
>
|
|
656
|
+
{{ opt.label }}
|
|
657
|
+
</button>
|
|
658
|
+
}
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
|
|
662
|
+
@if (pendingOption(); as pending) {
|
|
663
|
+
<div class="bg-gray-50 border border-gray-200 rounded-md p-4 mb-4 space-y-3">
|
|
664
|
+
@for (field of pending.fields ?? []; track field.key) {
|
|
665
|
+
<div>
|
|
666
|
+
<label [for]="'field-' + field.key" class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
|
667
|
+
<input
|
|
668
|
+
[id]="'field-' + field.key"
|
|
669
|
+
type="text"
|
|
670
|
+
[value]="fieldValues[field.key] ?? ''"
|
|
671
|
+
(input)="onFieldInput(field.key, $event)"
|
|
672
|
+
[attr.maxlength]="field.maxLength ?? 200"
|
|
673
|
+
[placeholder]="field.placeholder ?? ''"
|
|
674
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:ring-primary-500"
|
|
675
|
+
/>
|
|
676
|
+
</div>
|
|
677
|
+
}
|
|
678
|
+
@if (pending.requiresComments) {
|
|
679
|
+
<div>
|
|
680
|
+
<label for="option-comments" class="block text-sm font-medium text-gray-700 mb-1">
|
|
681
|
+
{{ pending.label }} — add a note{{ pending.commentsRequired ? '' : ' (optional)' }}
|
|
682
|
+
</label>
|
|
683
|
+
<textarea
|
|
684
|
+
id="option-comments"
|
|
685
|
+
rows="3"
|
|
686
|
+
[value]="comments"
|
|
687
|
+
(input)="onCommentsInput($event)"
|
|
688
|
+
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:ring-primary-500"
|
|
689
|
+
placeholder="Let them know why"
|
|
690
|
+
></textarea>
|
|
691
|
+
</div>
|
|
692
|
+
}
|
|
693
|
+
<div class="flex items-center gap-2">
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
(click)="submit(pending)"
|
|
697
|
+
[disabled]="submitting() || !canSubmit(pending)"
|
|
698
|
+
class="px-3 py-2 rounded-md text-sm font-medium text-white disabled:opacity-50"
|
|
699
|
+
[class]="variantClass(pending.variant)"
|
|
700
|
+
>
|
|
701
|
+
{{ submitting() ? 'Sending…' : 'Confirm ' + pending.label.toLowerCase() }}
|
|
702
|
+
</button>
|
|
703
|
+
<button
|
|
704
|
+
type="button"
|
|
705
|
+
(click)="cancelPending()"
|
|
706
|
+
[disabled]="submitting()"
|
|
707
|
+
class="px-3 py-2 rounded-md text-sm font-medium border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
708
|
+
>
|
|
709
|
+
Cancel
|
|
710
|
+
</button>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
}
|
|
714
|
+
@if (actionResult(); as r) {
|
|
715
|
+
<div class="bg-emerald-50 border border-emerald-200 rounded-md p-4 mb-4 text-sm text-emerald-800">{{ r.message }}</div>
|
|
716
|
+
}
|
|
717
|
+
@if (actionError(); as err) {
|
|
718
|
+
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4 text-sm text-red-700">{{ err }}</div>
|
|
719
|
+
}
|
|
720
|
+
@if (preview()?.alreadyActioned) {
|
|
721
|
+
<div class="bg-gray-50 border border-gray-200 rounded-md p-4 mb-4 text-sm text-gray-600">
|
|
722
|
+
{{ preview()?.alreadyActionedReason || 'This document has already been actioned.' }}
|
|
723
|
+
</div>
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
<div class="bg-white rounded-lg shadow p-4 sm:p-8">
|
|
727
|
+
<div class="flex flex-col gap-4 mb-6 sm:flex-row sm:justify-between sm:gap-8 sm:mb-8">
|
|
728
|
+
<div class="text-sm">
|
|
729
|
+
<div class="text-base font-semibold text-gray-900">{{ doc.company.name }}</div>
|
|
730
|
+
@for (line of doc.company.addressLines; track $index) {
|
|
731
|
+
<div class="text-gray-600">{{ line }}</div>
|
|
732
|
+
}
|
|
733
|
+
@if (doc.company.taxId) {
|
|
734
|
+
<div class="text-gray-600">Tax ID: {{ doc.company.taxId }}</div>
|
|
735
|
+
}
|
|
736
|
+
</div>
|
|
737
|
+
<div class="text-sm sm:text-right">
|
|
738
|
+
<div class="text-2xl font-bold text-primary-700 uppercase">{{ doc.kind }}</div>
|
|
739
|
+
<div class="font-semibold text-gray-900">{{ doc.number }}</div>
|
|
740
|
+
<div class="text-gray-600 mt-1">Date: {{ doc.issueDate | localeDate: 'mediumDate' }}</div>
|
|
741
|
+
@if (doc.dueDate) {
|
|
742
|
+
<div class="text-gray-600">{{ doc.dueLabel }}: {{ doc.dueDate | localeDate: 'mediumDate' }}</div>
|
|
743
|
+
}
|
|
744
|
+
<div class="text-gray-500">Status: {{ doc.statusLabel || doc.status }}</div>
|
|
745
|
+
@if (doc.purchaseOrderNumber) {
|
|
746
|
+
<div class="text-gray-500">PO: {{ doc.purchaseOrderNumber }}</div>
|
|
747
|
+
}
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
|
|
751
|
+
<div class="text-sm mb-6">
|
|
752
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">Bill to</div>
|
|
753
|
+
<div class="font-semibold text-gray-900">{{ doc.customer.name }}</div>
|
|
754
|
+
@if (doc.customer.contactName) {
|
|
755
|
+
<div class="text-gray-700">{{ doc.customer.contactName }}</div>
|
|
756
|
+
}
|
|
757
|
+
@for (line of doc.customer.addressLines; track $index) {
|
|
758
|
+
<div class="text-gray-600">{{ line }}</div>
|
|
759
|
+
}
|
|
760
|
+
@if (doc.customer.email) {
|
|
761
|
+
<div class="text-gray-600">{{ doc.customer.email }}</div>
|
|
762
|
+
}
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<!-- Phones keep two columns: quantity, price and tax move under the description. -->
|
|
766
|
+
<table class="w-full text-sm">
|
|
767
|
+
<thead>
|
|
768
|
+
<tr class="border-b border-gray-300 text-left text-gray-600">
|
|
769
|
+
<th class="hidden sm:table-cell py-2 pr-2 w-8">#</th>
|
|
770
|
+
<th class="py-2 pr-2">Description</th>
|
|
771
|
+
<th class="hidden sm:table-cell py-2 pr-2 text-right">Qty</th>
|
|
772
|
+
<th class="hidden sm:table-cell py-2 pr-2 text-right">Unit price</th>
|
|
773
|
+
<th class="hidden sm:table-cell py-2 pr-2 text-right">Tax</th>
|
|
774
|
+
<th class="py-2 text-right">Amount</th>
|
|
775
|
+
</tr>
|
|
776
|
+
</thead>
|
|
777
|
+
<tbody>
|
|
778
|
+
@for (line of doc.lines; track line.lineNumber) {
|
|
779
|
+
<tr class="border-b border-gray-100">
|
|
780
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-gray-500">{{ line.lineNumber }}</td>
|
|
781
|
+
<td class="py-2 pr-2">
|
|
782
|
+
<div class="text-gray-900">{{ line.description }}</div>
|
|
783
|
+
@if (line.productCode) {
|
|
784
|
+
<div class="text-xs text-gray-500">{{ line.productCode }}</div>
|
|
785
|
+
}
|
|
786
|
+
@if (line.discount > 0) {
|
|
787
|
+
<div class="text-xs text-gray-500">Discount {{ money(line.discount, doc.currencyCode) }}</div>
|
|
788
|
+
}
|
|
789
|
+
<div class="sm:hidden text-xs text-gray-500">
|
|
790
|
+
{{ line.quantity }} × {{ money(line.unitPrice, doc.currencyCode) }} · Tax {{ money(line.tax, doc.currencyCode) }}{{ line.taxRate ? ' (' + line.taxRate + '%)' : '' }}
|
|
791
|
+
</div>
|
|
792
|
+
</td>
|
|
793
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ line.quantity }}</td>
|
|
794
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ money(line.unitPrice, doc.currencyCode) }}</td>
|
|
795
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">
|
|
796
|
+
{{ money(line.tax, doc.currencyCode) }}
|
|
797
|
+
@if (line.taxRate) {
|
|
798
|
+
<div class="text-xs text-gray-500">{{ line.taxRate }}%</div>
|
|
799
|
+
}
|
|
800
|
+
</td>
|
|
801
|
+
<td class="py-2 text-right whitespace-nowrap align-top">{{ money(line.amount, doc.currencyCode) }}</td>
|
|
802
|
+
</tr>
|
|
803
|
+
}
|
|
804
|
+
@for (s of doc.surcharges; track $index) {
|
|
805
|
+
<tr class="border-b border-gray-100">
|
|
806
|
+
<td class="hidden sm:table-cell py-2 pr-2"></td>
|
|
807
|
+
<td class="py-2 pr-2 text-gray-900">
|
|
808
|
+
{{ s.description }}
|
|
809
|
+
<div class="sm:hidden text-xs text-gray-500">Tax {{ money(s.tax, doc.currencyCode) }}</div>
|
|
810
|
+
</td>
|
|
811
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">1</td>
|
|
812
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ money(s.unitPrice, doc.currencyCode) }}</td>
|
|
813
|
+
<td class="hidden sm:table-cell py-2 pr-2 text-right">{{ money(s.tax, doc.currencyCode) }}</td>
|
|
814
|
+
<td class="py-2 text-right whitespace-nowrap align-top">{{ money(s.amount, doc.currencyCode) }}</td>
|
|
815
|
+
</tr>
|
|
816
|
+
}
|
|
817
|
+
</tbody>
|
|
818
|
+
</table>
|
|
819
|
+
|
|
820
|
+
<div class="flex justify-end mt-6">
|
|
821
|
+
<div class="w-full sm:w-72 text-sm">
|
|
822
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Subtotal</span><span>{{ money(doc.subtotal, doc.currencyCode) }}</span></div>
|
|
823
|
+
@if (doc.discount > 0) {
|
|
824
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Discount</span><span>-{{ money(doc.discount, doc.currencyCode) }}</span></div>
|
|
825
|
+
}
|
|
826
|
+
@if (doc.fees > 0) {
|
|
827
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Fees</span><span>{{ money(doc.fees, doc.currencyCode) }}</span></div>
|
|
828
|
+
}
|
|
829
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Tax</span><span>{{ money(doc.tax, doc.currencyCode) }}</span></div>
|
|
830
|
+
<div class="flex justify-between py-1 border-b border-gray-100 font-semibold"><span>Total</span><span>{{ money(doc.total, doc.currencyCode) }}</span></div>
|
|
831
|
+
@if (doc.amountPaid > 0) {
|
|
832
|
+
<div class="flex justify-between py-1 border-b border-gray-100"><span>Paid</span><span>-{{ money(doc.amountPaid, doc.currencyCode) }}</span></div>
|
|
833
|
+
}
|
|
834
|
+
@if (doc.kind === 'Invoice') {
|
|
835
|
+
<div class="flex justify-between py-1 font-semibold text-primary-700"><span>Balance due</span><span>{{ money(doc.balanceDue, doc.currencyCode) }}</span></div>
|
|
836
|
+
}
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
@if (doc.paymentInstructions && doc.payable !== false) {
|
|
841
|
+
<div class="mt-8 text-sm">
|
|
842
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">How to pay</div>
|
|
843
|
+
<div class="text-gray-800 whitespace-pre-line">{{ doc.paymentInstructions }}</div>
|
|
844
|
+
</div>
|
|
845
|
+
}
|
|
846
|
+
@if (doc.notes) {
|
|
847
|
+
<div class="mt-6 text-sm">
|
|
848
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">Notes</div>
|
|
849
|
+
<div class="text-gray-800 whitespace-pre-line">{{ doc.notes }}</div>
|
|
850
|
+
</div>
|
|
851
|
+
}
|
|
852
|
+
@if (doc.terms) {
|
|
853
|
+
<div class="mt-6 text-sm">
|
|
854
|
+
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 mb-1">Terms</div>
|
|
855
|
+
<div class="text-gray-800 whitespace-pre-line">{{ doc.terms }}</div>
|
|
856
|
+
</div>
|
|
857
|
+
}
|
|
858
|
+
@if (doc.footer) {
|
|
859
|
+
<div class="mt-8 pt-4 border-t border-gray-200 text-center text-xs text-gray-500">{{ doc.footer }}</div>
|
|
860
|
+
}
|
|
861
|
+
</div>
|
|
862
|
+
}
|
|
863
|
+
</div>
|
|
864
|
+
</div>
|
|
865
|
+
`,
|
|
866
|
+
}]
|
|
867
|
+
}] });
|
|
868
|
+
function readFragment() {
|
|
869
|
+
if (typeof window === 'undefined')
|
|
870
|
+
return new URLSearchParams();
|
|
871
|
+
return new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
|
872
|
+
}
|
|
873
|
+
function fileNameFrom(contentDisposition) {
|
|
874
|
+
if (!contentDisposition)
|
|
875
|
+
return null;
|
|
876
|
+
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition);
|
|
877
|
+
if (utf8)
|
|
878
|
+
return decodeURIComponent(utf8[1]);
|
|
879
|
+
const plain = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
|
880
|
+
return plain ? plain[1] : null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export { HostedDocumentPageComponent };
|
|
884
|
+
//# sourceMappingURL=syndew-oluso-workspace-ui-hosted-document-page.component-BmifVN4H.mjs.map
|