feat: add inventory CSV import with warehouse/bin locations
- Add warehouse and bin_location columns to Inventory model - Create inventory_import_service for bulk TSV/CSV import - Add POST /api/v1/admin/inventory/import endpoint - Add Import button and modal to inventory admin page - Support both single-unit rows and explicit QUANTITY column File format: BIN, EAN, PRODUCT (optional), QUANTITY (optional) Products matched by GTIN/EAN, unmatched items reported. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,13 @@
|
||||
width='w-80'
|
||||
) }}
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
<button
|
||||
@click="showImportModal = true"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
@@ -456,6 +463,139 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Import Inventory Modal -->
|
||||
{% call modal_simple('importModal', 'Import Inventory', show_var='showImportModal', size='md') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Upload a TSV or CSV file to import inventory. Products are matched by EAN/GTIN.
|
||||
</p>
|
||||
|
||||
<!-- File Format Info -->
|
||||
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm">
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2">File Format:</p>
|
||||
<p class="text-blue-700 dark:text-blue-300 text-xs font-mono mb-1">BIN EAN PRODUCT QUANTITY</p>
|
||||
<p class="text-blue-600 dark:text-blue-400 text-xs">
|
||||
<strong>Required:</strong> BIN, EAN<br>
|
||||
<strong>Optional:</strong> PRODUCT (display only), QUANTITY (defaults to 1 per row)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="executeImport()">
|
||||
<!-- Vendor Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Vendor <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.vendor_id"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<template x-for="vendor in vendorsList" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Warehouse -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Warehouse
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="importForm.warehouse"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="strassen"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
File <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
@change="importForm.file = $event.target.files[0]"
|
||||
accept=".tsv,.csv,.txt"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 file:mr-4 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-purple-100 file:text-purple-700 dark:file:bg-purple-900 dark:file:text-purple-300 hover:file:bg-purple-200"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Clear Existing Option -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="importForm.clear_existing"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Clear existing inventory for this warehouse before import
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Import Result -->
|
||||
<template x-if="importResult">
|
||||
<div class="p-3 rounded-lg" :class="importResult.success ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'">
|
||||
<p class="font-medium" :class="importResult.success ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'">
|
||||
<span x-text="importResult.success ? 'Import Successful!' : 'Import Failed'"></span>
|
||||
</p>
|
||||
<template x-if="importResult.success">
|
||||
<div class="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
<p>Rows processed: <span x-text="importResult.total_rows"></span></p>
|
||||
<p>Entries created: <span x-text="importResult.entries_created"></span></p>
|
||||
<p>Entries updated: <span x-text="importResult.entries_updated"></span></p>
|
||||
<p>Total quantity: <span x-text="importResult.quantity_imported"></span></p>
|
||||
<template x-if="importResult.unmatched_gtins?.length > 0">
|
||||
<div class="mt-2">
|
||||
<p class="font-medium text-orange-600 dark:text-orange-400">
|
||||
Unmatched GTINs (<span x-text="importResult.unmatched_gtins.length"></span>):
|
||||
</p>
|
||||
<ul class="text-xs mt-1 max-h-32 overflow-y-auto">
|
||||
<template x-for="item in importResult.unmatched_gtins" :key="item.gtin">
|
||||
<li class="font-mono" x-text="item.gtin + ' (' + item.quantity + ' units) - ' + item.product_name"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!importResult.success && importResult.errors?.length > 0">
|
||||
<ul class="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
<template x-for="error in importResult.errors" :key="error">
|
||||
<li x-text="error"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeImportModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span x-text="importResult?.success ? 'Close' : 'Cancel'"></span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.vendor_id || !importForm.file"
|
||||
x-show="!importResult?.success"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-text="importing ? 'Importing...' : 'Import'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
|
||||
Reference in New Issue
Block a user