@things-factory/sales 10.1.223 → 10.1.227

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 (58) hide show
  1. package/dist-client/index.d.ts +1 -0
  2. package/dist-client/index.js +1 -0
  3. package/dist-client/index.js.map +1 -1
  4. package/dist-client/pages/quotation-editor.d.ts +50 -6
  5. package/dist-client/pages/quotation-editor.js +303 -33
  6. package/dist-client/pages/quotation-editor.js.map +1 -1
  7. package/dist-client/pages/sales-product-list.d.ts +54 -0
  8. package/dist-client/pages/sales-product-list.js +202 -0
  9. package/dist-client/pages/sales-product-list.js.map +1 -0
  10. package/dist-client/route.d.ts +1 -1
  11. package/dist-client/route.js +3 -0
  12. package/dist-client/route.js.map +1 -1
  13. package/dist-client/sales-product-pick.d.ts +51 -0
  14. package/dist-client/sales-product-pick.js +210 -0
  15. package/dist-client/sales-product-pick.js.map +1 -0
  16. package/dist-client/sales-product-words.d.ts +2 -0
  17. package/dist-client/sales-product-words.js +8 -0
  18. package/dist-client/sales-product-words.js.map +1 -0
  19. package/dist-client/tsconfig.tsbuildinfo +1 -1
  20. package/dist-server/service/index.d.ts +6 -2
  21. package/dist-server/service/index.js +8 -3
  22. package/dist-server/service/index.js.map +1 -1
  23. package/dist-server/service/quotation/quotation-query.d.ts +3 -0
  24. package/dist-server/service/quotation/quotation-query.js +36 -0
  25. package/dist-server/service/quotation/quotation-query.js.map +1 -1
  26. package/dist-server/service/quotation/quotation-service.d.ts +1 -0
  27. package/dist-server/service/quotation/quotation-service.js +5 -4
  28. package/dist-server/service/quotation/quotation-service.js.map +1 -1
  29. package/dist-server/service/sales-product/index.d.ts +6 -0
  30. package/dist-server/service/sales-product/index.js +10 -0
  31. package/dist-server/service/sales-product/index.js.map +1 -0
  32. package/dist-server/service/sales-product/sales-product-mutation.d.ts +6 -0
  33. package/dist-server/service/sales-product/sales-product-mutation.js +46 -0
  34. package/dist-server/service/sales-product/sales-product-mutation.js.map +1 -0
  35. package/dist-server/service/sales-product/sales-product-query.d.ts +11 -0
  36. package/dist-server/service/sales-product/sales-product-query.js +78 -0
  37. package/dist-server/service/sales-product/sales-product-query.js.map +1 -0
  38. package/dist-server/service/sales-product/sales-product-rules.d.ts +8 -0
  39. package/dist-server/service/sales-product/sales-product-rules.js +26 -0
  40. package/dist-server/service/sales-product/sales-product-rules.js.map +1 -0
  41. package/dist-server/service/sales-product/sales-product-service.d.ts +10 -0
  42. package/dist-server/service/sales-product/sales-product-service.js +86 -0
  43. package/dist-server/service/sales-product/sales-product-service.js.map +1 -0
  44. package/dist-server/service/sales-product/sales-product-type.d.ts +22 -0
  45. package/dist-server/service/sales-product/sales-product-type.js +81 -0
  46. package/dist-server/service/sales-product/sales-product-type.js.map +1 -0
  47. package/dist-server/service/sales-product/sales-product.d.ts +32 -0
  48. package/dist-server/service/sales-product/sales-product.js +126 -0
  49. package/dist-server/service/sales-product/sales-product.js.map +1 -0
  50. package/dist-server/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +7 -7
  52. package/things-factory.config.js +2 -1
  53. package/translations/en.json +33 -3
  54. package/translations/es.json +33 -3
  55. package/translations/ja.json +33 -3
  56. package/translations/ko.json +34 -4
  57. package/translations/ms.json +33 -3
  58. package/translations/zh.json +33 -3
@@ -2,3 +2,4 @@ export { configureQuotationSection, type QuotationSection } from './quotation-at
2
2
  export { quotationStatusWord } from './quotation-words.js';
3
3
  export { configureOpportunitySection, type OpportunitySection } from './opportunity-attach.js';
4
4
  export { opportunityStageWord } from './opportunity-words.js';
5
+ export { salesProductKindWord } from './sales-product-words.js';
@@ -2,4 +2,5 @@ export { configureQuotationSection } from './quotation-attach.js';
2
2
  export { quotationStatusWord } from './quotation-words.js';
