diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8c794175..a16e43e3f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - '5.x' + - '5.4' pull_request: permissions: contents: read diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 0000000000..ba35cdee61 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1 @@ +# Release Notes for Craft Commerce 5.4 (WIP) \ No newline at end of file diff --git a/composer.json b/composer.json index 424ae5b760..cc5aba220b 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "dompdf/dompdf": "^2.0.2", "ibericode/vat": "^1.2.2", "iio/libmergepdf": "^4.0", + "league/csv": "^9.22", "moneyphp/money": "^4.2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index ca12f99341..4c86215434 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff78ccead3f15c9a900dafb12295cbee", + "content-hash": "b8570e371d56217b8d4ab54f89bbfd08", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1893,6 +1893,97 @@ ], "time": "2024-09-24T20:45:14+00:00" }, + { + "name": "league/csv", + "version": "9.22.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "afc109aa11f3086b8be8dfffa04ac31480b36b76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/afc109aa11f3086b8be8dfffa04ac31480b36b76", + "reference": "afc109aa11f3086b8be8dfffa04ac31480b36b76", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-xdebug": "*", + "friendsofphp/php-cs-fixer": "^3.69.0", + "phpbench/phpbench": "^1.4.0", + "phpstan/phpstan": "^1.12.18", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.2", + "phpunit/phpunit": "^10.5.16 || ^11.5.7", + "symfony/var-dumper": "^6.4.8 || ^7.2.3" + }, + "suggest": { + "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters", + "ext-mbstring": "Needed to ease transcoding CSV using mb stream filters", + "ext-mysqli": "Requiered to use the package with the MySQLi extension", + "ext-pdo": "Required to use the package with the PDO extension", + "ext-pgsql": "Requiered to use the package with the PgSQL extension", + "ext-sqlite3": "Required to use the package with the SQLite3 extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Csv\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "CSV data manipulation made easy in PHP", + "homepage": "https://csv.thephpleague.com", + "keywords": [ + "convert", + "csv", + "export", + "filter", + "import", + "read", + "transform", + "write" + ], + "support": { + "docs": "https://csv.thephpleague.com", + "issues": "https://github.com/thephpleague/csv/issues", + "rss": "https://github.com/thephpleague/csv/releases.atom", + "source": "https://github.com/thephpleague/csv" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-02-28T10:00:39+00:00" + }, { "name": "league/uri", "version": "7.5.1", diff --git a/src/controllers/InventoryController.php b/src/controllers/InventoryController.php index f38bf56497..9f6951c32e 100644 --- a/src/controllers/InventoryController.php +++ b/src/controllers/InventoryController.php @@ -27,6 +27,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Html; +use craft\helpers\UrlHelper; use craft\web\assets\htmx\HtmxAsset; use craft\web\Controller; use craft\web\CpScreenResponseBehavior; @@ -197,12 +198,41 @@ public function actionEditLocationLevels(?string $inventoryLocationHandle = null ]; } + $importBtnId = sprintf("import-%s", mt_rand()); + $items['import'] = [ + 'type' => MenuItemType::Button, + 'id' => $importBtnId, + 'icon' => 'arrow-down', + 'label' => Craft::t('commerce', 'Import Inventory'), + ]; + + // make import button open a slideout + $view->registerJsWithVars(fn($id, $settings) => << { + e.preventDefault(); + const slideout = new Craft.CpScreenSlideout('commerce/inventory-importexport', $settings); +}); +JS, [ + $view->namespaceInputId($importBtnId), + [], + ]); + + $exportBtnId = sprintf("export-%s", mt_rand()); + $items['export'] = [ + 'type' => MenuItemType::Link, + 'id' => $exportBtnId, + 'url' => UrlHelper::actionUrl('commerce/inventory-importexport/export', ['inventoryLocationId' => $currentLocation->id]), + 'icon' => 'arrow-up', + 'label' => Craft::t('commerce', 'Export Inventory'), + ]; + return $this->asCpScreen() ->title($title) ->site(Cp::requestedSite()) ->selectableSites(Craft::$app->getSites()->getEditableSites()) ->action(null) ->crumbs($crumbs) + ->actionMenuItems(fn() => $items) ->contentTemplate('commerce/inventory/levels/_index', compact( 'inventoryLocations', 'currentLocation', @@ -532,7 +562,7 @@ public function actionUpdateLevels(): Response } if (count($errors) > 0) { - return $this->asFailure(Craft::t('commerce', 'Inventory was not updated.',), + return $this->asFailure(Craft::t('commerce', 'Inventory was not updated.'), ['errors' => $errors] ); } diff --git a/src/controllers/InventoryImportexportController.php b/src/controllers/InventoryImportexportController.php new file mode 100644 index 0000000000..2474a4760f --- /dev/null +++ b/src/controllers/InventoryImportexportController.php @@ -0,0 +1,203 @@ +_importScreen() + ->prepareScreen(function(Response $response, string $containerId) { + /** @var CpScreenResponseBehavior $response */ + $view = Craft::$app->getView(); + $view->registerJsWithVars( + fn($containerId) => <<contentTemplate('commerce/inventory/importexport/_importScreen', $params); + } + + public function actionImportInventory() + { + $this->requirePostRequest(); + $this->requirePermission('commerce-manageInventoryStockLevels'); + + $tempFilename = Craft::$app->getRequest()->getBodyParam('importFilename'); + if (!$tempFilename) { + return $this->asFailure(Craft::t('commerce', 'No file specified.')); + } + + $tempDirectory = Craft::$app->getPath()->getTempPath() . '/commerce-inventory-import'; + $tempFilePath = $tempDirectory . '/' . $tempFilename; + + if (!file_exists($tempFilePath)) { + return $this->asFailure(Craft::t('commerce', 'File not found.')); + } + + // Create and queue the import job + $jobId = Craft::$app->getQueue()->push(new ImportInventory([ + 'description' => Craft::t('commerce', 'Import inventory from CSV'), + 'filePath' => $tempFilePath, + ])); + + $message = Craft::t('commerce', 'Inventory import has been queued.'); + return $this->asSuccess($message); + } + + private function _importScreen() + { + // Register Commerce CP Assets + $this->view->registerAssetBundle(CommerceCpAsset::class); + + return $this->asCpScreen() + ->action('commerce/inventory-importexport/import-inventory') + ->addCrumb(Craft::t('commerce', 'Inventory'), 'commerce/inventory') + ->selectedSubnavItem('inventory') + ->redirectUrl('commerce/inventory') + ->title(Craft::t('commerce', 'Import Inventory')) + ->formAttributes(['enctype' => 'multipart/form-data', 'accept-charset' => 'UTF-8']) + ->metaSidebarTemplate('commerce/inventory/importexport/_importMeta') + ->submitButtonLabel(Craft::t('commerce', 'Import')); + } + + /** + * @return Response + * @throws InvalidConfigException + * @throws \yii\web\ForbiddenHttpException + * @throws \yii\web\HttpException + * @throws \yii\web\RangeNotSatisfiableHttpException + */ + public function actionExport(): Response + { + $this->requirePermission('commerce-manageInventoryStockLevels'); + + $inventoryLocationId = (int)Craft::$app->getRequest()->getParam('inventoryLocationId'); + $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); + + $inventoryQuery = Plugin::getInstance()->getInventory()->getInventoryLevelQuery() + ->andWhere(['inventoryLocationId' => $inventoryLocation->id]); + + $inventoryQuery->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[ii.purchasableId]]'); + $inventoryQuery->addSelect(['sku' => 'purchasables.sku']); + $inventoryQuery->addSelect(['description' => 'purchasables.description']); + + $response = Craft::$app->getResponse(); + $response->headers->set('Content-Type', 'text/csv'); + $response->headers->set('Content-Disposition', 'attachment; filename="inventory.csv"'); + $response->format = \yii\web\Response::FORMAT_RAW; + + // Open output stream + $stream = fopen('php://output', 'w'); + + // Create a CSV writer instance + $csv = Writer::createFromStream($stream); + + $csv->insertOne(['location', 'item', 'description', 'action', 'amount', 'notes']); + + foreach ($inventoryQuery->each() as $row) { + $data = [ + $row['inventoryLocationId'], + $row['sku'], + $row['description'], + 'set', + $row['onHandTotal'], + '', + ]; + $csv->insertOne($data); + } + + // Close the stream (optional, but good practice) + fclose($stream); + + // End the response to prevent further output + Craft::$app->end(); + } + + public function actionExampleTemplate() + { + // return csv example template + $this->requirePermission('commerce-manageInventoryStockLevels'); + + $csvFile = Writer::createFromString('location,item,action,amount,notes'); + + return Craft::$app->getResponse()->sendContentAsFile( + $csvFile->toString(), + Craft::t('commerce', 'inventory-import-template') . '.csv', + ['mimeType' => 'text/csv'] + ); + } + + /** + * Upload a file to a temporary directory + * + * @return Response + */ + public function actionUploadTempFile(): Response + { + $this->requirePostRequest(); + $this->requirePermission('commerce-manageInventoryStockLevels'); + $this->requireAcceptsJson(); + + $file = UploadedFile::getInstanceByName('file'); + + if (!$file) { + return $this->asFailure(Craft::t('commerce', 'No file uploaded')); + } + + // Check file type + if ($file->extension !== 'csv') { + return $this->asFailure(Craft::t('commerce', 'Only CSV files are allowed')); + } + + // Create temp directory if it doesn't exist + $tempDirectory = Craft::$app->getPath()->getTempPath() . '/commerce-inventory-import'; + if (!is_dir($tempDirectory)) { + mkdir($tempDirectory, 0777, true); + } + + // Generate unique filename + $filename = 'inventory-import-' . uniqid() . '.csv'; + $tempPath = $tempDirectory . '/' . $filename; + + // Save the file + if (!$file->saveAs($tempPath)) { + return $this->asFailure(Craft::t('commerce', 'Could not save the file')); + } + + return $this->asJson([ + 'success' => true, + 'filename' => $filename, + ]); + } +} diff --git a/src/elements/traits/OrderValidatorsTrait.php b/src/elements/traits/OrderValidatorsTrait.php index 747be145f3..9701d4c1a8 100644 --- a/src/elements/traits/OrderValidatorsTrait.php +++ b/src/elements/traits/OrderValidatorsTrait.php @@ -83,7 +83,7 @@ public function validateAddress(string $attribute): void // Set live scenario for addresses to match CP $address?->setScenario(Address::SCENARIO_LIVE); - if ($address && !$address->validate()) { + if ($address && (!$address->validate())) { $this->addModelErrors($address, $attribute); } diff --git a/src/plugin/Routes.php b/src/plugin/Routes.php index 7361d6bd6a..7cf2b4586e 100644 --- a/src/plugin/Routes.php +++ b/src/plugin/Routes.php @@ -161,6 +161,7 @@ private function _registerCpRoutes(): void // Inventory $event->rules['commerce/inventory'] = 'commerce/inventory/edit-location-levels'; // redirect to the first location $event->rules['commerce/inventory/levels'] = 'commerce/inventory/edit-location-levels'; // redirect to the first location + $event->rules['commerce/inventory/import'] = 'commerce/inventory-importexport/index'; $event->rules['commerce/inventory/item/'] = 'commerce/inventory/item-edit'; $event->rules['commerce/inventory/levels/'] = 'commerce/inventory/edit-location-levels'; diff --git a/src/queue/jobs/ImportInventory.php b/src/queue/jobs/ImportInventory.php new file mode 100644 index 0000000000..10598ad9f7 --- /dev/null +++ b/src/queue/jobs/ImportInventory.php @@ -0,0 +1,146 @@ + + * @since 5.4 + */ +class ImportInventory extends BaseJob +{ + /** + * @var string The path to the temporary file + */ + public string $filePath; + + /** + * @inheritDoc + */ + public function execute($queue): void + { + $this->setProgress($queue, 0.1); + + // Verify file exists + if (!file_exists($this->filePath)) { + throw new Exception('File not found: ' . $this->filePath); + } + + // Check CSV file is OK and has required headers + try { + $csv = Reader::createFromPath($this->filePath); + $csv->setHeaderOffset(0); + $csv->setEscape(''); // required in PHP8.4+ to avoid deprecation notices + } catch (\Exception $e) { + throw new Exception('Invalid CSV file: ' . $e->getMessage()); + } + + // Check required headers are all there + $headers = $csv->getHeader(); + if (!in_array('location', $headers) || !in_array('item', $headers) || !in_array('action', $headers) || !in_array('amount', $headers)) { + throw new Exception('Invalid CSV file. Missing required headers.'); + } + + $this->setProgress($queue, 0.2); + + $inventoryService = Plugin::getInstance()->getInventory(); + $inventoryLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); + $updateInventoryLevels = UpdateInventoryLevelCollection::make(); + $errors = []; + $processedRecords = 0; + $totalRecords = $csv->count(); + + foreach ($csv->getRecords() as $key => $record) { + $inventoryLocation = null; + if (is_numeric($record['location'])) { + $inventoryLocation = $inventoryLocations->firstWhere('id', $record['location']); + } else { + $inventoryLocation = $inventoryLocations->firstWhere('handle', $record['location']); + } + + if (!$inventoryLocation) { + $errors[$key][] = Craft::t('commerce', 'Invalid location: {error}', ['error' => $record['location']]); + continue; + } + + $item = null; + if (is_numeric($record['item'])) { + $item = $inventoryService->getInventoryItemById($record['item']); + } else { + $item = $inventoryService->getInventoryItemBySku($record['item']); + } + + if ($item === null) { + $errors[$key][] = Craft::t('commerce', 'Invalid item: {error}', ['error' => $record['item']]); + continue; + } + + $updateAction = $record['action']; + + if (!in_array($updateAction, ['set', 'adjust'])) { + $errors[$key][] = Craft::t('commerce', 'Invalid action type: {error}', ['error' => $updateAction]); + continue; + } + + $amount = $record['amount']; + + if (!is_numeric($amount)) { + $error = $record['amount'] ?: Craft::t('commerce', 'Missing'); + $errors[$key][] = Craft::t('commerce', 'Invalid amount: {error}', ['error' => $error]); + continue; + } + + $notes = $record['notes'] ?? ''; + + // if no errors for this row, add it to collection + if (empty($errors[$key])) { + $update = new UpdateInventoryLevel(); + $update->inventoryLocationId = $inventoryLocation->id; + $update->inventoryItemId = $item->id; + $update->quantity = $amount; + $update->note = $notes; + $update->updateAction = InventoryUpdateQuantityType::from($updateAction); + $update->type = InventoryTransactionType::AVAILABLE->value; + $updateInventoryLevels->add($update); + } + + $processedRecords++; + $this->setProgress($queue, 0.2 + (0.7 * ($processedRecords / $totalRecords))); + } + + if ($errors) { + Craft::error('Inventory import had errors: ' . json_encode($errors), __METHOD__); + } + + $inventoryService->executeUpdateInventoryLevels($updateInventoryLevels); + + @unlink($this->filePath); + + $this->setProgress($queue, 1); + } + + /** + * @inheritDoc + */ + protected function defaultDescription(): ?string + { + return Craft::t('commerce', 'Importing inventory data'); + } +} diff --git a/src/services/Inventory.php b/src/services/Inventory.php index a073448501..90687fbccd 100644 --- a/src/services/Inventory.php +++ b/src/services/Inventory.php @@ -94,6 +94,31 @@ public function getInventoryItemById(int $id): InventoryItem return $this->_populateInventoryItem($inventoryItem); } + /** + * @param string $sku + * @return ?InventoryItem + */ + public function getInventoryItemBySku(string $sku): ?InventoryItem + { + $subQuery = (new Query()) + ->select([ + 'items.id', + ]) + ->where(['purchasables.sku' => $sku]) + ->from(['purchasables' => Table::PURCHASABLES]) + ->leftJoin(['items' => Table::INVENTORYITEMS], '[[purchasables.id]] = [[items.purchasableId]]'); + + $inventoryItem = $this->getInventoryItemQuery() + ->where(['id' => $subQuery]) + ->one(); + + if (!$inventoryItem) { + return null; + } + + return $this->_populateInventoryItem($inventoryItem); + } + /** * @param array $ids * @return Collection diff --git a/src/templates/inventory/importexport/_importMeta.twig b/src/templates/inventory/importexport/_importMeta.twig new file mode 100644 index 0000000000..cb26ad1ca6 --- /dev/null +++ b/src/templates/inventory/importexport/_importMeta.twig @@ -0,0 +1,11 @@ +{% import '_includes/forms' as forms %} + +
+
+
{{ "Import Template"|t('commerce') }}
+
+ {{ "inventory-import-template"|t('commerce') }}.csv +
+
+
+ diff --git a/src/templates/inventory/importexport/_importScreen.twig b/src/templates/inventory/importexport/_importScreen.twig new file mode 100644 index 0000000000..cab4b39ed5 --- /dev/null +++ b/src/templates/inventory/importexport/_importScreen.twig @@ -0,0 +1,60 @@ +{% import '_includes/forms' as forms %} + +{% css %} +.upload-container { + margin-bottom: 24px; +} + +.upload-container .upload-prompt { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.upload-container .upload-prompt .spinner { + margin-left: 10px; +} + +.upload-container .file-info { + display: none; + background: #f3f7fc; + padding: 10px; + border-radius: 3px; + margin-top: 10px; +} + +.upload-container.has-file .file-info { + display: flex; +} + +.upload-container .file-info .file-name { + flex-grow: 1; +} + +.upload-container .file-info .remove-file { + color: #da5a47; +} +{% endcss %} + +
+ {# Hidden input to store the filename #} + + +
+
+ +
+ + +
+
+ +{% if errors is defined %} +{% for line, lineErrors in errors %} +
{{ "Errors on Line {line}:"|t('commerce', {line: line}) }}
+ {{ forms.errorList(lineErrors) }} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index ad37936c20..c0cd6f37f8 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -465,6 +465,7 @@ 'Expiry Date' => 'Expiry Date', 'Expiry date' => 'Expiry date', 'Expiry' => 'Expiry', + 'Export Inventory' => 'Export Inventory', 'Failed to receive transfer: {error}' => 'Failed to receive transfer: {error}', 'Failed to start' => 'Failed to start', 'Failed to update {num, plural, =1{order status} other{order statuses}}.' => 'Failed to update {num, plural, =1{order status} other{order statuses}}.', @@ -537,6 +538,8 @@ 'Ignore Promotions?' => 'Ignore Promotions?', 'Ignore previous matching sales if this sale matches.' => 'Ignore previous matching sales if this sale matches.', 'Ignore promotional prices when this discount is applied to matching line items' => 'Ignore promotional prices when this discount is applied to matching line items', + 'Import could not begin due to errors.' => 'Import could not begin due to errors.', + 'Import Inventory' => 'Import Inventory', 'Inactive Carts' => 'Inactive Carts', 'Inches (in)' => 'Inches (in)', 'Include built-in line item tax.' => 'Include built-in line item tax.', @@ -551,6 +554,7 @@ 'Info' => 'Info', 'Information linked?' => 'Information linked?', 'Information' => 'Information', + 'Invalid action type: {error}' => 'Invalid action type: {error}', 'Invalid JSON' => 'Invalid JSON', 'Invalid Order ID' => 'Invalid Order ID', 'Invalid VAT ID.' => 'Invalid VAT ID.', @@ -559,6 +563,8 @@ 'Invalid formula syntax' => 'Invalid formula syntax', 'Invalid gateway: {value}' => 'Invalid gateway: {value}', 'Invalid inventory movements.' => 'Invalid inventory movements.', + 'Invalid item: {error}' => 'Invalid item: {error}', + 'Invalid location: {error}' => 'Invalid location: {error}', 'Invalid order condition syntax.' => 'Invalid order condition syntax.', 'Invalid payment or order. Please review.' => 'Invalid payment or order. Please review.', 'Invalid payment source ID: {value}' => 'Invalid payment source ID: {value}', @@ -666,6 +672,7 @@ 'Minimum allowed quantity' => 'Minimum allowed quantity', 'Minimum number of matching items that need to be ordered for this discount to apply.' => 'Minimum number of matching items that need to be ordered for this discount to apply.', 'Minimum order quantity for this item is {num}.' => 'Minimum order quantity for this item is {num}.', + 'Missing' => 'Missing', 'Missing Gateway' => 'Missing Gateway', 'Missing a default inventory location.' => 'Missing a default inventory location.', 'Move Inventory' => 'Move Inventory', @@ -1336,6 +1343,7 @@ 'donation' => 'donation', 'donations' => 'donations', 'info' => 'info', + 'Invalid amount: {error}' => 'Invalid amount: {error}', 'inventory location' => 'inventory location', 'new customers' => 'new customers', 'on hand' => 'on hand', diff --git a/src/web/assets/commercecp/dist/commercecp.js b/src/web/assets/commercecp/dist/commercecp.js index 66c796a9c4..fea9571d7a 100644 --- a/src/web/assets/commercecp/dist/commercecp.js +++ b/src/web/assets/commercecp/dist/commercecp.js @@ -1,2 +1,2 @@ -!function(){var t={901:function(){function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}!function(e){"undefined"===t(Craft.Commerce)&&(Craft.Commerce={}),Craft.Commerce.initUnlimitedStockCheckbox=function(t){t.find("input.unlimited-stock:first").change(Craft.Commerce.handleUnlimitedStockCheckboxChange)},Craft.Commerce.handleUnlimitedStockCheckboxChange=function(t){var n=e(t.currentTarget),a=n.parent().prevAll(".textwrapper:first").children(".text:first");n.prop("checked")?a.prop("disabled",!0).addClass("disabled").val(""):a.prop("disabled",!1).removeClass("disabled").focus()}}(jQuery)},297:function(){function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}"undefined"===t(Craft.Commerce)&&(Craft.Commerce={}),Craft.Commerce.OrderEdit=Garnish.Base.extend({orderId:null,paymentForm:null,paymentAmount:null,paymentCurrency:null,$makePayment:null,init:function(t){this.setSettings(t),this.orderId=this.settings.orderId,this.paymentForm=this.settings.paymentForm,this.paymentAmount=this.settings.paymentAmount,this.paymentCurrency=this.settings.paymentCurrency,this.$makePayment=$("#make-payment"),this.addListener(this.$makePayment,"click","makePayment"),Object.keys(this.paymentForm.errors).length>0&&this.openPaymentModal()},openPaymentModal:function(){this.paymentModal?this.paymentModal.show():this.paymentModal=new Craft.Commerce.PaymentModal({orderId:this.orderId,paymentForm:this.paymentForm,paymentAmount:this.paymentAmount,paymentCurrency:this.paymentCurrency})},makePayment:function(t){t.preventDefault(),this.openPaymentModal()},_getCountries:function(){return window.countries}},{defaults:{orderId:null,paymentForm:null,paymentAmount:null,paymentCurrency:null,$makePayment:null}})},81:function(){function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}"undefined"===t(Craft.Commerce)&&(Craft.Commerce={}),Craft.Commerce.OrderIndex=Craft.BaseElementIndex.extend({startDate:null,endDate:null,init:function(t,e,n){if(this.on("selectSource",$.proxy(this,"updateSelectedSource")),this.base(t,e,n),Craft.ui.createDateRangePicker({onChange:function(t,e){this.startDate=t,this.endDate=e,this.updateElements()}.bind(this)}).appendTo(this.$toolbar),window.orderEdit&&window.orderEdit.currentUserPermissions["commerce-editOrders"]){var a=$("",{type:"button",class:"btn submit icon add",href:Craft.getUrl("commerce/orders/"+this.settings.store.handle+"/create"),text:Craft.t("commerce","New Order")});this.addButton(a)}},updateSelectedSource:function(){var t="all"!==(this.$source?this.$source:"all")?this.$source.data("handle"):null;if("index"===this.settings.context&&"undefined"!=typeof history){var e="commerce/orders";t&&(e+="/"+t),history.replaceState({},"",Craft.getUrl(e))}},getDefaultSourceKey:function(){var t=window.defaultStatusHandle;if(t)for(var e=0;e="+this.startDate.getTime()/1e3),this.endDate&&t.criteria[e].push("<"+(this.endDate.getTime()/1e3+86400))}return this.siteId&&(t.criteria.storeId=Craft.Commerce.sitesStores[this.siteId]),t},updateSourcesBadgeCounts:function(){$.ajax({url:Craft.getActionUrl("commerce/orders/get-index-sources-badge-counts"),type:"GET",dataType:"json",success:function(t){if(t.counts){var e=this.$sidebar;$.each(t.counts,(function(t,n){var a=e.find('nav a[data-key="*/orderStatus:'+n.handle+'"]');if(a){var s=a.find(".badge");if(0===n.orderCount)return void(s.length&&s.remove());s.length||(s=$('').appendTo(a)),s.text(n.orderCount)}}))}if(t.total){var n=this.$sidebar.find('nav a[data-key="*"]');if(n){var a=n.find(".badge");if(a.length||(a=$('').appendTo(n)),0===t.total)return void(a.length&&a.remove());a.length||(a=$('').appendTo(n)),a.text(t.total)}}}.bind(this)})},setIndexAvailable:function(){this.updateSourcesBadgeCounts(),this.base()}}),Craft.registerElementIndexClass("craft\\commerce\\elements\\Order",Craft.Commerce.OrderIndex)},10:function(){function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}"undefined"===t(Craft.Commerce)&&(Craft.Commerce={}),Craft.Commerce.PaymentModal=Garnish.Modal.extend({$container:null,$body:null,init:function(t){var e=this;this.$container=$("
",{id:"paymentmodal",class:"modal fitted loading"}).appendTo(Garnish.$bod),this.base(this.$container,$.extend({resizable:!1},t));var n={orderId:t.orderId,paymentForm:t.paymentForm,paymentAmount:t.paymentAmount,paymentCurrency:t.paymentCurrency};Craft.sendActionRequest("POST","commerce/orders/get-payment-modal",{data:n}).then((function(t){e.$container.removeClass("loading");var n=e;e.$container.append(t.data.modalHtml),Craft.appendHeadHtml(t.data.headHtml),Craft.appendBodyHtml(t.data.footHtml);var a=$(".buttons",e.$container),s=$('
'+Craft.t("commerce","Cancel")+"
").prependTo(a);e.addListener(s,"click","cancelPayment"),$("select#payment-form-select").change($.proxy((function(t){var e=$(t.currentTarget).val();$(".gateway-form").addClass("hidden"),$("#gateway-"+e+"-form").removeClass("hidden"),setTimeout((function(){Craft.initUiElements(this.$container),n.updateSizeAndPosition()}),200)}),e)).trigger("change"),setTimeout((function(){Craft.initUiElements(this.$container),n.updateSizeAndPosition()}),200)})).catch((function(t){var n=t.response;e.$container.removeClass("loading");var a=Craft.t("commerce","An unknown error occurred.");n.data.message&&(a=n.data.message),e.$container.append('
'+a+"
")}))},cancelPayment:function(){this.hide()}},{})},515:function(){function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}"undefined"===t(Craft.Commerce)&&(Craft.Commerce={}),Craft.Commerce.ProductSalesModal=Garnish.Modal.extend({id:null,$newSale:null,$cancelBtn:null,$select:null,$saveBtn:null,$spinner:null,$purchasableCheckboxes:[],init:function(t,e){this.id=Math.floor(1e9*Math.random()),this.setSettings(e,this.defaults),this.$form=$('