@things-factory/sales 10.1.255 → 10.1.257

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.
Files changed (36) hide show
  1. package/dist-client/entity360/opportunity-360.d.ts +3 -4
  2. package/dist-client/entity360/opportunity-360.js +16 -38
  3. package/dist-client/entity360/opportunity-360.js.map +1 -1
  4. package/dist-client/pages/opportunity-editor.d.ts +25 -33
  5. package/dist-client/pages/opportunity-editor.js +108 -175
  6. package/dist-client/pages/opportunity-editor.js.map +1 -1
  7. package/dist-client/pages/opportunity-list.d.ts +2 -3
  8. package/dist-client/pages/opportunity-list.js +6 -12
  9. package/dist-client/pages/opportunity-list.js.map +1 -1
  10. package/dist-client/pages/quotation-editor.d.ts +45 -26
  11. package/dist-client/pages/quotation-editor.js +225 -248
  12. package/dist-client/pages/quotation-editor.js.map +1 -1
  13. package/dist-client/pages/quotation-list.d.ts +7 -3
  14. package/dist-client/pages/quotation-list.js +26 -27
  15. package/dist-client/pages/quotation-list.js.map +1 -1
  16. package/dist-client/pages/sales-product-list.d.ts +7 -2
  17. package/dist-client/pages/sales-product-list.js +36 -55
  18. package/dist-client/pages/sales-product-list.js.map +1 -1
  19. package/dist-client/sales-product-pick.d.ts +4 -2
  20. package/dist-client/sales-product-pick.js +36 -32
  21. package/dist-client/sales-product-pick.js.map +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/dist-server/index.d.ts +1 -0
  24. package/dist-server/index.js +1 -0
  25. package/dist-server/index.js.map +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/dist-server/work-doors.d.ts +1 -0
  28. package/dist-server/work-doors.js +13 -0
  29. package/dist-server/work-doors.js.map +1 -0
  30. package/package.json +10 -10
  31. package/translations/en.json +4 -1
  32. package/translations/es.json +4 -1
  33. package/translations/ja.json +4 -1
  34. package/translations/ko.json +4 -1
  35. package/translations/ms.json +4 -1
  36. package/translations/zh.json +4 -1