3
3
  export { configureOpportunitySection } from './opportunity-attach.js';
4
4
  export { opportunityStageWord } from './opportunity-words.js';
5
+ export { salesProductKindWord } from './sales-product-words.js';
5
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAyB,MAAM,uBAAuB,CAAA;AACxF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,EAAE,2BAA2B,EAA2B,MAAM,yBAAyB,CAAA;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA","sourcesContent":["export { configureQuotationSection, type QuotationSection } from './quotation-attach.js'\nexport { quotationStatusWord } from './quotation-words.js'\nexport { configureOpportunitySection, type OpportunitySection } from './opportunity-attach.js'\nexport { opportunityStageWord } from './opportunity-words.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAyB,MAAM,uBAAuB,CAAA;AACxF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,EAAE,2BAA2B,EAA2B,MAAM,yBAAyB,CAAA;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAA","sourcesContent":["export { configureQuotationSection, type QuotationSection } from './quotation-attach.js'\nexport { quotationStatusWord } from './quotation-words.js'\nexport { configureOpportunitySection, type OpportunitySection } from './opportunity-attach.js'\nexport { opportunityStageWord } from './opportunity-words.js'\nexport { salesProductKindWord } from './sales-product-words.js'\n"]}
@@ -1,7 +1,13 @@
1
1
  import '@operato/data-grist/ox-grist.js';
2
2
  import '@operato/list-state/ox-page-state.js';
3
+ import '@operato/input/ox-select.js';
4
+ import '@operato/popup/ox-popup-list.js';
3
5
  import { PageView } from '@operato/shell';
4
- import { type DocumentValue } from '@things-factory/invoice/dist-client';
6
+ import { type DocumentDefaults, type DocumentParty, type DocumentValue } from '@things-factory/invoice/dist-client';
7
+ import '@things-factory/invoice/dist-client/document/document-party.js';
8
+ import '../sales-product-pick';
9
+ /** The next quotation number of a year after those given - 「Q2026-001」, 「Q2026-002」 … */
10
+ export declare function nextQuotationNumber(numbers: string[], year: number): string;
5
11
  declare const QuotationEditor_base: (new (...args: any[]) => import("lit").LitElement) & typeof PageView;
6
12
  /**
7
13
  * 견적 편집 — the same head, lines and totals as an invoice, valid until a day.
@@ -22,10 +28,16 @@ export declare class QuotationEditor extends QuotationEditor_base {
22
28
  opportunities: {
23
29
  id: string;
24
30
  name: string;
25
- counterparty?: {
26
- name: string;
27
- };
31
+ counterparty?: DocumentParty | null;
28
32
  }[];
33
+ /** The company's words a new quotation starts with, the terms it offers and how long an offer holds (ADR-0170). */
34
+ defaults: DocumentDefaults | null;
35
+ /** The counterparty the buyer was taken from on this screen - shown in the picker. */
36
+ party: DocumentParty | null;
37
+ /** The totals of the lines as typed - reckoned as the quotation will keep them, shown before it is saved. */
38
+ amounts: Partial<DocumentValue>;
39
+ /** Whether this person may write invoices - an accepted quotation then makes one. */
40
+ invoiceWriter: boolean;
29
41
  private grist?;
30
42
  get editable(): boolean;
