@things-factory/sales 10.1.256 → 10.1.258
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/dist-client/entity360/opportunity-360.d.ts +3 -4
- package/dist-client/entity360/opportunity-360.js +16 -38
- package/dist-client/entity360/opportunity-360.js.map +1 -1
- package/dist-client/pages/opportunity-editor.d.ts +25 -33
- package/dist-client/pages/opportunity-editor.js +108 -175
- package/dist-client/pages/opportunity-editor.js.map +1 -1
- package/dist-client/pages/opportunity-list.d.ts +2 -3
- package/dist-client/pages/opportunity-list.js +6 -12
- package/dist-client/pages/opportunity-list.js.map +1 -1
- package/dist-client/pages/quotation-editor.d.ts +45 -26
- package/dist-client/pages/quotation-editor.js +225 -248
- package/dist-client/pages/quotation-editor.js.map +1 -1
- package/dist-client/pages/quotation-list.d.ts +7 -3
- package/dist-client/pages/quotation-list.js +26 -27
- package/dist-client/pages/quotation-list.js.map +1 -1
- package/dist-client/pages/sales-product-list.d.ts +7 -2
- package/dist-client/pages/sales-product-list.js +36 -55
- package/dist-client/pages/sales-product-list.js.map +1 -1
- package/dist-client/sales-product-pick.d.ts +4 -2
- package/dist-client/sales-product-pick.js +36 -32
- package/dist-client/sales-product-pick.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -11
- package/translations/es.json +8 -0
- package/translations/ja.json +8 -0
- package/translations/ms.json +8 -0
- package/translations/zh.json +8 -0
|
@@ -7,14 +7,13 @@ import gql from 'graphql-tag';
|
|
|
7
7
|
import { css, html, nothing } from 'lit';
|
|
8
8
|
import { customElement, query, state } from 'lit/decorators.js';
|
|
9
9
|
import { DataGrist } from '@operato/data-grist';
|
|
10
|
-
import { client } from '@operato/graphql';
|
|
10
|
+
import { client, MutationController, QueryController } from '@operato/graphql';
|
|
11
11
|
import { i18next, localize } from '@operato/i18n';
|
|
12
|
-
import { notify } from '@operato/layout';
|
|
13
12
|
import { OxPrompt } from '@operato/popup/ox-prompt.js';
|
|
14
|
-
import { navigate, PageView } from '@operato/shell';
|
|
13
|
+
import { navigate, PageView, DraftController } from '@operato/shell';
|
|
15
14
|
import { ButtonStyles, CommonGristStyles, DocumentPageStyles, FormFieldStyles, ScrollbarStyles } from '@operato/styles';
|
|
16
15
|
import { hasPrivilege } from '@things-factory/auth-base/dist-client';
|
|
17
|
-
import { daysAfter, documentAmount, DocumentFormStyles, documentLinesConfig, linesOf, onLineChange, openDocumentPreview, reckonLines, renderDocumentHead, renderDocumentTotals,
|
|
16
|
+
import { daysAfter, documentAmount, DocumentFormStyles, documentLinesConfig, linesOf, onLineChange, openDocumentPreview, reckonLines, renderDocumentHead, renderDocumentTotals, DOCUMENT_DEFAULTS, DOCUMENT_DEFAULTS_READER, headFromDefaults, nextInvoiceNumber, OUR_SIDE_FIELDS, oursFromLast, partyOfName } from '@things-factory/invoice/dist-client';
|
|
18
17
|
import '@things-factory/invoice/dist-client/document/document-party.js';
|
|
19
18
|
import { quotationSection } from '../quotation-attach';
|
|
20
19
|
import '../sales-product-pick';
|
|
@@ -64,6 +63,10 @@ const INVOICE_HEAD = [
|
|
|
64
63
|
'bankDetails',
|
|
65
64
|
'notes'
|
|
66
65
|
];
|
|
66
|
+
/** A step of a quotation's life - each answers with the quotation it leaves (a revision is a new one). */
|
|
67
|
+
const step = (name, reason = false) => gql(reason
|
|
68
|
+
? `mutation ($id: String!, $reason: String!) { quotation: ${name}(id: $id, reason: $reason) { id } }`
|
|
69
|
+
: `mutation ($id: String!) { quotation: ${name}(id: $id) { id } }`);
|
|
67
70
|
/** How long an offer holds unless the person writes another last day. */
|
|
68
71
|
const VALID_DAYS = 30;
|
|
69
72
|
/** Days an invoice made from a quotation is due unless the company says otherwise - the invoice editor's. */
|
|
@@ -83,24 +86,62 @@ export function nextQuotationNumber(numbers, year) {
|
|
|
83
86
|
let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView) {
|
|
84
87
|
constructor() {
|
|
85
88
|
super(...arguments);
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this.loading = false;
|
|
89
|
-
this.error = null;
|
|
90
|
-
this.busy = false;
|
|
91
|
-
this.reason = '';
|
|
92
|
-
/** The open opportunities a draft may answer, and the one it answers when that one is closed. */
|
|
93
|
-
this.opportunities = [];
|
|
94
|
-
/** The company's words a new quotation starts with, the terms it offers and how long an offer holds (ADR-0170). */
|
|
95
|
-
this.defaults = null;
|
|
96
|
-
/** The counterparty the buyer was taken from on this screen - shown in the picker. */
|
|
97
|
-
this.party = null;
|
|
89
|
+
/** Whether this person may read the company's words - unknown, and nothing read, until asked. */
|
|
90
|
+
this.defaultsReadable = false;
|
|
98
91
|
/** The totals of the lines as typed - reckoned as the quotation will keep them, shown before it is saved. */
|
|
99
|
-
this.
|
|
92
|
+
this.typedAmounts = null;
|
|
100
93
|
/** Whether this person may write invoices - an accepted quotation then makes one. */
|
|
101
94
|
this.invoiceWriter = false;
|
|
102
95
|
/** Whether the application's section is this person's to read - asked once, before it is shown. */
|
|
103
96
|
this.sectionReadable = false;
|
|
97
|
+
/** An invoice being made of this quotation - its look-ups and its save. */
|
|
98
|
+
this.making = false;
|
|
99
|
+
/* The company's words, the open opportunities a draft may answer, the quotation the address names, and what a new one starts from (ADR-0190). */
|
|
100
|
+
this.companyDefaults = new QueryController(this, DOCUMENT_DEFAULTS, { variables: () => (this.defaultsReadable ? {} : undefined) });
|
|
101
|
+
this.openOpportunities = new QueryController(this, gql `
|
|
102
|
+
query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
|
|
103
|
+
opportunities(filters: $filters, pagination: $pagination, sortings: $sortings) {
|
|
104
|
+
items { id name counterparty { id name businessNumber } }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`, {
|
|
108
|
+
variables: () => ({
|
|
109
|
+
filters: [{ name: 'stage', operator: 'in', value: ['LEAD', 'PROPOSAL', 'QUOTED', 'NEGOTIATION'] }],
|
|
110
|
+
pagination: { page: 1, limit: 500 },
|
|
111
|
+
sortings: [{ name: 'name' }]
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
this.record = new QueryController(this, gql(`query ($id: String!) { quotation(id: $id) { ${FIELDS} } }`), {
|
|
115
|
+
variables: () => (this.quotationId ? { id: this.quotationId } : undefined)
|
|
116
|
+
});
|
|
117
|
+
this.starts = new QueryController(this, gql `
|
|
118
|
+
query ($filters: [Filter!], $sortings: [Sorting!]) {
|
|
119
|
+
numbered: quotations(filters: $filters, pagination: { page: 1, limit: 1000 }) { items { number } }
|
|
120
|
+
last: quotations(pagination: { page: 1, limit: 1 }, sortings: $sortings) { items { direction ${OUR_SIDE_FIELDS.join(' ')} } }
|
|
121
|
+
}
|
|
122
|
+
`, {
|
|
123
|
+
variables: () => this.address && !this.address.id ? { filters: [{ name: 'number', operator: 'like', value: `Q${today().slice(0, 4)}-%` }], sortings: [{ name: 'createdAt', desc: true }] } : undefined
|
|
124
|
+
});
|
|
125
|
+
/* What is typed over the quotation read, or over what a new one starts with; the lines are the grid's. */
|
|
126
|
+
this.draft = new DraftController(this, { read: () => this.formRead, key: () => this.address });
|
|
127
|
+
/* The reason the buyer declined with. */
|
|
128
|
+
this.decline = new DraftController(this, { read: () => ({ reason: '' }), key: () => this.address });
|
|
129
|
+
/* Every change of the quotation reads it again once it is done. */
|
|
130
|
+
this.saves = {
|
|
131
|
+
create: new MutationController(this, gql `mutation ($quotation: NewQuotation!) { quotation: createQuotation(quotation: $quotation) { id } }`),
|
|
132
|
+
update: this.saving(gql `mutation ($id: String!, $quotation: NewQuotation!) { quotation: updateQuotation(id: $id, quotation: $quotation) { id } }`),
|
|
133
|
+
sendQuotation: this.saving(step('sendQuotation')),
|
|
134
|
+
expireQuotation: this.saving(step('expireQuotation')),
|
|
135
|
+
acceptQuotation: this.saving(step('acceptQuotation')),
|
|
136
|
+
reviseQuotation: this.saving(step('reviseQuotation')),
|
|
137
|
+
rejectQuotation: this.saving(step('rejectQuotation', true)),
|
|
138
|
+
invoice: new MutationController(this, gql `
|
|
139
|
+
mutation ($invoice: NewInvoice!) {
|
|
140
|
+
created: createInvoice(invoice: $invoice) { id }
|
|
141
|
+
}
|
|
142
|
+
`)
|
|
143
|
+
};
|
|
144
|
+
this.relined = false;
|
|
104
145
|
}
|
|
105
146
|
static { this.styles = [
|
|
106
147
|
FormFieldStyles,
|
|
@@ -125,6 +166,51 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
125
166
|
}
|
|
126
167
|
`
|
|
127
168
|
]; }
|
|
169
|
+
saving(mutation) {
|
|
170
|
+
return new MutationController(this, mutation, { refetch: () => [this.record] });
|
|
171
|
+
}
|
|
172
|
+
get quotationId() {
|
|
173
|
+
return this.address?.id ?? '';
|
|
174
|
+
}
|
|
175
|
+
/** The company's words a new quotation starts with, the terms it offers and how long an offer holds (ADR-0170) - none until read. */
|
|
176
|
+
get defaults() {
|
|
177
|
+
return this.companyDefaults.data?.documentDefaults ?? null;
|
|
178
|
+
}
|
|
179
|
+
get quotation() {
|
|
180
|
+
return (this.quotationId && this.record.data?.quotation) || null;
|
|
181
|
+
}
|
|
182
|
+
get opportunities() {
|
|
183
|
+
return this.openOpportunities.data?.opportunities?.items ?? [];
|
|
184
|
+
}
|
|
185
|
+
/** What the form shows unedited: the quotation read, or what a new one starts with. */
|
|
186
|
+
get formRead() {
|
|
187
|
+
if (!this.address)
|
|
188
|
+
return undefined;
|
|
189
|
+
return this.address.id ? (this.quotation ?? undefined) : this.start;
|
|
190
|
+
}
|
|
191
|
+
/** The form as it shows: each field's edit, else what was read. */
|
|
192
|
+
get form() {
|
|
193
|
+
return { ...blank(), ...this.formRead, ...this.draft.edits };
|
|
194
|
+
}
|
|
195
|
+
/** The totals of the lines as typed, else as the quotation keeps them. */
|
|
196
|
+
get amounts() {
|
|
197
|
+
return this.typedAmounts ?? this.quotation ?? {};
|
|
198
|
+
}
|
|
199
|
+
get reason() {
|
|
200
|
+
return this.decline.value('reason') ?? '';
|
|
201
|
+
}
|
|
202
|
+
/** A failed read, or a quotation the address names that is not there. */
|
|
203
|
+
get error() {
|
|
204
|
+
if (this.record.error)
|
|
205
|
+
return this.record.error;
|
|
206
|
+
return this.quotationId && this.record.data && !this.record.data.quotation ? new Error(i18next.t('text.quotation-not-found')) : null;
|
|
207
|
+
}
|
|
208
|
+
get loading() {
|
|
209
|
+
return this.record.loading;
|
|
210
|
+
}
|
|
211
|
+
get busy() {
|
|
212
|
+
return this.making || Object.values(this.saves).some(save => save.pending);
|
|
213
|
+
}
|
|
128
214
|
get editable() {
|
|
129
215
|
return !this.quotation || this.quotation.status === 'DRAFT';
|
|
130
216
|
}
|
|
@@ -156,14 +242,12 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
156
242
|
actions: this.error ? [] : actions
|
|
157
243
|
};
|
|
158
244
|
}
|
|
159
|
-
|
|
245
|
+
pageUpdated(changes, lifecycle) {
|
|
160
246
|
if (!this.active || !(changes.active || changes.params))
|
|
161
247
|
return;
|
|
162
|
-
const id = lifecycle?.params
|
|
163
|
-
if (!this.
|
|
164
|
-
|
|
165
|
-
if (!this.defaults)
|
|
166
|
-
this.defaults = await documentDefaults();
|
|
248
|
+
const { id, opportunity } = lifecycle?.params ?? {};
|
|
249
|
+
if (!this.defaultsReadable)
|
|
250
|
+
hasPrivilege(DOCUMENT_DEFAULTS_READER).then(may => (this.defaultsReadable = !!may));
|
|
167
251
|
const section = quotationSection();
|
|
168
252
|
if (section && !this.sectionReadable)
|
|
169
253
|
(section.readable?.() ?? Promise.resolve(true)).then(may => (this.sectionReadable = !!may));
|
|
@@ -171,37 +255,43 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
171
255
|
this.invoiceWriter = !!writer;
|
|
172
256
|
this.updateContext();
|
|
173
257
|
});
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await this.prefill();
|
|
182
|
-
this.updateContext();
|
|
258
|
+
/* Written from an opportunity, the draft answers it. */
|
|
259
|
+
this.address = id ? { id } : { opportunity: opportunity ?? '' };
|
|
260
|
+
}
|
|
261
|
+
willUpdate(changes) {
|
|
262
|
+
super.willUpdate(changes);
|
|
263
|
+
const source = this.quotationId ? this.quotation : JSON.stringify(this.address ?? null);
|
|
264
|
+
if (source === this.lined)
|
|
183
265
|
return;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
266
|
+
this.lined = source;
|
|
267
|
+
this.typedAmounts = null;
|
|
268
|
+
this.configure();
|
|
269
|
+
this.relined = true;
|
|
270
|
+
}
|
|
271
|
+
updated(changes) {
|
|
272
|
+
super.updated(changes);
|
|
273
|
+
if (!this.relined)
|
|
274
|
+
return;
|
|
275
|
+
this.relined = false;
|
|
276
|
+
this.grist?.fetch();
|
|
187
277
|
}
|
|
188
278
|
render() {
|
|
189
279
|
const attached = this.quotation && this.sectionReadable ? quotationSection() : undefined;
|
|
190
280
|
const editable = this.editable && !this.busy;
|
|
191
|
-
return html `<ox-page-state .loading=${this.loading} .error=${this.error} @retry=${() => this.
|
|
281
|
+
return html `<ox-page-state .loading=${this.loading} .error=${this.error} @retry=${() => this.record.refetch()}>
|
|
192
282
|
<div class="page">
|
|
193
283
|
<section class="page-section">
|
|
194
284
|
<header class="page-section-header"><h3>${i18next.t('text.quotation-section-head')}</h3></header>
|
|
195
285
|
${renderDocumentHead(this.form, editable, patch => this.edit(patch), html `<label
|
|
196
286
|
>${i18next.t('field.quotation-valid-until')}
|
|
197
|
-
<input type="date" .value=${this.form.validUntil ?? ''} ?disabled=${!editable} @input=${(e) =>
|
|
287
|
+
<input type="date" .value=${this.form.validUntil ?? ''} ?disabled=${!editable} @input=${(e) => this.put({ validUntil: e.target.value })}
|
|
198
288
|
/></label>
|
|
199
289
|
${this.renderOpportunity(editable)}`, 'field.quotation-number', {
|
|
200
290
|
terms: this.defaults?.paymentTerms,
|
|
201
291
|
/* In the group it fills - the buyer's of a sale, the supplier's of a purchase. */
|
|
202
292
|
party: html `<label
|
|
203
293
|
>${i18next.t('field.counterparty')}
|
|
204
|
-
<document-party-select .party=${this.party} ?disabled=${!editable} @party=${(e) => this.takeParty(e.detail)}></document-party-select>
|
|
294
|
+
<document-party-select .party=${this.form.party ?? null} ?disabled=${!editable} @party=${(e) => this.takeParty(e.detail)}></document-party-select>
|
|
205
295
|
</label>`
|
|
206
296
|
})}
|
|
207
297
|
</section>
|
|
@@ -216,8 +306,8 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
216
306
|
<ox-grist
|
|
217
307
|
mode="GRID"
|
|
218
308
|
.config=${this.gristConfig}
|
|
219
|
-
.fetchHandler=${() => ({ total: this.form.items.length, records: this.form.items })}
|
|
220
|
-
@record-change=${(e) => (this.
|
|
309
|
+
.fetchHandler=${() => ({ total: (this.form.items ?? []).length, records: (this.form.items ?? []).map((item) => ({ ...item })) })}
|
|
310
|
+
@record-change=${(e) => (this.typedAmounts = onLineChange(this.grist, e, this.form.currency, false, this.form.discountAmount))}
|
|
221
311
|
></ox-grist>
|
|
222
312
|
</section>
|
|
223
313
|
<section class="page-section">
|
|
@@ -245,13 +335,13 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
245
335
|
<header class="page-section-header">
|
|
246
336
|
<h3>${i18next.t('text.quotation-section-decline')}</h3>
|
|
247
337
|
<div class="page-section-actions">
|
|
248
|
-
<button ?disabled=${this.busy || !this.reason.trim()} @click=${() => this.change('rejectQuotation',
|
|
338
|
+
<button ?disabled=${this.busy || !this.reason.trim()} @click=${() => this.decline.submit(sent => this.change('rejectQuotation', (sent.reason ?? '').trim()))}>${i18next.t('button.quotation-reject')}</button>
|
|
249
339
|
</div>
|
|
250
340
|
</header>
|
|
251
341
|
<div class="form">
|
|
252
342
|
<label class="wide"
|
|
253
343
|
>${i18next.t('field.quotation-outcome-reason')}
|
|
254
|
-
<input .value=${this.reason} ?disabled=${this.busy} @input=${(e) =>
|
|
344
|
+
<input .value=${this.reason} ?disabled=${this.busy} @input=${(e) => this.decline.set('reason', e.target.value)}
|
|
255
345
|
/></label>
|
|
256
346
|
</div>
|
|
257
347
|
</section>`;
|
|
@@ -266,22 +356,27 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
266
356
|
return;
|
|
267
357
|
grist.addRecord(lineOfProduct(product));
|
|
268
358
|
await grist.updateComplete;
|
|
269
|
-
this.
|
|
359
|
+
this.typedAmounts = reckonLines(grist, this.form.currency, false, this.form.discountAmount);
|
|
270
360
|
grist.refresh();
|
|
271
361
|
}
|
|
272
362
|
/** The discount of the whole quotation as typed, and the totals after it. */
|
|
273
363
|
discount(value) {
|
|
274
|
-
this.
|
|
275
|
-
this.
|
|
364
|
+
this.put({ discountAmount: value });
|
|
365
|
+
this.typedAmounts = reckonLines(this.grist, this.form.currency, false, value);
|
|
366
|
+
}
|
|
367
|
+
/** Edits of several fields at once. */
|
|
368
|
+
put(patch) {
|
|
369
|
+
for (const [name, value] of Object.entries(patch))
|
|
370
|
+
this.draft.set(name, value);
|
|
276
371
|
}
|
|
277
372
|
/** A change of the head; a new currency reckons the lines in its minor unit; the last day follows a moved issue day. */
|
|
278
373
|
edit(patch) {
|
|
279
374
|
const currency = this.form.currency;
|
|
280
375
|
const following = patch.issueDate && this.form.validUntil === daysAfter(this.form.issueDate, this.validDays);
|
|
281
|
-
this.
|
|
376
|
+
this.put({ ...patch, ...(following ? { validUntil: daysAfter(patch.issueDate, this.validDays) } : {}) });
|
|
282
377
|
if (patch.currency && patch.currency !== currency) {
|
|
283
378
|
this.configure();
|
|
284
|
-
this.
|
|
379
|
+
this.typedAmounts = reckonLines(this.grist, this.form.currency, false, this.form.discountAmount);
|
|
285
380
|
}
|
|
286
381
|
}
|
|
287
382
|
/** The opportunity it answers - found by typing; choosing one takes its counterparty as the buyer when none is written. */
|
|
@@ -300,7 +395,7 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
300
395
|
if (id === (this.form.opportunityId ?? ''))
|
|
301
396
|
return;
|
|
302
397
|
const opportunity = choices.find(one => one.id === id);
|
|
303
|
-
this.
|
|
398
|
+
this.put({ opportunityId: id });
|
|
304
399
|
if (opportunity?.counterparty && !String(this.form.buyerName ?? '').trim())
|
|
305
400
|
this.takeParty(opportunity.counterparty);
|
|
306
401
|
}}
|
|
@@ -315,45 +410,26 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
315
410
|
}
|
|
316
411
|
/** A counterparty as the other side: the buyer of a sale, the supplier of a purchase. */
|
|
317
412
|
takeParty(party) {
|
|
318
|
-
this.party
|
|
319
|
-
if (!party)
|
|
320
|
-
return;
|
|
321
|
-
const side = this.form.direction === 'PURCHASE' ? 'supplier' : 'buyer';
|
|
322
|
-
this.form = { ...this.form, [`${side}Name`]: party.name, [`${side}RegistrationNumber`]: party.businessNumber ?? this.form[`${side}RegistrationNumber`] ?? '' };
|
|
413
|
+
this.put(party ? partyFields(this.form, party) : { party: null });
|
|
323
414
|
}
|
|
324
415
|
/**
|
|
325
416
|
* What a new quotation starts with, so a person writes only what is new: the next number of the year, the last day an
|
|
326
|
-
* offer holds, the company's own words (ADR-0170),
|
|
327
|
-
* wrote it.
|
|
417
|
+
* offer holds, the company's own words (ADR-0170), what they leave blank of our side as the last sales quotation
|
|
418
|
+
* wrote it, and the counterparty of the opportunity it answers.
|
|
328
419
|
*/
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const last = (response.data?.last?.items ?? []).find((one) => one.direction === 'SALES');
|
|
343
|
-
this.form = { ...this.form, ...headFromDefaults(this.form, this.defaults) };
|
|
344
|
-
this.form = {
|
|
345
|
-
...this.form,
|
|
346
|
-
...oursFromLast(this.form, last),
|
|
347
|
-
number: this.form.number || nextQuotationNumber((response.data?.numbered?.items ?? []).map((one) => one.number), year),
|
|
348
|
-
validUntil: this.form.validUntil || daysAfter(this.form.issueDate, this.validDays)
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
/* Nothing to start from - the person writes it, as before. */
|
|
353
|
-
}
|
|
354
|
-
const opportunity = this.opportunities.find(one => one.id === this.form.opportunityId);
|
|
355
|
-
if (opportunity?.counterparty && !String(this.form.buyerName ?? '').trim())
|
|
356
|
-
this.takeParty(opportunity.counterparty);
|
|
420
|
+
get start() {
|
|
421
|
+
const form = { ...blank(), opportunityId: this.address?.opportunity ?? '' };
|
|
422
|
+
const started = this.starts.data;
|
|
423
|
+
const last = (started?.last?.items ?? []).find((one) => one.direction === 'SALES');
|
|
424
|
+
const head = { ...form, ...headFromDefaults(form, this.defaults) };
|
|
425
|
+
const filled = {
|
|
426
|
+
...head,
|
|
427
|
+
...oursFromLast(head, last),
|
|
428
|
+
number: started ? nextQuotationNumber((started.numbered?.items ?? []).map((one) => one.number), Number(form.issueDate.slice(0, 4))) : '',
|
|
429
|
+
validUntil: daysAfter(form.issueDate, this.validDays)
|
|
430
|
+
};
|
|
431
|
+
const opportunity = this.opportunities.find(one => one.id === form.opportunityId);
|
|
432
|
+
return opportunity?.counterparty ? { ...filled, ...partyFields(filled, opportunity.counterparty) } : filled;
|
|
357
433
|
}
|
|
358
434
|
/** Days an offer holds - the company's, or thirty. */
|
|
359
435
|
get validDays() {
|
|
@@ -363,100 +439,29 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
363
439
|
const answered = this.quotation?.opportunity;
|
|
364
440
|
return answered && !this.opportunities.some(one => one.id === answered.id) ? [answered, ...this.opportunities] : this.opportunities;
|
|
365
441
|
}
|
|
366
|
-
async readOpportunities() {
|
|
367
|
-
try {
|
|
368
|
-
const response = await client.query({
|
|
369
|
-
query: gql `
|
|
370
|
-
query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
|
|
371
|
-
opportunities(filters: $filters, pagination: $pagination, sortings: $sortings) {
|
|
372
|
-
items { id name counterparty { id name businessNumber } }
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
`,
|
|
376
|
-
variables: {
|
|
377
|
-
filters: [{ name: 'stage', operator: 'in', value: ['LEAD', 'PROPOSAL', 'QUOTED', 'NEGOTIATION'] }],
|
|
378
|
-
pagination: { page: 1, limit: 500 },
|
|
379
|
-
sortings: [{ name: 'name' }]
|
|
380
|
-
},
|
|
381
|
-
fetchPolicy: 'no-cache'
|
|
382
|
-
});
|
|
383
|
-
this.opportunities = response.data?.opportunities?.items ?? [];
|
|
384
|
-
}
|
|
385
|
-
catch (error) {
|
|
386
|
-
notify({ level: 'error', message: error?.graphQLErrors?.[0]?.message ?? error?.message ?? String(error) });
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
442
|
configure() {
|
|
390
443
|
this.gristConfig = documentLinesConfig({ editable: this.editable, currency: this.form.currency || 'KRW' });
|
|
391
444
|
}
|
|
392
|
-
async load(id) {
|
|
393
|
-
this.loading = true;
|
|
394
|
-
this.error = null;
|
|
395
|
-
try {
|
|
396
|
-
const response = await client.query({ query: gql(`query ($id: String!) { quotation(id: $id) { ${FIELDS} } }`), variables: { id }, fetchPolicy: 'no-cache' });
|
|
397
|
-
if (response.errors?.length)
|
|
398
|
-
throw response;
|
|
399
|
-
if (!response.data.quotation)
|
|
400
|
-
throw new Error(i18next.t('text.quotation-not-found'));
|
|
401
|
-
this.show(response.data.quotation);
|
|
402
|
-
}
|
|
403
|
-
catch (error) {
|
|
404
|
-
this.error = error;
|
|
405
|
-
}
|
|
406
|
-
finally {
|
|
407
|
-
this.loading = false;
|
|
408
|
-
this.updateContext();
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
show(quotation) {
|
|
412
|
-
this.quotation = quotation;
|
|
413
|
-
this.form = { ...quotation, items: quotation.items.map((item) => ({ ...item })) };
|
|
414
|
-
this.amounts = quotation;
|
|
415
|
-
this.reason = '';
|
|
416
|
-
this.configure();
|
|
417
|
-
this.grist?.fetch();
|
|
418
|
-
}
|
|
419
|
-
async run(work) {
|
|
420
|
-
if (this.busy)
|
|
421
|
-
return undefined;
|
|
422
|
-
this.busy = true;
|
|
423
|
-
try {
|
|
424
|
-
return await work();
|
|
425
|
-
}
|
|
426
|
-
catch (error) {
|
|
427
|
-
notify({ level: 'error', message: error?.graphQLErrors?.[0]?.message ?? error?.errors?.[0]?.message ?? error?.message ?? String(error) });
|
|
428
|
-
return undefined;
|
|
429
|
-
}
|
|
430
|
-
finally {
|
|
431
|
-
this.busy = false;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
445
|
input() {
|
|
435
446
|
const input = Object.fromEntries(HEAD.map(key => [key, this.form[key] === '' ? null : this.form[key] ?? null]));
|
|
436
447
|
input.items = linesOf(this.grist).map(({ receivedTaxAmount, ...line }) => line);
|
|
437
448
|
input.discountAmount = input.discountAmount === null ? 0 : Number(input.discountAmount);
|
|
438
449
|
return input;
|
|
439
450
|
}
|
|
451
|
+
/** A new one is opened once it is made; the edits sent are cleared once the save answered. Answers the quotation's id. */
|
|
440
452
|
async save() {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
? `mutation ($id: String!, $quotation: NewQuotation!) { quotation: updateQuotation(id: $id, quotation: $quotation) { ${FIELDS} } }`
|
|
446
|
-
: `mutation ($quotation: NewQuotation!) { quotation: createQuotation(quotation: $quotation) { ${FIELDS} } }`),
|
|
447
|
-
variables: { ...(id ? { id } : {}), quotation: this.input() }
|
|
448
|
-
});
|
|
449
|
-
if (response.errors?.length)
|
|
450
|
-
throw response;
|
|
451
|
-
return response.data.quotation;
|
|
452
|
-
});
|
|
453
|
+
if (this.busy)
|
|
454
|
+
return undefined;
|
|
455
|
+
const id = this.quotationId;
|
|
456
|
+
const saved = await this.draft.submit(() => (id ? this.saves.update.run({ id, quotation: this.input() }) : this.saves.create.run({ quotation: this.input() })));
|
|
453
457
|
if (!saved)
|
|
454
458
|
return undefined;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
navigate(`quotation-editor?id=${saved.id}`);
|
|
459
|
-
|
|
459
|
+
if (!id) {
|
|
460
|
+
/* The lines went with it: the grid keeps them as saved while the new quotation opens. */
|
|
461
|
+
this.grist?.commit();
|
|
462
|
+
navigate(`quotation-editor?id=${saved.quotation.id}`);
|
|
463
|
+
}
|
|
464
|
+
return saved.quotation.id;
|
|
460
465
|
}
|
|
461
466
|
/** Asked first - to whom and how much, fixed after - then save what is on screen and send it. */
|
|
462
467
|
async send() {
|
|
@@ -475,26 +480,16 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
475
480
|
return;
|
|
476
481
|
const saved = await this.save();
|
|
477
482
|
if (saved)
|
|
478
|
-
await this.change('sendQuotation');
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (response.errors?.length)
|
|
489
|
-
throw response;
|
|
490
|
-
return response.data.quotation;
|
|
491
|
-
});
|
|
492
|
-
if (!changed)
|
|
493
|
-
return;
|
|
494
|
-
if (changed.id !== this.quotation?.id)
|
|
495
|
-
navigate(`quotation-editor?id=${changed.id}`);
|
|
496
|
-
this.show(changed);
|
|
497
|
-
this.updateContext();
|
|
483
|
+
await this.change('sendQuotation', undefined, saved);
|
|
484
|
+
}
|
|
485
|
+
/** A step of the quotation's life; a revision opens the new version. */
|
|
486
|
+
async change(name, reason, id = this.quotationId) {
|
|
487
|
+
if (this.busy)
|
|
488
|
+
return undefined;
|
|
489
|
+
const changed = await this.saves[name].run({ id, ...(reason === undefined ? {} : { reason }) });
|
|
490
|
+
if (changed && changed.quotation.id !== id)
|
|
491
|
+
navigate(`quotation-editor?id=${changed.quotation.id}`);
|
|
492
|
+
return changed;
|
|
498
493
|
}
|
|
499
494
|
/**
|
|
500
495
|
* An invoice draft of an accepted quotation (ADR-0170 decision 11): its parties, currency, language, terms, account,
|
|
@@ -503,8 +498,12 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
503
498
|
* as the invoice editor links it.
|
|
504
499
|
*/
|
|
505
500
|
async makeInvoice() {
|
|
501
|
+
if (this.busy)
|
|
502
|
+
return;
|
|
506
503
|
const quotation = this.quotation;
|
|
507
|
-
|
|
504
|
+
this.making = true;
|
|
505
|
+
try {
|
|
506
|
+
/* Looked up for the invoice, shown nowhere: the number after the last sales invoice's, and the counterparty. */
|
|
508
507
|
const last = await client.query({
|
|
509
508
|
query: gql `
|
|
510
509
|
query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
|
|
@@ -516,40 +515,37 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
516
515
|
});
|
|
517
516
|
const issueDate = today();
|
|
518
517
|
const party = await partyOfName(quotation.direction === 'PURCHASE' ? quotation.supplierName : quotation.buyerName);
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
invoice:
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
unit: unit ?? null,
|
|
539
|
-
unitPrice,
|
|
540
|
-
discount,
|
|
541
|
-
taxKind,
|
|
542
|
-
taxRate
|
|
543
|
-
}))
|
|
544
|
-
}
|
|
518
|
+
const made = await this.saves.invoice.run({
|
|
519
|
+
invoice: {
|
|
520
|
+
number: nextInvoiceNumber(last.data?.invoices?.items?.[0]?.number, Number(issueDate.slice(0, 4))),
|
|
521
|
+
counterpartyId: party?.id ?? null,
|
|
522
|
+
...Object.fromEntries(INVOICE_HEAD.map(key => [key, quotation[key] ?? null])),
|
|
523
|
+
issueDate,
|
|
524
|
+
dueDate: daysAfter(issueDate, this.defaults?.paymentDays ?? PAYMENT_DAYS),
|
|
525
|
+
sourceReference: i18next.t('text.quotation-invoice-source', { number: quotation.number, version: quotation.version }),
|
|
526
|
+
taxBasis: 'CALCULATED',
|
|
527
|
+
discountAmount: Number(quotation.discountAmount ?? 0),
|
|
528
|
+
items: quotation.items.map(({ description, quantity, unit, unitPrice, discount, taxKind, taxRate }) => ({
|
|
529
|
+
description,
|
|
530
|
+
quantity,
|
|
531
|
+
unit: unit ?? null,
|
|
532
|
+
unitPrice,
|
|
533
|
+
discount,
|
|
534
|
+
taxKind,
|
|
535
|
+
taxRate
|
|
536
|
+
}))
|
|
545
537
|
}
|
|
546
538
|
});
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
|
|
539
|
+
const created = made?.created;
|
|
540
|
+
if (created)
|
|
541
|
+
navigate(`invoice-editor?id=${created.id}`);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
/* The look-up's failure is told by the GraphQL link. */
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
this.making = false;
|
|
548
|
+
}
|
|
553
549
|
}
|
|
554
550
|
/**
|
|
555
551
|
* The saved quotation as it goes out (ADR-0170 decision 10) - the server's page in the language chosen there, the
|
|
@@ -576,55 +572,31 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
|
|
|
576
572
|
__decorate([
|
|
577
573
|
state(),
|
|
578
574
|
__metadata("design:type", Object)
|
|
579
|
-
], QuotationEditor.prototype, "
|
|
580
|
-
__decorate([
|
|
581
|
-
state(),
|
|
582
|
-
__metadata("design:type", Object)
|
|
583
|
-
], QuotationEditor.prototype, "form", void 0);
|
|
584
|
-
__decorate([
|
|
585
|
-
state(),
|
|
586
|
-
__metadata("design:type", Object)
|
|
587
|
-
], QuotationEditor.prototype, "loading", void 0);
|
|
588
|
-
__decorate([
|
|
589
|
-
state(),
|
|
590
|
-
__metadata("design:type", Object)
|
|
591
|
-
], QuotationEditor.prototype, "error", void 0);
|
|
592
|
-
__decorate([
|
|
593
|
-
state(),
|
|
594
|
-
__metadata("design:type", Object)
|
|
595
|
-
], QuotationEditor.prototype, "busy", void 0);
|
|
596
|
-
__decorate([
|
|
597
|
-
state(),
|
|
598
|
-
__metadata("design:type", Object)
|
|
599
|
-
], QuotationEditor.prototype, "reason", void 0);
|
|
575
|
+
], QuotationEditor.prototype, "address", void 0);
|
|
600
576
|
__decorate([
|
|
601
577
|
state(),
|
|
602
578
|
__metadata("design:type", Object)
|
|
603
579
|
], QuotationEditor.prototype, "gristConfig", void 0);
|
|
604
|
-
__decorate([
|
|
605
|
-
state(),
|
|
606
|
-
__metadata("design:type", Array)
|
|
607
|
-
], QuotationEditor.prototype, "opportunities", void 0);
|
|
608
580
|
__decorate([
|
|
609
581
|
state(),
|
|
610
582
|
__metadata("design:type", Object)
|
|
611
|
-
], QuotationEditor.prototype, "
|
|
583
|
+
], QuotationEditor.prototype, "defaultsReadable", void 0);
|
|
612
584
|
__decorate([
|
|
613
585
|
state(),
|
|
614
586
|
__metadata("design:type", Object)
|
|
615
|
-
], QuotationEditor.prototype, "
|
|
587
|
+
], QuotationEditor.prototype, "typedAmounts", void 0);
|
|
616
588
|
__decorate([
|
|
617
589
|
state(),
|
|
618
590
|
__metadata("design:type", Object)
|
|
619
|
-
], QuotationEditor.prototype, "
|
|
591
|
+
], QuotationEditor.prototype, "invoiceWriter", void 0);
|
|
620
592
|
__decorate([
|
|
621
593
|
state(),
|
|
622
594
|
__metadata("design:type", Object)
|
|
623
|
-
], QuotationEditor.prototype, "
|
|
595
|
+
], QuotationEditor.prototype, "sectionReadable", void 0);
|
|
624
596
|
__decorate([
|
|
625
597
|
state(),
|
|
626
598
|
__metadata("design:type", Object)
|
|
627
|
-
], QuotationEditor.prototype, "
|
|
599
|
+
], QuotationEditor.prototype, "making", void 0);
|
|
628
600
|
__decorate([
|
|
629
601
|
query('ox-grist'),
|
|
630
602
|
__metadata("design:type", DataGrist)
|
|
@@ -633,6 +605,11 @@ QuotationEditor = __decorate([
|
|
|
633
605
|
customElement('quotation-editor')
|
|
634
606
|
], QuotationEditor);
|
|
635
607
|
export { QuotationEditor };
|
|
608
|
+
/** A counterparty as the other side of `form`: the buyer of a sale, the supplier of a purchase. */
|
|
609
|
+
function partyFields(form, party) {
|
|
610
|
+
const side = form.direction === 'PURCHASE' ? 'supplier' : 'buyer';
|
|
611
|
+
return { party, [`${side}Name`]: party.name, [`${side}RegistrationNumber`]: party.businessNumber ?? form[`${side}RegistrationNumber`] ?? '' };
|
|
612
|
+
}
|
|
636
613
|
function blank() {
|
|
637
614
|
return { number: '', direction: 'SALES', currency: 'KRW', language: 'ko', supplierName: '', buyerName: '', issueDate: today(), validUntil: '', discountAmount: 0, items: [] };
|
|
638
615
|
}
|