@@ -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, documentDefaults, headFromDefaults, nextInvoiceNumber, OUR_SIDE_FIELDS, oursFromLast, partyOfName } from '@things-factory/invoice/dist-client';
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.quotation = null;
87
- this.form = blank();
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.amounts = {};
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
- async pageUpdated(changes, lifecycle) {
245
+ pageUpdated(changes, lifecycle) {
160
246
  if (!this.active || !(changes.active || changes.params))
161
247
  return;
162
- const id = lifecycle?.params?.id;
163
- if (!this.opportunities.length)
164
- await this.readOpportunities();
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
- if (!id) {
175
- this.quotation = null;
176
- /* Written from an opportunity, the draft answers it. */
177
- this.form = { ...blank(), opportunityId: lifecycle?.params?.opportunity ?? '' };
178
- this.amounts = {};
179
- this.party = null;
180
- this.configure();
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
- if (id !== this.quotation?.id)
186
- await this.load(id);
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.quotation && this.load(this.quotation.id)}>
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) => (this.form = { ...this.form, validUntil: e.target.value })}
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.amounts = onLineChange(this.grist, e, this.form.currency, false, this.form.discountAmount))}
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', this.reason.trim())}>${i18next.t('button.quotation-reject')}</button>
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) => (this.reason = e.target.value)}
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.amounts = reckonLines(grist, this.form.currency, false, this.form.discountAmount);
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.form = { ...this.form, discountAmount: value };
275
- this.amounts = reckonLines(this.grist, this.form.currency, false, value);
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.form = { ...this.form, ...patch, ...(following ? { validUntil: daysAfter(patch.issueDate, this.validDays) } : {}) };
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.amounts = reckonLines(this.grist, this.form.currency, false, this.form.discountAmount);
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.form = { ...this.form, opportunityId: id };
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 = 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), and what they leave blank of our side as the last sales quotation
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
- async prefill() {
330
- try {
331
- const year = Number(this.form.issueDate.slice(0, 4));
332
- const response = await client.query({
333
- query: gql `
334
- query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
335
- numbered: quotations(filters: $filters, pagination: { page: 1, limit: 1000 }) { items { number } }
336
- last: quotations(pagination: $pagination, sortings: $sortings) { items { direction ${OUR_SIDE_FIELDS.join(' ')} } }
337
- }
338
- `,
339
- variables: { filters: [{ name: 'number', operator: 'like', value: `Q${year}-%` }], pagination: { page: 1, limit: 1 }, sortings: [{ name: 'createdAt', desc: true }] },
340
- fetchPolicy: 'no-cache'
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
- const id = this.quotation?.id;
442
- const saved = await this.run(async () => {
443
- const response = await client.mutate({
444
- mutation: gql(id
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
- this.show(saved);
456
- this.updateContext();
457
- if (!id)
458
- navigate(`quotation-editor?id=${saved.id}`);
459
- return saved;
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
- async change(mutation, reason) {
481
- const changed = await this.run(async () => {
482
- const response = await client.mutate({
483
- mutation: gql(reason === undefined
484
- ? `mutation ($id: String!) { quotation: ${mutation}(id: $id) { ${FIELDS} } }`
485
- : `mutation ($id: String!, $reason: String!) { quotation: ${mutation}(id: $id, reason: $reason) { ${FIELDS} } }`),
486
- variables: { id: this.quotation.id, ...(reason === undefined ? {} : { reason }) }
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
- const created = await this.run(async () => {
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 response = await client.mutate({
520
- mutation: gql `
521
- mutation ($invoice: NewInvoice!) {
522
- createInvoice(invoice: $invoice) { id }
523
- }
524
- `,
525
- variables: {
526
- invoice: {
527
- number: nextInvoiceNumber(last.data?.invoices?.items?.[0]?.number, Number(issueDate.slice(0, 4))),
528
- counterpartyId: party?.id ?? null,
529
- ...Object.fromEntries(INVOICE_HEAD.map(key => [key, quotation[key] ?? null])),
530
- issueDate,
531
- dueDate: daysAfter(issueDate, this.defaults?.paymentDays ?? PAYMENT_DAYS),
532
- sourceReference: i18next.t('text.quotation-invoice-source', { number: quotation.number, version: quotation.version }),
533
- taxBasis: 'CALCULATED',
534
- discountAmount: Number(quotation.discountAmount ?? 0),
535
- items: quotation.items.map(({ description, quantity, unit, unitPrice, discount, taxKind, taxRate }) => ({
536
- description,
537
- quantity,
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
- if (response.errors?.length)
548
- throw response;
549
- return response.data.createInvoice;
550
- });
551
- if (created)
552
- navigate(`invoice-editor?id=${created.id}`);
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, "quotation", void 0);
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, "defaults", void 0);
583
+ ], QuotationEditor.prototype, "defaultsReadable", void 0);
612
584
  __decorate([
613
585
  state(),
614
586
  __metadata("design:type", Object)
615
- ], QuotationEditor.prototype, "party", void 0);
587
+ ], QuotationEditor.prototype, "typedAmounts", void 0);
616
588
  __decorate([
617
589
  state(),
618
590
  __metadata("design:type", Object)
619
- ], QuotationEditor.prototype, "amounts", void 0);
591
+ ], QuotationEditor.prototype, "invoiceWriter", void 0);
620
592
  __decorate([
621
593
  state(),
622
594
  __metadata("design:type", Object)
623
- ], QuotationEditor.prototype, "invoiceWriter", void 0);
595
+ ], QuotationEditor.prototype, "sectionReadable", void 0);
624
596
  __decorate([
625
597
  state(),
626
598
  __metadata("design:type", Object)
627
- ], QuotationEditor.prototype, "sectionReadable", void 0);
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
  }