31
43
  get context(): {
@@ -38,6 +50,27 @@ export declare class QuotationEditor extends QuotationEditor_base {
38
50
  pageUpdated(changes: any, lifecycle: any): Promise<void>;
39
51
  render(): import("lit-html").TemplateResult<1>;
40
52
  private renderDecline;
53
+ /**
54
+ * A line made from a product, after the lines there are - a copy the person then changes like any line (ADR-0171) -
55
+ * and the totals reckoned with it.
56
+ */
57
+ private addProduct;
58
+ /** The discount of the whole quotation as typed, and the totals after it. */
59
+ private discount;
60
+ /** A change of the head; a new currency reckons the lines in its minor unit; the last day follows a moved issue day. */
61
+ private edit;
62
+ /** The opportunity it answers - found by typing; choosing one takes its counterparty as the buyer when none is written. */
63
+ private renderOpportunity;
64
+ /** A counterparty as the other side: the buyer of a sale, the supplier of a purchase. */
65
+ private takeParty;
66
+ /**
67
+ * What a new quotation starts with, so a person writes only what is new: the next number of the year, the last day an
68
+ * offer holds, the company's own words (ADR-0170), and what they leave blank of our side as the last sales quotation
69
+ * wrote it.
70
+ */
71
+ private prefill;
72
+ /** Days an offer holds - the company's, or thirty. */
73
+ private get validDays();
41
74
  private opportunityChoices;
42
75
  private readOpportunities;
43
76
  private configure;
@@ -46,9 +79,20 @@ export declare class QuotationEditor extends QuotationEditor_base {
46
79
  private run;
47
80
  private input;
48
81
  private save;
49
- /** Save what is on screen, then send it — its amounts are fixed from here on. */
82
+ /** Asked first - to whom and how much, fixed after - then save what is on screen and send it. */
50
83
  private send;
51
84
  private change;
52
- private print;
85
+ /**
86
+ * An invoice draft of an accepted quotation (ADR-0170 decision 11): its parties, currency, language, terms, account,
87
+ * notes, discount and lines as the quotation has them, issued today and due the company's days after, the quotation
88
+ * named as its source - then opened to be checked and issued. The counterparty is the one of the other party's name,
89
+ * as the invoice editor links it.
90
+ */
91
+ private makeInvoice;
92
+ /**
93
+ * The saved quotation as it goes out (ADR-0170 decision 10) - the server's page in the language chosen there, the
94
+ * quotation's own first, to download as PDF or print. What is on screen and not saved is not in it.
95
+ */
96
+ private preview;
53
97
  }
54
98
  export {};
@@ -1,6 +1,8 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
2
  import '@operato/data-grist/ox-grist.js';
3
3
  import '@operato/list-state/ox-page-state.js';
4
+ import '@operato/input/ox-select.js';
5
+ import '@operato/popup/ox-popup-list.js';
4
6
  import gql from 'graphql-tag';
5
7
  import { css, html, nothing } from 'lit';
6
8
  import { customElement, query, state } from 'lit/decorators.js';
@@ -8,16 +10,21 @@ import { DataGrist } from '@operato/data-grist';
8
10
  import { client } from '@operato/graphql';
9
11
  import { i18next, localize } from '@operato/i18n';
10
12
  import { notify } from '@operato/layout';
13
+ import { OxPrompt } from '@operato/popup/ox-prompt.js';
11
14
  import { navigate, PageView } from '@operato/shell';
12
15
  import { ButtonStyles, CommonGristStyles, DocumentPageStyles, FormFieldStyles, ScrollbarStyles } from '@operato/styles';
13
- import { documentHtml, DocumentFormStyles, documentLinesConfig, linesOf, printDocument, renderDocumentHead, renderDocumentTotals } from '@things-factory/invoice/dist-client';
16
+ 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';
18
+ import '@things-factory/invoice/dist-client/document/document-party.js';
14
19
  import { quotationSection } from '../quotation-attach';
20
+ import '../sales-product-pick';
21
+ import { lineOfProduct } from '../sales-product-pick';
15
22
  import { quotationStatusWord } from '../quotation-words';
16
23
  const FIELDS = `
17
24
  id number version status validUntil outcomeReason opportunityId opportunity { id name } direction currency language supplierName supplierAddress supplierRegistrationNumber
18
25
  buyerName buyerAddress buyerRegistrationNumber issueDate purchaseOrder paymentTerms bankDetails notes sourceReference
19
- supplyAmount taxAmount totalAmount taxRounding currencyDigits
20
- items { position description quantity unitPrice discount taxKind taxRate supplyAmount }
26
+ discountAmount lineSupplyAmount supplyAmount taxAmount totalAmount taxRounding currencyDigits
27
+ items { position description quantity unit unitPrice discount taxKind taxRate supplyAmount }
21
28
  taxGroups { taxKind taxRate supplyAmount taxAmount }
22
29
  `;
23
30
  const HEAD = [
@@ -38,9 +45,35 @@ const HEAD = [
38
45
  'notes',
39
46
  'sourceReference',
40
47
  'validUntil',
41
- 'opportunityId'
48
+ 'opportunityId',
49
+ 'discountAmount'
42
50
  ];
43
51
  const today = () => new Date().toLocaleDateString('en-CA');
52
+ /** What an invoice made from a quotation takes of its head as it is. */
53
+ const INVOICE_HEAD = [
54
+ 'direction',
55
+ 'currency',
56
+ 'language',
57
+ 'supplierName',
58
+ 'supplierAddress',
59
+ 'supplierRegistrationNumber',
60
+ 'buyerName',
61
+ 'buyerAddress',
62
+ 'buyerRegistrationNumber',
63
+ 'paymentTerms',
64
+ 'bankDetails',
65
+ 'notes'
66
+ ];
67
+ /** How long an offer holds unless the person writes another last day. */
68
+ const VALID_DAYS = 30;
69
+ /** Days an invoice made from a quotation is due unless the company says otherwise - the invoice editor's. */
70
+ const PAYMENT_DAYS = 30;
71
+ /** The next quotation number of a year after those given - 「Q2026-001」, 「Q2026-002」 … */
72
+ export function nextQuotationNumber(numbers, year) {
73
+ const prefix = `Q${year}-`;
74
+ const last = Math.max(0, ...numbers.filter(one => one?.startsWith(prefix)).map(one => Number(one.slice(prefix.length)) || 0));
75
+ return `${prefix}${String(last + 1).padStart(3, '0')}`;
76
+ }
44
77
  /**
45
78
  * 견적 편집 — the same head, lines and totals as an invoice, valid until a day.
46
79
  *
@@ -58,6 +91,14 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
58
91
  this.reason = '';
59
92
  /** The open opportunities a draft may answer, and the one it answers when that one is closed. */
60
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;
98
+ /** The totals of the lines as typed - reckoned as the quotation will keep them, shown before it is saved. */
99
+ this.amounts = {};
100
+ /** Whether this person may write invoices - an accepted quotation then makes one. */
101
+ this.invoiceWriter = false;
61
102
  }
62
103
  static { this.styles = [
63
104
  FormFieldStyles,
@@ -70,6 +111,16 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
70
111
  ox-grist {
71
112
  height: calc(var(--form-element-height-medium) * 10);
72
113
  }
114
+ /* 상품에서 추가 sits over the lines, as wide as a head field. */
115
+ .pick {
116
+ display: flex;
117
+ flex-direction: column;
118
+ gap: var(--spacing-small);
119
+ max-width: 480px;
120
+ margin-bottom: var(--spacing-medium);
121
+ font-size: var(--ui-t-micro);
122
+ color: var(--ui-muted);
123
+ }
73
124
  `
74
125
  ]; }
75
126
  get editable() {
@@ -88,10 +139,12 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
88
139
  else
89
140
  actions.push({ title: i18next.t('button.quotation-accept'), emphasis: { raised: true }, action: () => this.change('acceptQuotation') });
90
141
  }
142
+ if (status === 'ACCEPTED' && this.invoiceWriter)
143
+ actions.push({ title: i18next.t('button.quotation-make-invoice'), icon: 'receipt_long', emphasis: { raised: true }, action: () => this.makeInvoice() });
91
144
  if (['SENT', 'REJECTED', 'EXPIRED'].includes(status))
92
145
  actions.push({ title: i18next.t('button.quotation-revise'), icon: 'edit_document', action: () => this.change('reviseQuotation') });
93
146
  if (this.quotation)
94
- actions.push({ title: i18next.t('button.quotation-print'), icon: 'print', action: () => this.print() });
147
+ actions.push({ title: i18next.t('button.invoice-preview'), icon: 'preview', action: () => this.preview() });
95
148
  return {
96
149
  title: this.quotation ? `${i18next.t('title.quotation')} ${this.quotation.number} · v${this.quotation.version}` : i18next.t('title.quotation-new'),
97
150
  ...(this.quotation ? { description: quotationStatusWord(status) } : {}),
@@ -107,11 +160,20 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
107
160
  const id = lifecycle?.params?.id;
108
161
  if (!this.opportunities.length)
109
162
  await this.readOpportunities();
163
+ if (!this.defaults)
164
+ this.defaults = await documentDefaults();
165
+ hasPrivilege({ category: 'invoice', privilege: 'mutation', domainOwnerGranted: true }).then(writer => {
166
+ this.invoiceWriter = !!writer;
167
+ this.updateContext();
168
+ });
110
169
  if (!id) {
111
170
  this.quotation = null;
112
171
  /* Written from an opportunity, the draft answers it. */
113
172
  this.form = { ...blank(), opportunityId: lifecycle?.params?.opportunity ?? '' };
173
+ this.amounts = {};
174
+ this.party = null;
114
175
  this.configure();
176
+ await this.prefill();
115
177
  this.updateContext();
116
178
  return;
117
179
  }
@@ -125,28 +187,38 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
125
187
  <div class="page">
126
188
  <section class="page-section">
127
189
  <header class="page-section-header"><h3>${i18next.t('text.quotation-section-head')}</h3></header>
128
- ${renderDocumentHead(this.form, editable, patch => (this.form = { ...this.form, ...patch }), html `<label
190
+ ${renderDocumentHead(this.form, editable, patch => this.edit(patch), html `<label
129
191
  >${i18next.t('field.quotation-valid-until')}
130
192
  <input type="date" .value=${this.form.validUntil ?? ''} ?disabled=${!editable} @input=${(e) => (this.form = { ...this.form, validUntil: e.target.value })}
131
193
  /></label>
132
- <label
133
- >${i18next.t('field.quotation-opportunity')}
134
- <select .value=${this.form.opportunityId ?? ''} ?disabled=${!editable} @change=${(e) => (this.form = { ...this.form, opportunityId: e.target.value })}>
135
- <option value=""></option>
136
- ${this.opportunityChoices().map(one => html `<option value=${one.id} ?selected=${one.id === this.form.opportunityId}>${one.name}${one.counterparty?.name ? ` · ${one.counterparty.name}` : ''}</option>`)}
137
- </select>
138
- </label>`, 'field.quotation-number')}
194
+ ${this.renderOpportunity(editable)}`, 'field.quotation-number', {
195
+ terms: this.defaults?.paymentTerms,
196
+ /* In the group it fills - the buyer's of a sale, the supplier's of a purchase. */
197
+ party: html `<label
198
+ >${i18next.t('field.invoice-counterparty')}
199
+ <document-party-select .party=${this.party} ?disabled=${!editable} @party=${(e) => this.takeParty(e.detail)}></document-party-select>
200
+ </label>`
201
+ })}
139
202
  </section>
140
203
  <section class="page-section">
141
204
  <header class="page-section-header"><h3>${i18next.t('text.quotation-section-lines')}</h3></header>
142
- <ox-grist mode="GRID" .config=${this.gristConfig} .fetchHandler=${() => ({ total: this.form.items.length, records: this.form.items })}></ox-grist>
143
- </section>
144
- ${this.quotation
145
- ? html `<section class="page-section">
146
- <header class="page-section-header"><h3>${i18next.t('text.quotation-section-totals')}</h3></header>
147
- ${renderDocumentTotals(this.quotation)}
148
- </section>`
205
+ ${editable
206
+ ? html `<label class="pick"
207
+ >${i18next.t('field.sales-product-pick')}
208
+ <sales-product-pick .currency=${this.form.currency || 'KRW'} @product=${(e) => this.addProduct(e.detail)}></sales-product-pick>
209
+ </label>`
149
210
  : nothing}
211
+ <ox-grist
212
+ mode="GRID"
213
+ .config=${this.gristConfig}
214
+ .fetchHandler=${() => ({ total: this.form.items.length, records: this.form.items })}
215
+ @record-change=${(e) => (this.amounts = onLineChange(this.grist, e, this.form.currency, false, this.form.discountAmount))}
216
+ ></ox-grist>
217
+ </section>
218
+ <section class="page-section">
219
+ <header class="page-section-header"><h3>${i18next.t('text.quotation-section-totals')}</h3></header>
220
+ ${renderDocumentTotals({ ...this.amounts, currency: this.form.currency }, { value: this.form.discountAmount, editable, change: value => this.discount(value) })}
221
+ </section>
150
222
  ${this.quotation?.status === 'SENT' ? this.renderDecline() : nothing}
151
223
  ${this.quotation?.outcomeReason
152
224
  ? html `<section class="page-section">
@@ -179,6 +251,109 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
179
251
  </div>
180
252
  </section>`;
181
253
  }
254
+ /**
255
+ * A line made from a product, after the lines there are - a copy the person then changes like any line (ADR-0171) -
256
+ * and the totals reckoned with it.
257
+ */
258
+ async addProduct(product) {
259
+ const grist = this.grist;
260
+ if (!grist || !product)
261
+ return;
262
+ grist.addRecord(lineOfProduct(product));
263
+ await grist.updateComplete;
264
+ this.amounts = reckonLines(grist, this.form.currency, false, this.form.discountAmount);
265
+ grist.refresh();
266
+ }
267
+ /** The discount of the whole quotation as typed, and the totals after it. */
268
+ discount(value) {
269
+ this.form = { ...this.form, discountAmount: value };
270
+ this.amounts = reckonLines(this.grist, this.form.currency, false, value);
271
+ }
272
+ /** A change of the head; a new currency reckons the lines in its minor unit; the last day follows a moved issue day. */
273
+ edit(patch) {
274
+ const currency = this.form.currency;
275
+ const following = patch.issueDate && this.form.validUntil === daysAfter(this.form.issueDate, this.validDays);
276
+ this.form = { ...this.form, ...patch, ...(following ? { validUntil: daysAfter(patch.issueDate, this.validDays) } : {}) };
277
+ if (patch.currency && patch.currency !== currency) {
278
+ this.configure();
279
+ this.amounts = reckonLines(this.grist, this.form.currency, false, this.form.discountAmount);
280
+ }
281
+ }
282
+ /** The opportunity it answers - found by typing; choosing one takes its counterparty as the buyer when none is written. */
283
+ renderOpportunity(editable) {
284
+ const choices = this.opportunityChoices();
285
+ const chosen = choices.find(one => one.id === this.form.opportunityId);
286
+ const label = (one) => `${one.name}${one.counterparty?.name ? ` · ${one.counterparty.name}` : ''}`;
287
+ return html `<label
288
+ >${i18next.t('field.quotation-opportunity')}
289
+ <ox-select
290
+ .value=${this.form.opportunityId ?? ''}
291
+ ?disabled=${!editable}
292
+ aria-label=${i18next.t('field.quotation-opportunity')}
293
+ @change=${(e) => {
294
+ const id = String(e.detail ?? '');
295
+ if (id === (this.form.opportunityId ?? ''))
296
+ return;
297
+ const opportunity = choices.find(one => one.id === id);
298
+ this.form = { ...this.form, opportunityId: id };
299
+ if (opportunity?.counterparty && !String(this.form.buyerName ?? '').trim())
300
+ this.takeParty(opportunity.counterparty);
301
+ }}
302
+ >
303
+ <span slot="label">${chosen ? label(chosen) : '—'}</span>
304
+ <ox-popup-list align-left nowrap with-search>
305
+ <div option value="">—</div>
306
+ ${choices.map(one => html `<div option value=${one.id}>${label(one)}</div>`)}
307
+ </ox-popup-list>
308
+ </ox-select>
309
+ </label>`;
310
+ }
311
+ /** A counterparty as the other side: the buyer of a sale, the supplier of a purchase. */
312
+ takeParty(party) {
313
+ this.party = party;
314
+ if (!party)
315
+ return;
316
+ const side = this.form.direction === 'PURCHASE' ? 'supplier' : 'buyer';
317
+ this.form = { ...this.form, [`${side}Name`]: party.name, [`${side}RegistrationNumber`]: party.businessNumber ?? this.form[`${side}RegistrationNumber`] ?? '' };
318
+ }
319
+ /**
320
+ * What a new quotation starts with, so a person writes only what is new: the next number of the year, the last day an
321
+ * offer holds, the company's own words (ADR-0170), and what they leave blank of our side as the last sales quotation
322
+ * wrote it.
323
+ */
324
+ async prefill() {
325
+ try {
326
+ const year = Number(this.form.issueDate.slice(0, 4));
327
+ const response = await client.query({
328
+ query: gql `
329
+ query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
330
+ numbered: quotations(filters: $filters, pagination: { page: 1, limit: 1000 }) { items { number } }
331
+ last: quotations(pagination: $pagination, sortings: $sortings) { items { direction ${OUR_SIDE_FIELDS.join(' ')} } }
332
+ }
333
+ `,
334
+ variables: { filters: [{ name: 'number', operator: 'like', value: `Q${year}-%` }], pagination: { page: 1, limit: 1 }, sortings: [{ name: 'createdAt', desc: true }] },
335
+ fetchPolicy: 'no-cache'
336
+ });
337
+ const last = (response.data?.last?.items ?? []).find((one) => one.direction === 'SALES');
338
+ this.form = { ...this.form, ...headFromDefaults(this.form, this.defaults) };
339
+ this.form = {
340
+ ...this.form,
341
+ ...oursFromLast(this.form, last),
342
+ number: this.form.number || nextQuotationNumber((response.data?.numbered?.items ?? []).map((one) => one.number), year),
343
+ validUntil: this.form.validUntil || daysAfter(this.form.issueDate, this.validDays)
344
+ };
345
+ }
346
+ catch {
347
+ /* Nothing to start from - the person writes it, as before. */
348
+ }
349
+ const opportunity = this.opportunities.find(one => one.id === this.form.opportunityId);
350
+ if (opportunity?.counterparty && !String(this.form.buyerName ?? '').trim())
351
+ this.takeParty(opportunity.counterparty);
352
+ }
353
+ /** Days an offer holds - the company's, or thirty. */
354
+ get validDays() {
355
+ return this.defaults?.validDays || VALID_DAYS;
356
+ }
182
357
  opportunityChoices() {
183
358
  const answered = this.quotation?.opportunity;
184
359
  return answered && !this.opportunities.some(one => one.id === answered.id) ? [answered, ...this.opportunities] : this.opportunities;
@@ -189,7 +364,7 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
189
364
  query: gql `
190
365
  query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
191
366
  opportunities(filters: $filters, pagination: $pagination, sortings: $sortings) {
192
- items { id name counterparty { name } }
367
+ items { id name counterparty { id name businessNumber } }
193
368
  }
194
369
  }
195
370
  `,
@@ -231,6 +406,7 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
231
406
  show(quotation) {
232
407
  this.quotation = quotation;
233
408
  this.form = { ...quotation, items: quotation.items.map((item) => ({ ...item })) };
409
+ this.amounts = quotation;
234
410
  this.reason = '';
235
411
  this.configure();
236
412
  this.grist?.fetch();
@@ -253,6 +429,7 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
253
429
  input() {
254
430
  const input = Object.fromEntries(HEAD.map(key => [key, this.form[key] === '' ? null : this.form[key] ?? null]));
255
431
  input.items = linesOf(this.grist).map(({ receivedTaxAmount, ...line }) => line);
432
+ input.discountAmount = input.discountAmount === null ? 0 : Number(input.discountAmount);
256
433
  return input;
257
434
  }
258
435
  async save() {
@@ -276,8 +453,21 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
276
453
  navigate(`quotation-editor?id=${saved.id}`);
277
454
  return saved;
278
455
  }
279
- /** Save what is on screen, then send it — its amounts are fixed from here on. */
456
+ /** Asked first - to whom and how much, fixed after - then save what is on screen and send it. */
280
457
  async send() {
458
+ const purchase = this.form.direction === 'PURCHASE';
459
+ const words = {
460
+ party: (purchase ? this.form.supplierName : this.form.buyerName) || '—',
461
+ total: documentAmount(this.amounts.totalAmount ?? 0, this.form.currency || 'KRW')
462
+ };
463
+ const confirmed = await OxPrompt.open({
464
+ title: i18next.t('button.quotation-send'),
465
+ text: purchase ? i18next.t('text.quotation-send-confirm-purchase', words) : i18next.t('text.quotation-send-confirm', words),
466
+ confirmButton: { text: i18next.t('button.quotation-send') },
467
+ cancelButton: { text: i18next.t('button.cancel') }
468
+ });
469
+ if (!confirmed)
470
+ return;
281
471
  const saved = await this.save();
282
472
  if (saved)
283
473
  await this.change('sendQuotation');
@@ -301,17 +491,81 @@ let QuotationEditor = class QuotationEditor extends localize(i18next)(PageView)
301
491
  this.show(changed);
302
492
  this.updateContext();
303
493
  }
304
- print() {
494
+ /**
495
+ * An invoice draft of an accepted quotation (ADR-0170 decision 11): its parties, currency, language, terms, account,
496
+ * notes, discount and lines as the quotation has them, issued today and due the company's days after, the quotation
497
+ * named as its source - then opened to be checked and issued. The counterparty is the one of the other party's name,
498
+ * as the invoice editor links it.
499
+ */
500
+ async makeInvoice() {
305
501
  const quotation = this.quotation;
306
- const lng = quotation.language === 'en' ? 'en' : 'ko';
307
- printDocument(documentHtml(quotation, {
308
- title: i18next.t('title.quotation-print', { lng }),
309
- numberLabel: 'field.quotation-number',
310
- terms: [
311
- { label: i18next.t('field.quotation-version', { lng }), value: `v${quotation.version}` },
312
- { label: i18next.t('field.quotation-valid-until', { lng }), value: quotation.validUntil }
313
- ]
314
- }));
502
+ const created = await this.run(async () => {
503
+ const last = await client.query({
504
+ query: gql `
505
+ query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
506
+ invoices(filters: $filters, pagination: $pagination, sortings: $sortings) { items { number } }
507
+ }
508
+ `,
509
+ variables: { filters: [{ name: 'direction', operator: 'eq', value: 'SALES' }], pagination: { page: 1, limit: 1 }, sortings: [{ name: 'createdAt', desc: true }] },
510
+ fetchPolicy: 'no-cache'
511
+ });
512
+ const issueDate = today();
513
+ const party = await partyOfName(quotation.direction === 'PURCHASE' ? quotation.supplierName : quotation.buyerName);
514
+ const response = await client.mutate({
515
+ mutation: gql `
516
+ mutation ($invoice: NewInvoice!) {
517
+ createInvoice(invoice: $invoice) { id }
518
+ }
519
+ `,
520
+ variables: {
521
+ invoice: {
522
+ number: nextInvoiceNumber(last.data?.invoices?.items?.[0]?.number, Number(issueDate.slice(0, 4))),
523
+ counterpartyId: party?.id ?? null,
524
+ ...Object.fromEntries(INVOICE_HEAD.map(key => [key, quotation[key] ?? null])),
525
+ issueDate,
526
+ dueDate: daysAfter(issueDate, this.defaults?.paymentDays ?? PAYMENT_DAYS),
527
+ sourceReference: i18next.t('text.quotation-invoice-source', { number: quotation.number, version: quotation.version }),
528
+ taxBasis: 'CALCULATED',
529
+ discountAmount: Number(quotation.discountAmount ?? 0),
530
+ items: quotation.items.map(({ description, quantity, unit, unitPrice, discount, taxKind, taxRate }) => ({
531
+ description,
532
+ quantity,
533
+ unit: unit ?? null,
534
+ unitPrice,
535
+ discount,
536
+ taxKind,
537
+ taxRate
538
+ }))
539
+ }
540
+ }
541
+ });
542
+ if (response.errors?.length)
543
+ throw response;
544
+ return response.data.createInvoice;
545
+ });
546
+ if (created)
547
+ navigate(`invoice-editor?id=${created.id}`);
548
+ }
549
+ /**
550
+ * The saved quotation as it goes out (ADR-0170 decision 10) - the server's page in the language chosen there, the
551
+ * quotation's own first, to download as PDF or print. What is on screen and not saved is not in it.
552
+ */
553
+ preview() {
554
+ const { id, number, version, language } = this.quotation;
555
+ openDocumentPreview(async (format, lng) => {
556
+ const response = await client.query({
557
+ query: gql `
558
+ query ($id: String!, $format: DocumentFileFormat!, $language: String) {
559
+ quotationDocumentFile(id: $id, format: $format, language: $language) { fileName mimeType content }
560
+ }
561
+ `,
562
+ variables: { id, format, language: lng },
563
+ fetchPolicy: 'no-cache'
564
+ });
565
+ if (!response.data?.quotationDocumentFile)
566
+ throw response.errors?.[0] ?? new Error(i18next.t('text.quotation-not-found'));
567
+ return response.data.quotationDocumentFile;
568
+ }, language, `${i18next.t('title.invoice-preview')} · ${number} · v${version}`);
315
569
  }
316
570
  };
317
571
  __decorate([
@@ -346,6 +600,22 @@ __decorate([
346
600
  state(),
347
601
  __metadata("design:type", Array)
348
602
  ], QuotationEditor.prototype, "opportunities", void 0);
603
+ __decorate([
604
+ state(),
605
+ __metadata("design:type", Object)
606
+ ], QuotationEditor.prototype, "defaults", void 0);
607
+ __decorate([
608
+ state(),
609
+ __metadata("design:type", Object)
610
+ ], QuotationEditor.prototype, "party", void 0);
611
+ __decorate([
612
+ state(),
613
+ __metadata("design:type", Object)
614
+ ], QuotationEditor.prototype, "amounts", void 0);
615
+ __decorate([
616
+ state(),
617
+ __metadata("design:type", Object)
618
+ ], QuotationEditor.prototype, "invoiceWriter", void 0);
349
619
  __decorate([
350
620
  query('ox-grist'),
351
621
  __metadata("design:type", DataGrist)
@@ -355,6 +625,6 @@ QuotationEditor = __decorate([
355
625
  ], QuotationEditor);
356
626
  export { QuotationEditor };
357
627
  function blank() {
358
- return { number: '', direction: 'SALES', currency: 'KRW', language: 'ko', supplierName: '', buyerName: '', issueDate: today(), validUntil: '', items: [] };
628
+ return { number: '', direction: 'SALES', currency: 'KRW', language: 'ko', supplierName: '', buyerName: '', issueDate: today(), validUntil: '', discountAmount: 0, items: [] };
359
629
  }
360
630
  //# sourceMappingURL=quotation-editor.js.map