@micha.bigler/ui-core-micha 4.6.0 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/billing/WithdrawalDialog.js +1 -1
- package/dist/i18n/billingTranslations.js +1 -0
- package/dist/i18n/receiptsTranslations.js +38 -0
- package/dist/i18n/uiCoreTranslations.js +2 -1
- package/dist/index.js +9 -0
- package/dist/receipts/ReceiptCaptureCard.js +119 -0
- package/dist/receipts/ReceiptConsentSwitch.js +18 -0
- package/dist/receipts/ReceiptPicker.js +19 -0
- package/dist/receipts/ReceiptReviewDialog.js +39 -0
- package/dist/receipts/api.js +18 -0
- package/dist/receipts/files.js +39 -0
- package/dist/receipts/format.js +42 -0
- package/dist/receipts/useReceiptConsent.js +52 -0
- package/dist/receipts/wire.js +5 -0
- package/dist/theme/kitSxRegistry.js +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.1 - UCM-BILL-4
|
|
4
|
+
|
|
5
|
+
The withdrawal dialog no longer says the subscription will not renew for a token package: for `kind: token_package` it says that the unused tokens of the purchase are removed (`Billing.WITHDRAWAL_EFFECT_TOKENS`, de, de_informal, en, fr, sw).
|
|
6
|
+
|
|
7
|
+
## 4.7.0 - UCM-RCPT-1
|
|
8
|
+
|
|
9
|
+
Shared receipt capture and review, against the `django-core-micha` receipts module (DCM-RCPT-1; `consent/` and `read/`
|
|
10
|
+
under `apiBase`, default `/api/receipts/`). `ReceiptCaptureCard` offers the camera (`capture="environment"`) and a file
|
|
11
|
+
button for JPG, PNG, HEIC and PDF, scales large JPEG and PNG files on the device (HEIC and PDF pass through; at most
|
|
12
|
+
10 MB), shows the progress while the receipt is read and the recognised boxes on the image, and the edge states (no
|
|
13
|
+
consent, nothing recognised, recognition unavailable, file not readable). Without consent it shows the consent switch
|
|
14
|
+
and, when the host gives `onUploadOnly`, an upload-only path. `ReceiptReviewDialog` (a sheet on phones) lists each
|
|
15
|
+
recognised value next to the form's current one with "Apply", "Only empty fields" and "Apply all"; the host passes its
|
|
16
|
+
form values, field labels and extra fields and receives only the chosen values (`onApply(values, { mode })`).
|
|
17
|
+
`ReceiptPicker` selects among several receipts in one photo (low confidence starts unselected). `ReceiptConsentSwitch`
|
|
18
|
+
reads and writes the user's own consent and works on a profile page too; the text names the `provider` the host passes.
|
|
19
|
+
Also `useReceiptConsent`, `createReceiptsApi` / `defaultReceiptsApi` (every function overridable), `receiptFileProblem`,
|
|
20
|
+
`prepareReceiptFile` and `receiptsTranslations` (de/en/fr/sw, part of `uiCoreTranslations`). The kit stores nothing; the
|
|
21
|
+
app keeps its form and its files.
|
|
22
|
+
|
|
3
23
|
## 4.6.0 - UCM-CHAT-6
|
|
4
24
|
|
|
5
25
|
Plain model choice, summary and cost on the shared chat surface, against `django-core-micha` 3.5.0 (DCM-CHAT-13).
|
|
@@ -90,6 +90,6 @@ export function WithdrawalDialog({ open, purchase, api, onClose, onWithdrawn })
|
|
|
90
90
|
onClose === null || onClose === void 0 ? void 0 : onClose(); };
|
|
91
91
|
const amount = formatPrice(preview === null || preview === void 0 ? void 0 : preview.refund_cents, i18n.language, preview === null || preview === void 0 ? void 0 : preview.currency);
|
|
92
92
|
const none = !(Number(preview === null || preview === void 0 ? void 0 : preview.refund_cents) > 0);
|
|
93
|
-
return _jsxs(Dialog, { open: open, onClose: close, disableEscapeKeyDown: state === 'submitting', fullWidth: true, maxWidth: "sm", "aria-labelledby": titleId, children: [_jsx(DialogTitle, { id: titleId, children: t(state === 'success' ? 'Billing.WITHDRAWAL_DONE' : 'Billing.WITHDRAWAL_TITLE') }), _jsxs(DialogContent, { children: [state === 'loading' && _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", role: "status", children: [_jsx(CircularProgress, { size: 20 }), _jsx(Typography, { children: t('Billing.LOADING') })] }), state === 'preview_error' && _jsx(Alert, { severity: "error", sx: WITHDRAWAL_DIALOG_ALERT_SX, children: t('Billing.WITHDRAWAL_PREVIEW_ERROR') }), (state === 'not_possible') && _jsx(Alert, { severity: "info", children: t('Billing.WITHDRAWAL_NOT_POSSIBLE') }), (state === 'ready' || state === 'submitting' || state === 'confirm_error') && _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { children: t('Billing.WITHDRAWAL_EFFECT') }), _jsx(Typography, { children: none ? t('Billing.WITHDRAWAL_REFUND_NONE') : t('Billing.WITHDRAWAL_REFUND', { amount }) }), _jsx(Typography, { children: t('Billing.WITHDRAWAL_CALCULATION', { explanation: describeCalculation(Object.assign(Object.assign({}, preview), { kind: purchase === null || purchase === void 0 ? void 0 : purchase.kind }), t, i18n.language) }) }), state === 'confirm_error' && _jsx(Alert, { severity: "error", sx: WITHDRAWAL_DIALOG_ALERT_SX, children: t('Billing.WITHDRAWAL_ERROR') }), state === 'submitting' && _jsxs(Typography, { role: "status", children: [_jsx(CircularProgress, { size: 16 }), " ", t('Billing.LOADING')] })] }), state === 'success' && _jsx(Typography, { children: t('Billing.WITHDRAWAL_DONE_DETAIL', { date: formatDateTime(result === null || result === void 0 ? void 0 : result.withdrawn_at, i18n.language) }) })] }), _jsx(DialogActions, { children: state === 'ready' || state === 'confirm_error' ? _jsxs(_Fragment, { children: [_jsx(Button, { onClick: close, disabled: state === 'submitting', children: t('Billing.WITHDRAWAL_CANCEL') }), _jsx(Button, { variant: "contained", onClick: confirm, disabled: state === 'submitting', children: t('Billing.WITHDRAWAL_CONFIRM') })] }) : state === 'preview_error' ? _jsxs(_Fragment, { children: [_jsx(Button, { onClick: close, children: t('Billing.WITHDRAWAL_CLOSE') }), _jsx(Button, { onClick: () => setAttempt((n) => n + 1), children: t('Billing.RETRY') })] }) : (state === 'success' || state === 'not_possible') && _jsx(Button, { onClick: close, children: t('Billing.WITHDRAWAL_CLOSE') }) })] });
|
|
93
|
+
return _jsxs(Dialog, { open: open, onClose: close, disableEscapeKeyDown: state === 'submitting', fullWidth: true, maxWidth: "sm", "aria-labelledby": titleId, children: [_jsx(DialogTitle, { id: titleId, children: t(state === 'success' ? 'Billing.WITHDRAWAL_DONE' : 'Billing.WITHDRAWAL_TITLE') }), _jsxs(DialogContent, { children: [state === 'loading' && _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", role: "status", children: [_jsx(CircularProgress, { size: 20 }), _jsx(Typography, { children: t('Billing.LOADING') })] }), state === 'preview_error' && _jsx(Alert, { severity: "error", sx: WITHDRAWAL_DIALOG_ALERT_SX, children: t('Billing.WITHDRAWAL_PREVIEW_ERROR') }), (state === 'not_possible') && _jsx(Alert, { severity: "info", children: t('Billing.WITHDRAWAL_NOT_POSSIBLE') }), (state === 'ready' || state === 'submitting' || state === 'confirm_error') && _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { children: t((purchase === null || purchase === void 0 ? void 0 : purchase.kind) === 'token_package' ? 'Billing.WITHDRAWAL_EFFECT_TOKENS' : 'Billing.WITHDRAWAL_EFFECT') }), _jsx(Typography, { children: none ? t('Billing.WITHDRAWAL_REFUND_NONE') : t('Billing.WITHDRAWAL_REFUND', { amount }) }), _jsx(Typography, { children: t('Billing.WITHDRAWAL_CALCULATION', { explanation: describeCalculation(Object.assign(Object.assign({}, preview), { kind: purchase === null || purchase === void 0 ? void 0 : purchase.kind }), t, i18n.language) }) }), state === 'confirm_error' && _jsx(Alert, { severity: "error", sx: WITHDRAWAL_DIALOG_ALERT_SX, children: t('Billing.WITHDRAWAL_ERROR') }), state === 'submitting' && _jsxs(Typography, { role: "status", children: [_jsx(CircularProgress, { size: 16 }), " ", t('Billing.LOADING')] })] }), state === 'success' && _jsx(Typography, { children: t('Billing.WITHDRAWAL_DONE_DETAIL', { date: formatDateTime(result === null || result === void 0 ? void 0 : result.withdrawn_at, i18n.language) }) })] }), _jsx(DialogActions, { children: state === 'ready' || state === 'confirm_error' ? _jsxs(_Fragment, { children: [_jsx(Button, { onClick: close, disabled: state === 'submitting', children: t('Billing.WITHDRAWAL_CANCEL') }), _jsx(Button, { variant: "contained", onClick: confirm, disabled: state === 'submitting', children: t('Billing.WITHDRAWAL_CONFIRM') })] }) : state === 'preview_error' ? _jsxs(_Fragment, { children: [_jsx(Button, { onClick: close, children: t('Billing.WITHDRAWAL_CLOSE') }), _jsx(Button, { onClick: () => setAttempt((n) => n + 1), children: t('Billing.RETRY') })] }) : (state === 'success' || state === 'not_possible') && _jsx(Button, { onClick: close, children: t('Billing.WITHDRAWAL_CLOSE') }) })] });
|
|
94
94
|
}
|
|
95
95
|
export default WithdrawalDialog;
|
|
@@ -44,6 +44,7 @@ export const billingTranslations = {
|
|
|
44
44
|
'Billing.WITHDRAWAL_UNTIL': { de: 'bis {{date}}', de_informal: 'bis {{date}}', en: 'until {{date}}', fr: 'jusqu’au {{date}}', sw: 'hadi {{date}}' },
|
|
45
45
|
'Billing.WITHDRAWAL_TITLE': { de: 'Vertrag widerrufen?', de_informal: 'Vertrag widerrufen?', en: 'Withdraw this contract?', fr: 'Révoquer ce contrat ?', sw: 'Uondoe mkataba huu?' },
|
|
46
46
|
'Billing.WITHDRAWAL_EFFECT': { de: 'Mit dem Widerruf endet der Zugang aus diesem Kauf sofort, und das Abo verlängert sich nicht.', de_informal: 'Mit dem Widerruf endet der Zugang aus diesem Kauf sofort, und das Abo verlängert sich nicht.', en: 'With the withdrawal, access from this purchase ends immediately and the subscription will not renew.', fr: 'La révocation met immédiatement fin à l’accès lié à cet achat et l’abonnement ne sera pas renouvelé.', sw: 'Ukiondoa, ufikiaji wa ununuzi huu utaisha mara moja na usajili hautasasishwa.' },
|
|
47
|
+
'Billing.WITHDRAWAL_EFFECT_TOKENS': { de: 'Mit dem Widerruf werden die noch nicht verbrauchten Token aus diesem Kauf entfernt.', de_informal: 'Mit dem Widerruf werden die noch nicht verbrauchten Token aus diesem Kauf entfernt.', en: 'Withdrawing removes the unused tokens from this purchase.', fr: 'La révocation supprime les jetons non utilisés de cet achat.', sw: 'Kuondoa kunaondoa tokeni ambazo hazijatumika za ununuzi huu.' },
|
|
47
48
|
'Billing.WITHDRAWAL_REFUND': { de: 'Sie erhalten {{amount}} zurück.', de_informal: 'Du erhältst {{amount}} zurück.', en: 'You will receive {{amount}} back.', fr: 'Vous recevrez {{amount}} en remboursement.', sw: 'Utarejeshewa {{amount}}.' },
|
|
48
49
|
'Billing.WITHDRAWAL_REFUND_NONE': { de: 'Sie erhalten keine Rückzahlung.', de_informal: 'Du erhältst keine Rückzahlung.', en: 'You will receive no refund.', fr: 'Vous ne recevrez aucun remboursement.', sw: 'Hutapokea marejesho ya pesa.' },
|
|
49
50
|
'Billing.WITHDRAWAL_CALCULATION': { de: 'Berechnung: {{explanation}}', de_informal: 'Berechnung: {{explanation}}', en: 'Calculation: {{explanation}}', fr: 'Calcul : {{explanation}}', sw: 'Hesabu: {{explanation}}' },
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const receiptsTranslations = {
|
|
2
|
+
'Receipts.CAMERA': { de: 'Beleg fotografieren', en: 'Take a receipt photo', fr: 'Photographier un reçu', sw: 'Piga picha ya risiti' },
|
|
3
|
+
'Receipts.PICK_FILE': { de: 'Datei wählen', en: 'Choose a file', fr: 'Choisir un fichier', sw: 'Chagua faili' },
|
|
4
|
+
'Receipts.ACCEPTED_TYPES': { de: 'JPG, PNG, HEIC oder PDF', en: 'JPG, PNG, HEIC or PDF', fr: 'JPG, PNG, HEIC ou PDF', sw: 'JPG, PNG, HEIC au PDF' },
|
|
5
|
+
'Receipts.READING': { de: 'Beleg wird gelesen …', en: 'Reading receipt …', fr: 'Lecture du reçu …', sw: 'Risiti inasomwa …' },
|
|
6
|
+
'Receipts.ANOTHER': { de: 'Anderer Beleg', en: 'Another receipt', fr: 'Un autre reçu', sw: 'Risiti nyingine' },
|
|
7
|
+
'Receipts.REVIEW_TITLE': { de: 'Erkannte Werte', en: 'Recognised values', fr: 'Valeurs reconnues', sw: 'Thamani zilizotambuliwa' },
|
|
8
|
+
'Receipts.SAME_AS_FORM': { de: 'wie im Formular', en: 'same as in the form', fr: 'identique au formulaire', sw: 'sawa na iliyo kwenye fomu' },
|
|
9
|
+
'Receipts.APPLY': { de: 'Übernehmen', en: 'Apply', fr: 'Appliquer', sw: 'Tumia' },
|
|
10
|
+
'Receipts.ONLY_EMPTY': { de: 'Nur leere Felder', en: 'Empty fields only', fr: 'Champs vides uniquement', sw: 'Sehemu tupu pekee' },
|
|
11
|
+
'Receipts.APPLY_ALL': { de: 'Alle übernehmen', en: 'Apply all', fr: 'Tout appliquer', sw: 'Tumia zote' },
|
|
12
|
+
'Receipts.CLOSE': { de: 'Schliessen', en: 'Close', fr: 'Fermer', sw: 'Funga' },
|
|
13
|
+
'Receipts.CERTAIN': { de: 'sicher', en: 'confident', fr: 'sûr', sw: 'uhakika' },
|
|
14
|
+
'Receipts.UNCERTAIN': { de: 'unsicher', en: 'uncertain', fr: 'incertain', sw: 'si ya uhakika' },
|
|
15
|
+
'Receipts.RECOGNISED_COUNT': { de: '{{n}} Belege erkannt', en: '{{n}} receipts recognised', fr: '{{n}} reçus reconnus', sw: 'Risiti {{n}} zimetambuliwa' },
|
|
16
|
+
'Receipts.APPLY_COUNT': { de: '{{n}} übernehmen', en: 'Apply {{n}}', fr: 'Appliquer {{n}}', sw: 'Tumia {{n}}' },
|
|
17
|
+
'Receipts.UNREADABLE': { de: 'Unleserlich', en: 'Unreadable', fr: 'Illisible', sw: 'Haijasomeka' },
|
|
18
|
+
'Receipts.AMOUNT_UNKNOWN': { de: 'Betrag nicht erkannt', en: 'Amount not recognised', fr: 'Montant non reconnu', sw: 'Kiasi hakikutambuliwa' },
|
|
19
|
+
'Receipts.CONSENT': { de: 'Belege dürfen für die automatische Erkennung an {{provider}} übermittelt werden. Jederzeit widerrufbar.', en: 'Receipts may be sent to {{provider}} for automatic recognition. You can withdraw consent at any time.', fr: 'Les reçus peuvent être transmis à {{provider}} pour reconnaissance automatique. Vous pouvez retirer votre consentement à tout moment.', sw: 'Risiti zinaweza kutumwa kwa {{provider}} ili zitambuliwe kiotomatiki. Unaweza kuondoa ruhusa wakati wowote.' },
|
|
20
|
+
'Receipts.UPLOAD_ONLY': { de: 'Nur hochladen', en: 'Upload only', fr: 'Téléverser uniquement', sw: 'Pakia pekee' },
|
|
21
|
+
'Receipts.NOTHING_RECOGNISED': { de: 'Auf diesem Beleg wurden keine Angaben erkannt. Bitte von Hand erfassen.', en: 'No details were recognised on this receipt. Please enter them manually.', fr: 'Aucune information n’a été reconnue sur ce reçu. Veuillez les saisir manuellement.', sw: 'Hakuna maelezo yaliyotambuliwa kwenye risiti hii. Tafadhali yaweke mwenyewe.' },
|
|
22
|
+
'Receipts.UNAVAILABLE': { de: 'Die Belegerkennung ist im Moment nicht verfügbar. Bitte von Hand erfassen.', en: 'Receipt recognition is unavailable right now. Please enter the details manually.', fr: 'La reconnaissance des reçus est momentanément indisponible. Veuillez saisir les informations manuellement.', sw: 'Utambuzi wa risiti haupatikani kwa sasa. Tafadhali weka maelezo mwenyewe.' },
|
|
23
|
+
'Receipts.FILE_PROBLEM': { de: 'Diese Datei kann nicht gelesen werden. Erlaubt sind JPG, PNG, HEIC und PDF bis 10 MB.', en: 'This file cannot be read. JPG, PNG, HEIC and PDF files up to 10 MB are allowed.', fr: 'Impossible de lire ce fichier. Les fichiers JPG, PNG, HEIC et PDF jusqu’à 10 Mo sont acceptés.', sw: 'Faili hii haiwezi kusomwa. JPG, PNG, HEIC na PDF hadi MB 10 zinaruhusiwa.' },
|
|
24
|
+
'Receipts.FIELD_merchant': { de: 'Händler', en: 'Merchant', fr: 'Commerçant', sw: 'Muuzaji' },
|
|
25
|
+
'Receipts.FIELD_date': { de: 'Datum', en: 'Date', fr: 'Date', sw: 'Tarehe' },
|
|
26
|
+
'Receipts.FIELD_total': { de: 'Betrag', en: 'Amount', fr: 'Montant', sw: 'Kiasi' },
|
|
27
|
+
'Receipts.FIELD_currency': { de: 'Währung', en: 'Currency', fr: 'Devise', sw: 'Sarafu' },
|
|
28
|
+
'Receipts.FIELD_payment_method': { de: 'Zahlungsart', en: 'Payment method', fr: 'Mode de paiement', sw: 'Njia ya malipo' },
|
|
29
|
+
'Receipts.FIELD_account': { de: 'Konto', en: 'Account', fr: 'Compte', sw: 'Akaunti' },
|
|
30
|
+
'Receipts.FIELD_items': { de: 'Positionen', en: 'Line items', fr: 'Articles', sw: 'Vipengee' },
|
|
31
|
+
'Receipts.CARD_LABEL': { de: 'Beleg', en: 'Receipt', fr: 'Reçu', sw: 'Risiti' },
|
|
32
|
+
'Receipts.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler', sw: 'Ghairi' },
|
|
33
|
+
'Receipts.PAYMENT_cash': { de: 'Bar', en: 'Cash', fr: 'Espèces', sw: 'Taslimu' },
|
|
34
|
+
'Receipts.PAYMENT_card': { de: 'Karte', en: 'Card', fr: 'Carte', sw: 'Kadi' },
|
|
35
|
+
'Receipts.PAYMENT_other': { de: 'Andere', en: 'Other', fr: 'Autre', sw: 'Nyingine' },
|
|
36
|
+
'Receipts.CONSENT_ERROR': { de: 'Die Einwilligung konnte nicht geladen werden.', en: 'Consent could not be loaded.', fr: 'Impossible de charger le consentement.', sw: 'Ruhusa haikuweza kupakiwa.' },
|
|
37
|
+
'Receipts.RETRY': { de: 'Erneut versuchen', en: 'Try again', fr: 'Réessayer', sw: 'Jaribu tena' },
|
|
38
|
+
};
|
|
@@ -15,10 +15,11 @@ import { chartsTranslations } from './chartsTranslations';
|
|
|
15
15
|
import { chatTranslations } from './chatTranslations';
|
|
16
16
|
import { messagingTranslations } from './messagingTranslations';
|
|
17
17
|
import { notificationsTranslations } from './notificationsTranslations';
|
|
18
|
+
import { receiptsTranslations } from './receiptsTranslations';
|
|
18
19
|
import { onboardingTranslations } from './onboardingTranslations';
|
|
19
20
|
import { sectionNavTranslations } from './sectionNavTranslations';
|
|
20
21
|
import { userMenuTranslations } from './userMenuTranslations';
|
|
21
|
-
export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), billingTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
|
|
22
|
+
export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), billingTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), receiptsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
|
|
22
23
|
export function createUiCoreTranslations({ germanVariant = 'formal' } = {}) {
|
|
23
24
|
return Object.fromEntries(Object.entries(uiCoreTranslations).map(([key, value]) => {
|
|
24
25
|
const { de_informal } = value, rest = __rest(value, ["de_informal"]);
|
package/dist/index.js
CHANGED
|
@@ -99,4 +99,13 @@ export { chartsTranslations } from './i18n/chartsTranslations';
|
|
|
99
99
|
export { messagingTranslations } from './i18n/messagingTranslations';
|
|
100
100
|
export { chatTranslations } from './i18n/chatTranslations';
|
|
101
101
|
export { billingTranslations } from './i18n/billingTranslations';
|
|
102
|
+
export { receiptsTranslations } from './i18n/receiptsTranslations';
|
|
103
|
+
export { ReceiptCaptureCard } from './receipts/ReceiptCaptureCard';
|
|
104
|
+
export { ReceiptReviewDialog } from './receipts/ReceiptReviewDialog';
|
|
105
|
+
export { ReceiptPicker } from './receipts/ReceiptPicker';
|
|
106
|
+
export { ReceiptConsentSwitch } from './receipts/ReceiptConsentSwitch';
|
|
107
|
+
export { useReceiptConsent } from './receipts/useReceiptConsent';
|
|
108
|
+
export { createReceiptsApi, defaultReceiptsApi } from './receipts/api';
|
|
109
|
+
export { receiptFileProblem, prepareReceiptFile } from './receipts/files';
|
|
110
|
+
export { RECEIPT_ACCEPT, RECEIPT_MAX_BYTES } from './receipts/wire';
|
|
102
111
|
export { createUiCoreTranslations, uiCoreTranslations } from './i18n/uiCoreTranslations';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Alert, Box, Button, Card, CardContent, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { defaultReceiptsApi } from './api';
|
|
6
|
+
import { RECEIPT_ACCEPT } from './wire';
|
|
7
|
+
import { prepareReceiptFile, receiptFileProblem } from './files';
|
|
8
|
+
import { useReceiptConsent } from './useReceiptConsent';
|
|
9
|
+
import ReceiptConsentSwitch from './ReceiptConsentSwitch';
|
|
10
|
+
import ReceiptPicker from './ReceiptPicker';
|
|
11
|
+
import ReceiptReviewDialog from './ReceiptReviewDialog';
|
|
12
|
+
export const RECEIPT_CAPTURE_CARD_SX = { width: '100%' };
|
|
13
|
+
export const RECEIPT_CAPTURE_ALERT_SX = { width: '100%' };
|
|
14
|
+
export const RECEIPT_CAPTURE_ACTION_SX = { flex: '1 1 auto' };
|
|
15
|
+
export const RECEIPT_CAPTURE_PROGRESS_SX = { width: '100%' };
|
|
16
|
+
const ERROR_TEXT = { unsupported_file_type: 'file', file_too_large: 'file', invalid_request: 'file', nothing_recognised: 'nothing', provider_unavailable: 'unavailable', spend_cap_reached: 'unavailable', count_limit_reached: 'unavailable', authentication_required: 'unavailable' };
|
|
17
|
+
const problemCode = (error) => { var _a, _b; return ((_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.code) || 'provider_unavailable'; };
|
|
18
|
+
export function ReceiptCaptureCard({ api = defaultReceiptsApi, provider = 'OpenAI', context, values, fieldLabels, extraFields, accountLabel, onApply, onSelectReceipts, confirmLabel, onUploadOnly, onFile, onReset, disabled, sx }) {
|
|
19
|
+
const { t } = useTranslation();
|
|
20
|
+
const consent = useReceiptConsent(api);
|
|
21
|
+
const [phase, setPhase] = useState('idle'), [error, setError] = useState(null), [receipt, setReceipt] = useState(null), [receipts, setReceipts] = useState([]), [progress, setProgress] = useState(null), [reviewOpen, setReviewOpen] = useState(false);
|
|
22
|
+
const [file, setFile] = useState(null), [preview, setPreview] = useState(null);
|
|
23
|
+
const cameraRef = useRef(null), fileRef = useRef(null), uploadRef = useRef(null), abortRef = useRef(null), urlRef = useRef(null);
|
|
24
|
+
const clearUrl = () => { if (urlRef.current)
|
|
25
|
+
URL.revokeObjectURL(urlRef.current); urlRef.current = null; setPreview(null); };
|
|
26
|
+
const reset = () => { var _a; (_a = abortRef.current) === null || _a === void 0 ? void 0 : _a.abort(); abortRef.current = null; clearUrl(); setFile(null); setReceipt(null); setReceipts([]); setProgress(null); setError(null); setPhase('idle'); setReviewOpen(false); onReset === null || onReset === void 0 ? void 0 : onReset(); };
|
|
27
|
+
useEffect(() => () => { var _a; (_a = abortRef.current) === null || _a === void 0 ? void 0 : _a.abort(); if (urlRef.current)
|
|
28
|
+
URL.revokeObjectURL(urlRef.current); }, []);
|
|
29
|
+
useEffect(() => { var _a; if (consent.status === 'ready' && !consent.granted && phase === 'reading') {
|
|
30
|
+
(_a = abortRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
31
|
+
setPhase('idle');
|
|
32
|
+
} }, [consent.status, consent.granted, phase]);
|
|
33
|
+
const choose = async (raw, uploadOnly = false) => {
|
|
34
|
+
var _a, _b, _c;
|
|
35
|
+
const incoming = ((_b = (_a = raw === null || raw === void 0 ? void 0 : raw.target) === null || _a === void 0 ? void 0 : _a.files) === null || _b === void 0 ? void 0 : _b[0]) || raw;
|
|
36
|
+
if (raw === null || raw === void 0 ? void 0 : raw.target)
|
|
37
|
+
raw.target.value = '';
|
|
38
|
+
if (!incoming)
|
|
39
|
+
return;
|
|
40
|
+
(_c = abortRef.current) === null || _c === void 0 ? void 0 : _c.abort();
|
|
41
|
+
clearUrl();
|
|
42
|
+
setError(null);
|
|
43
|
+
setReceipt(null);
|
|
44
|
+
setReceipts([]);
|
|
45
|
+
setReviewOpen(false);
|
|
46
|
+
let issue = receiptFileProblem(incoming, { checkSize: uploadOnly });
|
|
47
|
+
if (issue) {
|
|
48
|
+
setError(issue);
|
|
49
|
+
setPhase('error');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (uploadOnly) {
|
|
53
|
+
onUploadOnly === null || onUploadOnly === void 0 ? void 0 : onUploadOnly(incoming);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
abortRef.current = controller;
|
|
58
|
+
setPhase('reading');
|
|
59
|
+
setProgress(null);
|
|
60
|
+
try {
|
|
61
|
+
const prepared = await prepareReceiptFile(incoming);
|
|
62
|
+
issue = receiptFileProblem(prepared);
|
|
63
|
+
if (controller.signal.aborted)
|
|
64
|
+
return;
|
|
65
|
+
if (issue) {
|
|
66
|
+
setError(issue);
|
|
67
|
+
setPhase('error');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setFile(prepared);
|
|
71
|
+
onFile === null || onFile === void 0 ? void 0 : onFile(prepared);
|
|
72
|
+
if (['image/jpeg', 'image/png'].includes(prepared.type.toLowerCase()) && !/\.(heic|heif)$/i.test(prepared.name)) {
|
|
73
|
+
try {
|
|
74
|
+
urlRef.current = URL.createObjectURL(prepared);
|
|
75
|
+
setPreview(urlRef.current);
|
|
76
|
+
}
|
|
77
|
+
catch (_d) { /* preview is optional */ }
|
|
78
|
+
}
|
|
79
|
+
const result = await api.readReceipts(prepared, { context, signal: controller.signal, onUploadProgress: (e) => setProgress((e === null || e === void 0 ? void 0 : e.total) ? Math.round((e.loaded / e.total) * 100) : null) });
|
|
80
|
+
if (controller.signal.aborted)
|
|
81
|
+
return;
|
|
82
|
+
const list = Array.isArray(result) ? result : [];
|
|
83
|
+
if (!list.length) {
|
|
84
|
+
clearUrl();
|
|
85
|
+
setFile(null);
|
|
86
|
+
setError('nothing_recognised');
|
|
87
|
+
setPhase('error');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setReceipts(list);
|
|
91
|
+
if (list.length === 1) {
|
|
92
|
+
setReceipt(list[0]);
|
|
93
|
+
setPhase('review');
|
|
94
|
+
setReviewOpen(true);
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
setPhase('picker');
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
if (!controller.signal.aborted) {
|
|
101
|
+
const code = problemCode(e);
|
|
102
|
+
if (code === 'consent_required') {
|
|
103
|
+
consent.reload();
|
|
104
|
+
setPhase('idle');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
setError(code);
|
|
108
|
+
setPhase('error');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const errKey = error && ERROR_TEXT[error];
|
|
114
|
+
const overlayBoxes = receipt ? [receipt.box, ...(receipt.items || []).map((i) => i.box)].filter((b) => b && b.w > 0 && b.h > 0) : [];
|
|
115
|
+
const camera = _jsx("input", { ref: cameraRef, hidden: true, type: "file", accept: "image/*", capture: "environment", onChange: choose });
|
|
116
|
+
const chooser = _jsx("input", { ref: fileRef, hidden: true, type: "file", accept: RECEIPT_ACCEPT, onChange: choose });
|
|
117
|
+
return _jsx(Paper, { component: "section", "aria-label": t('Receipts.CARD_LABEL'), sx: [RECEIPT_CAPTURE_CARD_SX, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])], children: _jsx(Card, { variant: "outlined", children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 2, children: [_jsx(ReceiptConsentSwitch, { api: api, provider: provider, consent: consent }), !consent.granted && _jsx(_Fragment, { children: onUploadOnly && _jsxs(_Fragment, { children: [_jsx(Button, { disabled: disabled, onClick: () => { var _a; return (_a = uploadRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: t('Receipts.UPLOAD_ONLY') }), _jsx("input", { ref: uploadRef, hidden: true, type: "file", accept: RECEIPT_ACCEPT, onChange: (e) => choose(e, true) })] }) }), consent.status === 'loading' && _jsx(LinearProgress, { sx: RECEIPT_CAPTURE_PROGRESS_SX }), consent.granted && _jsxs(_Fragment, { children: [_jsxs(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 1, children: [camera, chooser, _jsx(Button, { sx: RECEIPT_CAPTURE_ACTION_SX, variant: "contained", disabled: disabled || phase === 'reading', onClick: () => { var _a; return (_a = cameraRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: t('Receipts.CAMERA') }), _jsx(Button, { sx: RECEIPT_CAPTURE_ACTION_SX, disabled: disabled || phase === 'reading', onClick: () => { var _a; return (_a = fileRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: t('Receipts.PICK_FILE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Receipts.ACCEPTED_TYPES') })] }), phase === 'reading' && _jsxs(_Fragment, { children: [_jsx(Box, { sx: { position: 'relative' }, children: preview ? _jsxs(_Fragment, { children: [_jsx("img", { src: preview, alt: (file === null || file === void 0 ? void 0 : file.name) || '', style: { display: 'block', maxWidth: '100%', maxHeight: 360 } }), overlayBoxes.map((b, i) => _jsx(Box, { sx: { position: 'absolute', left: `${b.x * 100}%`, top: `${b.y * 100}%`, width: `${b.w * 100}%`, height: `${b.h * 100}%`, border: 2, borderColor: 'primary.main', pointerEvents: 'none' } }, i))] }) : _jsx(Typography, { children: (file === null || file === void 0 ? void 0 : file.name) || ' ' }) }), _jsx(LinearProgress, { variant: progress == null ? 'indeterminate' : 'determinate', value: progress || 0, sx: RECEIPT_CAPTURE_PROGRESS_SX }), _jsx(Typography, { role: "status", children: t('Receipts.READING') })] }), phase === 'review' && _jsxs(_Fragment, { children: [_jsx(Box, { sx: { position: 'relative' }, children: preview && _jsxs(_Fragment, { children: [_jsx("img", { src: preview, alt: (file === null || file === void 0 ? void 0 : file.name) || '', style: { display: 'block', maxWidth: '100%', maxHeight: 360 } }), overlayBoxes.map((b, i) => _jsx(Box, { sx: { position: 'absolute', left: `${b.x * 100}%`, top: `${b.y * 100}%`, width: `${b.w * 100}%`, height: `${b.h * 100}%`, border: 2, borderColor: 'primary.main', pointerEvents: 'none' } }, i))] }) }), _jsx(Button, { onClick: () => setReviewOpen(true), children: t('Receipts.REVIEW_TITLE') }), _jsx(ReceiptReviewDialog, { open: reviewOpen, receipt: receipt, values: values, fieldLabels: fieldLabels, extraFields: extraFields, accountLabel: accountLabel, onApply: onApply, onClose: () => setReviewOpen(false) })] }), phase === 'picker' && _jsx(ReceiptPicker, { receipts: receipts, imageUrl: preview, fieldLabels: fieldLabels, confirmLabel: confirmLabel, onCancel: reset, onConfirm: (chosen) => { onSelectReceipts === null || onSelectReceipts === void 0 ? void 0 : onSelectReceipts(chosen); clearUrl(); setReceipts([]); setPhase('idle'); } }), errKey && _jsx(Alert, { severity: errKey === 'file' ? 'error' : errKey === 'nothing' ? 'info' : 'warning', sx: RECEIPT_CAPTURE_ALERT_SX, children: t(errKey === 'file' ? 'Receipts.FILE_PROBLEM' : errKey === 'nothing' ? 'Receipts.NOTHING_RECOGNISED' : 'Receipts.UNAVAILABLE') }), phase !== 'idle' && _jsx(Button, { onClick: reset, children: t('Receipts.ANOTHER') })] }) }) }) });
|
|
118
|
+
}
|
|
119
|
+
export default ReceiptCaptureCard;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useMemo } from 'react';
|
|
3
|
+
import { Alert, Button, FormControlLabel, Stack, Switch } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { defaultReceiptsApi } from './api';
|
|
6
|
+
import { useReceiptConsent } from './useReceiptConsent';
|
|
7
|
+
export const RECEIPT_CONSENT_CONTROL_SX = { alignItems: 'flex-start' };
|
|
8
|
+
export const RECEIPT_CONSENT_ALERT_SX = { width: '100%' };
|
|
9
|
+
export function ReceiptConsentSwitch({ api = defaultReceiptsApi, provider = 'OpenAI', consent, onChange, sx }) {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
const fallbackApi = useMemo(() => consent ? { getConsent: () => Promise.resolve({ granted: false }), setConsent: (v) => consent.set(v).then((answer) => ({ granted: answer })) } : api, [api, Boolean(consent)]);
|
|
12
|
+
const own = useReceiptConsent(fallbackApi);
|
|
13
|
+
const state = consent || own;
|
|
14
|
+
const change = async (event) => { const answer = await state.set(event.target.checked); if (answer === event.target.checked)
|
|
15
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(answer); };
|
|
16
|
+
return _jsxs(Stack, { spacing: 1, children: [_jsx(FormControlLabel, { sx: [RECEIPT_CONSENT_CONTROL_SX, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])], control: _jsx(Switch, { checked: Boolean(state.granted), onChange: change, disabled: state.status === 'loading' || state.busy, inputProps: { 'aria-label': t('Receipts.CONSENT', { provider }) } }), label: t('Receipts.CONSENT', { provider }) }), (state.status === 'error' || state.error) && _jsx(Alert, { severity: "error", sx: RECEIPT_CONSENT_ALERT_SX, action: _jsx(Button, { color: "inherit", size: "small", onClick: state.reload, children: t('Receipts.RETRY') }), children: t('Receipts.CONSENT_ERROR') })] });
|
|
17
|
+
}
|
|
18
|
+
export default ReceiptConsentSwitch;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Button, Card, CardContent, Checkbox, Chip, DialogActions, DialogContent, DialogTitle, Grid, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { formatReceiptAmount, formatReceiptDate } from './format';
|
|
6
|
+
export const RECEIPT_PICKER_CARD_SX = { height: '100%' };
|
|
7
|
+
export const RECEIPT_PICKER_CLICK_SX = { cursor: 'pointer', height: '100%' };
|
|
8
|
+
export const RECEIPT_PICKER_ACTION_SX = { justifyContent: 'space-between' };
|
|
9
|
+
function cropStyle(box) { if (!box || !box.w || !box.h)
|
|
10
|
+
return null; return { backgroundSize: `${100 / box.w}% ${100 / box.h}%`, backgroundPosition: `${(box.x / (1 - box.w)) * 100 || 0}% ${(box.y / (1 - box.h)) * 100 || 0}%` }; }
|
|
11
|
+
export function ReceiptPicker({ receipts = [], imageUrl, onConfirm, onCancel, confirmLabel, fieldLabels, sx }) {
|
|
12
|
+
const { t, i18n } = useTranslation();
|
|
13
|
+
const [selected, setSelected] = useState([]);
|
|
14
|
+
useEffect(() => setSelected(receipts.map((r, i) => r.confidence === 'low' ? -1 : i).filter((i) => i >= 0)), [receipts]);
|
|
15
|
+
const toggle = (i) => setSelected((old) => old.includes(i) ? old.filter((n) => n !== i) : [...old, i]);
|
|
16
|
+
const count = selected.length;
|
|
17
|
+
return _jsxs(Box, { sx: sx, children: [_jsx(DialogTitle, { children: t('Receipts.RECOGNISED_COUNT', { n: receipts.length }) }), _jsx(DialogContent, { children: _jsx(Grid, { container: true, spacing: 2, children: receipts.map((r, i) => { const crop = cropStyle(r.box); return _jsx(Grid, { size: { xs: 12, md: 4 }, children: _jsx(Card, { sx: RECEIPT_PICKER_CARD_SX, variant: "outlined", children: _jsx(Box, { onClick: () => toggle(i), sx: RECEIPT_PICKER_CLICK_SX, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsx(Box, { role: "img", "aria-label": r.merchant || t('Receipts.UNREADABLE'), sx: Object.assign({ height: 100, bgcolor: 'action.hover', backgroundImage: imageUrl && crop ? `url(${imageUrl})` : 'none', backgroundRepeat: 'no-repeat' }, (imageUrl && crop ? crop : {})) }), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(Checkbox, { checked: selected.includes(i), inputProps: { 'aria-label': r.merchant || t('Receipts.UNREADABLE') }, onChange: () => toggle(i), onClick: (e) => e.stopPropagation() }), _jsx(Typography, { children: r.merchant || t('Receipts.UNREADABLE') })] }), _jsxs(Typography, { variant: "body2", children: [r.date ? formatReceiptDate(r.date, i18n.language) : '', r.total ? ` · ${formatReceiptAmount(r.total, r.currency, i18n.language)}` : t('Receipts.AMOUNT_UNKNOWN')] }), _jsx(Chip, { size: "small", color: r.confidence === 'low' ? 'warning' : 'success', label: t(r.confidence === 'low' ? 'Receipts.UNCERTAIN' : 'Receipts.CERTAIN') })] }) }) }) }) }, i); }) }) }), _jsxs(DialogActions, { sx: RECEIPT_PICKER_ACTION_SX, children: [_jsx(Button, { onClick: onCancel, children: t('Receipts.CANCEL') }), _jsx(Button, { disabled: !count, variant: "contained", onClick: () => onConfirm === null || onConfirm === void 0 ? void 0 : onConfirm(receipts.filter((_r, i) => selected.includes(i))), children: confirmLabel ? confirmLabel(count) : t('Receipts.APPLY_COUNT', { n: count }) })] })] });
|
|
18
|
+
}
|
|
19
|
+
export default ReceiptPicker;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Drawer, Stack, Typography, useMediaQuery } from '@mui/material';
|
|
4
|
+
import { useTheme } from '@mui/material/styles';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { formatReceiptAmount, formatReceiptDate, isEmptyValue, receiptValueEqual } from './format';
|
|
7
|
+
export const RECEIPT_REVIEW_ACTIONS_SX = { gap: 1, flexWrap: 'wrap', flexDirection: { xs: 'column', sm: 'row' }, '& > :nth-of-type(1)': { order: { xs: 3, sm: 0 } }, '& > :nth-of-type(2)': { order: { xs: 2, sm: 0 } }, '& > :nth-of-type(3)': { order: { xs: 1, sm: 0 } } };
|
|
8
|
+
export const RECEIPT_REVIEW_ROW_SX = { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 1, alignItems: 'start' };
|
|
9
|
+
export const RECEIPT_REVIEW_SHEET_PAPER_SX = { height: '92%' };
|
|
10
|
+
export const RECEIPT_REVIEW_ACTION_BUTTON_SX = { flex: '1 1 auto' };
|
|
11
|
+
export function ReceiptReviewDialog({ open, receipt, values = {}, fieldLabels = {}, extraFields = [], accountLabel = (id) => id, onApply, onClose }) {
|
|
12
|
+
var _a;
|
|
13
|
+
const { t, i18n } = useTranslation();
|
|
14
|
+
const theme = useTheme();
|
|
15
|
+
const mobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
16
|
+
const rows = [];
|
|
17
|
+
const add = (key, value, recognized = value !== '' && value != null, format = (v) => String(v)) => { if (recognized)
|
|
18
|
+
rows.push({ key, value, text: format(value), confidence: receipt === null || receipt === void 0 ? void 0 : receipt.confidence }); };
|
|
19
|
+
add('merchant', receipt === null || receipt === void 0 ? void 0 : receipt.merchant);
|
|
20
|
+
add('date', receipt === null || receipt === void 0 ? void 0 : receipt.date, Boolean(receipt === null || receipt === void 0 ? void 0 : receipt.date), (v) => formatReceiptDate(v, i18n.language));
|
|
21
|
+
add('total', receipt === null || receipt === void 0 ? void 0 : receipt.total, Boolean(receipt === null || receipt === void 0 ? void 0 : receipt.total), (v) => formatReceiptAmount(v, receipt === null || receipt === void 0 ? void 0 : receipt.currency, i18n.language));
|
|
22
|
+
add('currency', receipt === null || receipt === void 0 ? void 0 : receipt.currency);
|
|
23
|
+
if ((receipt === null || receipt === void 0 ? void 0 : receipt.payment_method) && receipt.payment_method !== 'unknown')
|
|
24
|
+
add('payment_method', receipt.payment_method, true, (v) => t(`Receipts.PAYMENT_${v}`));
|
|
25
|
+
const account = (_a = receipt === null || receipt === void 0 ? void 0 : receipt.account_suggestions) === null || _a === void 0 ? void 0 : _a.find((s) => s === null || s === void 0 ? void 0 : s.account_id);
|
|
26
|
+
if (account)
|
|
27
|
+
rows.push({ key: 'account', value: account.account_id, text: accountLabel(account.account_id), confidence: account.confidence === 'low' ? 'low' : receipt === null || receipt === void 0 ? void 0 : receipt.confidence });
|
|
28
|
+
if (Array.isArray(receipt === null || receipt === void 0 ? void 0 : receipt.items) && receipt.items.length)
|
|
29
|
+
rows.push({ key: 'items', value: receipt.items, text: receipt.items.map((item) => `${item.description || ''}${item.amount ? ` · ${item.amount}` : ''}`).join('\n'), confidence: receipt === null || receipt === void 0 ? void 0 : receipt.confidence });
|
|
30
|
+
(extraFields || []).forEach(({ key, format }) => { if (!['merchant', 'date', 'total', 'currency', 'payment_method', 'account', 'items'].includes(key) && (receipt === null || receipt === void 0 ? void 0 : receipt[key]) != null && receipt[key] !== '')
|
|
31
|
+
add(key, receipt[key], true, format || ((v) => typeof v === 'string' ? v : JSON.stringify(v))); });
|
|
32
|
+
const label = (key) => { var _a; return fieldLabels[key] || ((_a = extraFields.find((field) => field.key === key)) === null || _a === void 0 ? void 0 : _a.label) || t(`Receipts.FIELD_${key}`); };
|
|
33
|
+
const apply = (list, mode, close) => { if (!list.length)
|
|
34
|
+
return; onApply === null || onApply === void 0 ? void 0 : onApply(Object.fromEntries(list.map((r) => [r.key, r.value])), { mode }); if (close)
|
|
35
|
+
onClose === null || onClose === void 0 ? void 0 : onClose(); };
|
|
36
|
+
const content = _jsxs(_Fragment, { children: [_jsx(DialogTitle, { id: "receipt-review-title", children: t('Receipts.REVIEW_TITLE') }), _jsx(DialogContent, { children: _jsx(Stack, { spacing: 2, children: rows.map((row) => { const current = values[row.key]; const equal = !isEmptyValue(current) && receiptValueEqual(row.key, current, row.value); return _jsxs(Box, { sx: RECEIPT_REVIEW_ROW_SX, children: [_jsxs(Box, { sx: { minWidth: 0 }, children: [_jsx(Typography, { variant: "subtitle2", children: label(row.key) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", children: [row.key === 'items' ? _jsx(Box, { component: "ul", sx: { m: 0, pl: 2 }, children: row.value.map((item, index) => _jsx("li", { children: _jsx(Typography, { component: "span", children: `${item.description || ''}${item.amount ? ` · ${item.amount}` : ''}` }) }, index)) }) : _jsx(Typography, { children: row.text }), row.confidence === 'low' && _jsx(Chip, { size: "small", color: "warning", label: t('Receipts.UNCERTAIN') }), equal ? _jsx(Typography, { variant: "caption", children: t('Receipts.SAME_AS_FORM') }) : !isEmptyValue(current) && _jsx(Typography, { variant: "body2", sx: { textDecoration: 'line-through' }, children: String(current) })] })] }), !equal && _jsx(Button, { size: "small", onClick: () => apply([row], 'field', false), children: t('Receipts.APPLY') })] }, row.key); }) }) }), _jsxs(DialogActions, { sx: RECEIPT_REVIEW_ACTIONS_SX, children: [_jsx(Button, { sx: RECEIPT_REVIEW_ACTION_BUTTON_SX, onClick: onClose, children: t('Receipts.CLOSE') }), _jsx(Button, { variant: "outlined", sx: RECEIPT_REVIEW_ACTION_BUTTON_SX, disabled: !rows.some((r) => isEmptyValue(values[r.key])), onClick: () => apply(rows.filter((r) => isEmptyValue(values[r.key])), 'empty', true), children: t('Receipts.ONLY_EMPTY') }), _jsx(Button, { variant: "contained", sx: RECEIPT_REVIEW_ACTION_BUTTON_SX, disabled: !rows.length, onClick: () => apply(rows, 'all', true), children: t('Receipts.APPLY_ALL') })] })] });
|
|
37
|
+
return mobile ? _jsx(Drawer, { anchor: "bottom", open: open, onClose: onClose, PaperProps: { sx: RECEIPT_REVIEW_SHEET_PAPER_SX, role: 'dialog', 'aria-labelledby': 'receipt-review-title' }, children: content }) : _jsx(Dialog, { open: open, onClose: onClose, maxWidth: "sm", fullWidth: true, "aria-labelledby": "receipt-review-title", children: content });
|
|
38
|
+
}
|
|
39
|
+
export default ReceiptReviewDialog;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import apiClient from '../auth/apiClient';
|
|
2
|
+
import { receiptsApiUrls } from './wire';
|
|
3
|
+
export function createReceiptsApi(overrides = {}, { apiBase = '/api/receipts/' } = {}) {
|
|
4
|
+
const urls = receiptsApiUrls(apiBase);
|
|
5
|
+
const defaults = {
|
|
6
|
+
getConsent: () => apiClient.get(urls.consent).then((r) => r.data),
|
|
7
|
+
setConsent: (granted) => (granted ? apiClient.post(urls.consent) : apiClient.delete(urls.consent)).then((r) => r.data),
|
|
8
|
+
readReceipts: (file, { context, onUploadProgress, signal } = {}) => {
|
|
9
|
+
const body = new FormData();
|
|
10
|
+
body.append('file', file);
|
|
11
|
+
if (context !== undefined)
|
|
12
|
+
body.append('context', JSON.stringify(context));
|
|
13
|
+
return apiClient.post(urls.read, body, { onUploadProgress, signal }).then((r) => { var _a; return Array.isArray((_a = r.data) === null || _a === void 0 ? void 0 : _a.receipts) ? r.data.receipts : []; });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
return Object.assign(Object.assign({}, defaults), overrides);
|
|
17
|
+
}
|
|
18
|
+
export const defaultReceiptsApi = createReceiptsApi();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { RECEIPT_MAX_BYTES } from './wire';
|
|
2
|
+
const extOf = (file) => String((file === null || file === void 0 ? void 0 : file.name) || '').split('.').pop().toLowerCase();
|
|
3
|
+
// `checkSize: false` is for files that are scaled down before upload; the bound is checked again on the prepared file.
|
|
4
|
+
export function receiptFileProblem(file, { checkSize = true } = {}) {
|
|
5
|
+
const ext = extOf(file);
|
|
6
|
+
const type = String((file === null || file === void 0 ? void 0 : file.type) || '').toLowerCase();
|
|
7
|
+
// A declared type decides; the extension only decides when the browser gave none (phones often send an empty type for HEIC).
|
|
8
|
+
const supported = type && type !== 'application/octet-stream'
|
|
9
|
+
? ['image/jpeg', 'image/png', 'image/heic', 'image/heif', 'application/pdf'].includes(type)
|
|
10
|
+
: ['jpg', 'jpeg', 'png', 'heic', 'heif', 'pdf'].includes(ext);
|
|
11
|
+
if (!supported)
|
|
12
|
+
return 'unsupported_file_type';
|
|
13
|
+
if (!Number.isFinite(file === null || file === void 0 ? void 0 : file.size) || (checkSize && file.size > RECEIPT_MAX_BYTES))
|
|
14
|
+
return 'file_too_large';
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function decode(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const image = new Image(); image.onload = () => { URL.revokeObjectURL(url); resolve(image); }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('decode')); }; image.src = url; }); }
|
|
18
|
+
function blobOf(canvas) { return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85)); }
|
|
19
|
+
export async function prepareReceiptFile(file) {
|
|
20
|
+
var _a;
|
|
21
|
+
const ext = extOf(file), type = String((file === null || file === void 0 ? void 0 : file.type) || '').toLowerCase();
|
|
22
|
+
if (file.size <= 1.5 * 1024 * 1024 || !(['image/jpeg', 'image/png'].includes(type) || ['jpg', 'jpeg', 'png'].includes(ext)))
|
|
23
|
+
return file;
|
|
24
|
+
try {
|
|
25
|
+
const image = await decode(file), width = image.naturalWidth || image.width, height = image.naturalHeight || image.height;
|
|
26
|
+
const scale = Math.min(1, 2400 / Math.max(width, height));
|
|
27
|
+
if (!width || !height)
|
|
28
|
+
return file;
|
|
29
|
+
const canvas = document.createElement('canvas');
|
|
30
|
+
canvas.width = Math.round(width * scale);
|
|
31
|
+
canvas.height = Math.round(height * scale);
|
|
32
|
+
(_a = canvas.getContext('2d')) === null || _a === void 0 ? void 0 : _a.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
33
|
+
const blob = await blobOf(canvas);
|
|
34
|
+
return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: file.lastModified }) : file;
|
|
35
|
+
}
|
|
36
|
+
catch (_b) {
|
|
37
|
+
return file;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { intlLocale } from '../billing/format';
|
|
2
|
+
export function formatReceiptAmount(total, currency, language) {
|
|
3
|
+
if (typeof total !== 'string' || !total.trim())
|
|
4
|
+
return total || '';
|
|
5
|
+
const n = Number(total);
|
|
6
|
+
if (!Number.isFinite(n))
|
|
7
|
+
return total;
|
|
8
|
+
const candidate = typeof currency === 'string' ? currency.toUpperCase() : '';
|
|
9
|
+
let code = 'USD';
|
|
10
|
+
try {
|
|
11
|
+
const supported = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('currency') : null;
|
|
12
|
+
if (/^[A-Z]{3}$/.test(candidate) && (!supported || supported.includes(candidate))) {
|
|
13
|
+
new Intl.NumberFormat(intlLocale(language), { style: 'currency', currency: candidate });
|
|
14
|
+
code = candidate;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch (_a) {
|
|
18
|
+
code = 'USD';
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return new Intl.NumberFormat(intlLocale(language), { style: 'currency', currency: code }).format(n);
|
|
22
|
+
}
|
|
23
|
+
catch (_b) {
|
|
24
|
+
return total;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function formatReceiptDate(iso, language) {
|
|
28
|
+
if (typeof iso !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(iso))
|
|
29
|
+
return iso || '';
|
|
30
|
+
const date = new Date(`${iso}T00:00:00Z`);
|
|
31
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== iso)
|
|
32
|
+
return iso;
|
|
33
|
+
return new Intl.DateTimeFormat(intlLocale(language), { dateStyle: 'short', timeZone: 'UTC' }).format(date);
|
|
34
|
+
}
|
|
35
|
+
export function isEmptyValue(value) { return value === '' || value === null || value === undefined || (Array.isArray(value) && value.length === 0); }
|
|
36
|
+
export function receiptValueEqual(key, a, b) {
|
|
37
|
+
if (key === 'items')
|
|
38
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => ['description', 'amount'].every((k) => { var _a; var _b, _c; return String((_b = item === null || item === void 0 ? void 0 : item[k]) !== null && _b !== void 0 ? _b : '').trim().toLowerCase() === String((_c = (_a = b[i]) === null || _a === void 0 ? void 0 : _a[k]) !== null && _c !== void 0 ? _c : '').trim().toLowerCase(); }));
|
|
39
|
+
if (key === 'total' && a !== '' && b !== '' && Number.isFinite(Number(a)) && Number.isFinite(Number(b)))
|
|
40
|
+
return Number(a) === Number(b);
|
|
41
|
+
return String(a !== null && a !== void 0 ? a : '').trim().toLowerCase() === String(b !== null && b !== void 0 ? b : '').trim().toLowerCase();
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
export function useReceiptConsent(api) {
|
|
3
|
+
const [status, setStatus] = useState('loading');
|
|
4
|
+
const [granted, setGranted] = useState(false);
|
|
5
|
+
const [busy, setBusy] = useState(false);
|
|
6
|
+
const [error, setError] = useState(false);
|
|
7
|
+
const [reloadCount, setReloadCount] = useState(0);
|
|
8
|
+
const liveRef = useRef(true);
|
|
9
|
+
const generation = useRef(0); // bumped when the api changes, so an answer of the old api can never count
|
|
10
|
+
const reload = useCallback(() => { setGranted(false); setStatus('loading'); setError(false); setReloadCount((n) => n + 1); }, []);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
liveRef.current = true;
|
|
13
|
+
let requestLive = true;
|
|
14
|
+
generation.current += 1;
|
|
15
|
+
setGranted(false);
|
|
16
|
+
setStatus('loading'); // consent known for another api never carries over
|
|
17
|
+
Promise.resolve().then(() => api.getConsent()).then((answer) => { if (requestLive) {
|
|
18
|
+
setGranted(Boolean(answer === null || answer === void 0 ? void 0 : answer.granted));
|
|
19
|
+
setStatus('ready');
|
|
20
|
+
setError(false);
|
|
21
|
+
} }).catch(() => { if (requestLive) {
|
|
22
|
+
setStatus('error');
|
|
23
|
+
setError(true);
|
|
24
|
+
} });
|
|
25
|
+
return () => { requestLive = false; liveRef.current = false; };
|
|
26
|
+
}, [api, reloadCount]);
|
|
27
|
+
const set = useCallback(async (next) => {
|
|
28
|
+
const mine = generation.current;
|
|
29
|
+
setBusy(true);
|
|
30
|
+
setError(false);
|
|
31
|
+
if (!next)
|
|
32
|
+
setGranted(false); // fail closed: a revocation takes effect at once, whatever the server answers
|
|
33
|
+
try {
|
|
34
|
+
const answer = await api.setConsent(Boolean(next));
|
|
35
|
+
if (!liveRef.current || mine !== generation.current)
|
|
36
|
+
return false;
|
|
37
|
+
setGranted(Boolean(answer === null || answer === void 0 ? void 0 : answer.granted));
|
|
38
|
+
setStatus('ready');
|
|
39
|
+
return Boolean(answer === null || answer === void 0 ? void 0 : answer.granted);
|
|
40
|
+
}
|
|
41
|
+
catch (_a) {
|
|
42
|
+
if (liveRef.current && mine === generation.current)
|
|
43
|
+
setError(true);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (liveRef.current && mine === generation.current)
|
|
48
|
+
setBusy(false);
|
|
49
|
+
}
|
|
50
|
+
}, [api]);
|
|
51
|
+
return { status, granted, busy, error, set, reload };
|
|
52
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const RECEIPT_ERROR_CODES = ['consent_required', 'unsupported_file_type', 'file_too_large', 'invalid_request', 'nothing_recognised', 'spend_cap_reached', 'count_limit_reached', 'provider_unavailable', 'authentication_required'];
|
|
2
|
+
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
|
|
3
|
+
export const RECEIPT_ACCEPT = 'image/jpeg,image/png,image/heic,image/heif,application/pdf,.jpg,.jpeg,.png,.heic,.heif,.pdf';
|
|
4
|
+
export const RECEIPT_FIELD_KEYS = ['merchant', 'date', 'total', 'currency', 'payment_method', 'account', 'items'];
|
|
5
|
+
export const receiptsApiUrls = (base = '/api/receipts/') => { const root = base.replace(/\/$/, '') + '/'; return { consent: root + 'consent/', read: root + 'read/' }; };
|
|
@@ -39,6 +39,10 @@ import { PLAN_PICKER_ALERT_SX, PLAN_PICKER_CARD_SX, PLAN_PICKER_LABEL_SX, PLAN_P
|
|
|
39
39
|
import { SUBSCRIPTION_SECTION_ALERT_SX } from '../billing/SubscriptionSection';
|
|
40
40
|
import { WITHDRAWAL_DIALOG_ALERT_SX } from '../billing/WithdrawalDialog';
|
|
41
41
|
import { WITHDRAWAL_SECTION_ALERT_SX } from '../billing/WithdrawalSection';
|
|
42
|
+
import { RECEIPT_CONSENT_ALERT_SX, RECEIPT_CONSENT_CONTROL_SX } from '../receipts/ReceiptConsentSwitch';
|
|
43
|
+
import { RECEIPT_CAPTURE_ACTION_SX, RECEIPT_CAPTURE_ALERT_SX, RECEIPT_CAPTURE_CARD_SX, RECEIPT_CAPTURE_PROGRESS_SX } from '../receipts/ReceiptCaptureCard';
|
|
44
|
+
import { RECEIPT_PICKER_ACTION_SX, RECEIPT_PICKER_CARD_SX } from '../receipts/ReceiptPicker';
|
|
45
|
+
import { RECEIPT_REVIEW_ACTIONS_SX, RECEIPT_REVIEW_ACTION_BUTTON_SX, RECEIPT_REVIEW_SHEET_PAPER_SX } from '../receipts/ReceiptReviewDialog';
|
|
42
46
|
const SX_EXPORTS = {
|
|
43
47
|
ACCESS_CODE_ACTION_SX,
|
|
44
48
|
ACCESS_ENDED_BANNER_ALERT_SX,
|
|
@@ -116,6 +120,17 @@ const SX_EXPORTS = {
|
|
|
116
120
|
SUBSCRIPTION_SECTION_ALERT_SX,
|
|
117
121
|
WITHDRAWAL_DIALOG_ALERT_SX,
|
|
118
122
|
WITHDRAWAL_SECTION_ALERT_SX,
|
|
123
|
+
RECEIPT_CONSENT_ALERT_SX,
|
|
124
|
+
RECEIPT_CONSENT_CONTROL_SX,
|
|
125
|
+
RECEIPT_CAPTURE_ACTION_SX,
|
|
126
|
+
RECEIPT_CAPTURE_ALERT_SX,
|
|
127
|
+
RECEIPT_CAPTURE_CARD_SX,
|
|
128
|
+
RECEIPT_CAPTURE_PROGRESS_SX,
|
|
129
|
+
RECEIPT_PICKER_ACTION_SX,
|
|
130
|
+
RECEIPT_PICKER_CARD_SX,
|
|
131
|
+
RECEIPT_REVIEW_ACTIONS_SX,
|
|
132
|
+
RECEIPT_REVIEW_ACTION_BUTTON_SX,
|
|
133
|
+
RECEIPT_REVIEW_SHEET_PAPER_SX,
|
|
119
134
|
USER_INVITE_ACTION_SX,
|
|
120
135
|
USER_INVITE_ALERT_SX,
|
|
121
136
|
USER_LIST_ACTION_SX,
|
|
@@ -223,4 +238,15 @@ export const KIT_COMPONENT_SX_REGISTRY = [
|
|
|
223
238
|
entry('SubscriptionSection.alert', 'MuiAlert', SUBSCRIPTION_SECTION_ALERT_SX),
|
|
224
239
|
entry('WithdrawalDialog.alert', 'MuiAlert', WITHDRAWAL_DIALOG_ALERT_SX),
|
|
225
240
|
entry('WithdrawalSection.alert', 'MuiAlert', WITHDRAWAL_SECTION_ALERT_SX),
|
|
241
|
+
entry('ReceiptConsentSwitch.alert', 'MuiAlert', RECEIPT_CONSENT_ALERT_SX),
|
|
242
|
+
entry('ReceiptConsentSwitch.control', 'MuiFormControlLabel', RECEIPT_CONSENT_CONTROL_SX),
|
|
243
|
+
entry('ReceiptCaptureCard.card', 'MuiPaper', RECEIPT_CAPTURE_CARD_SX),
|
|
244
|
+
entry('ReceiptCaptureCard.alert', 'MuiAlert', RECEIPT_CAPTURE_ALERT_SX),
|
|
245
|
+
entry('ReceiptCaptureCard.action', 'MuiButton', RECEIPT_CAPTURE_ACTION_SX),
|
|
246
|
+
entry('ReceiptCaptureCard.progress', 'MuiLinearProgress', RECEIPT_CAPTURE_PROGRESS_SX),
|
|
247
|
+
entry('ReceiptPicker.card', 'MuiCard', RECEIPT_PICKER_CARD_SX),
|
|
248
|
+
entry('ReceiptPicker.actions', 'MuiDialogActions', RECEIPT_PICKER_ACTION_SX),
|
|
249
|
+
entry('ReceiptReviewDialog.actions', 'MuiDialogActions', RECEIPT_REVIEW_ACTIONS_SX),
|
|
250
|
+
entry('ReceiptReviewDialog.sheetPaper', 'MuiPaper', RECEIPT_REVIEW_SHEET_PAPER_SX),
|
|
251
|
+
entry('ReceiptReviewDialog.action', 'MuiButton', RECEIPT_REVIEW_ACTION_BUTTON_SX),
|
|
226
252
|
];
|