initial: bootstrap from BukidBountyApp base
This commit is contained in:
774
resources/js/Pages/BatchAddProducts.vue
Normal file
774
resources/js/Pages/BatchAddProducts.vue
Normal file
@@ -0,0 +1,774 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { useModal } from '../composables/Core/useModal'
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle'
|
||||
import { useAuth } from '../composables/Core/useAuth'
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
||||
import { useFileUpload } from '../composables/useFileUpload.js'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
const { isStoreOwner } = useAuth()
|
||||
const { uploadFile } = useFileUpload({ category: 'ProductMarket' })
|
||||
usePageTitle('Batch Add Products')
|
||||
|
||||
const handleLeafPhoto = async (index, event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
products.value[index].photoUploading = true
|
||||
const result = await uploadFile(file)
|
||||
products.value[index].photoUploading = false
|
||||
if (result?.hashkey) products.value[index].photoHash = result.hashkey
|
||||
}
|
||||
|
||||
const removeLeafPhoto = (index) => {
|
||||
products.value[index].photoHash = ''
|
||||
}
|
||||
|
||||
const downloadingTemplate = ref(false)
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
downloadingTemplate.value = true
|
||||
try {
|
||||
const response = await axios.get('/admin/batch/products/template', {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(new Blob([response.data]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'bukidbounty-batch-products-template.xlsx'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
modal.open({ title: 'Error', body: 'Failed to download template. Please try again.' })
|
||||
} finally {
|
||||
downloadingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const makeLeaf = () => ({
|
||||
source: 'new',
|
||||
product_hash: '',
|
||||
linked: null,
|
||||
name: '',
|
||||
price: 0,
|
||||
available: 0,
|
||||
unitname: 'pcs',
|
||||
description: '',
|
||||
category: '',
|
||||
subcategory: '',
|
||||
barcode: '',
|
||||
photoHash: '',
|
||||
photoUploading: false,
|
||||
})
|
||||
|
||||
const products = ref([makeLeaf()])
|
||||
const saving = ref(false)
|
||||
const categories = ref([])
|
||||
const selectableStores = ref([])
|
||||
const targetStoreHash = ref('')
|
||||
|
||||
const targetStore = computed(() =>
|
||||
selectableStores.value.find(s => s.hashkey === targetStoreHash.value) || null
|
||||
)
|
||||
|
||||
const addProduct = () => { products.value.push(makeLeaf()) }
|
||||
const removeProduct = (index) => {
|
||||
if (products.value.length > 1) products.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Products/New/Category/Datalist')
|
||||
if (response.data && response.data.success) {
|
||||
categories.value = response.data.categories
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSelectableStores = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Admin/Stores/Selectable')
|
||||
if (response.data && response.data.success) {
|
||||
selectableStores.value = response.data.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading stores:', err)
|
||||
}
|
||||
|
||||
// Store owners must have an existing store before importing. Without
|
||||
// one the backend rejects every row, so block the page and offer to
|
||||
// create a store instead of letting them fill the form for nothing.
|
||||
if (isStoreOwner.value && selectableStores.value.length === 0) {
|
||||
modal.yesNoModal({
|
||||
title: 'No store found',
|
||||
body: 'You need to create a store before importing products.',
|
||||
yesText: 'Create Store',
|
||||
onYes: () => navigate({ page: 'CreateStore' }),
|
||||
noText: 'Cancel',
|
||||
onNo: () => navigate({ page: 'Home' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fuzzy picker modal -----------------------------------------------
|
||||
const showPickerModal = ref(false)
|
||||
const pickerLeafIndex = ref(-1)
|
||||
const pickerQuery = ref('')
|
||||
const pickerResults = ref([])
|
||||
const pickerSearching = ref(false)
|
||||
let pickerDebounce = null
|
||||
|
||||
const openPicker = (index) => {
|
||||
pickerLeafIndex.value = index
|
||||
pickerQuery.value = products.value[index].name || ''
|
||||
pickerResults.value = []
|
||||
showPickerModal.value = true
|
||||
if (pickerQuery.value.trim().length >= 2) runPickerSearch({ warnIfEmpty: true })
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
showPickerModal.value = false
|
||||
pickerLeafIndex.value = -1
|
||||
pickerQuery.value = ''
|
||||
pickerResults.value = []
|
||||
}
|
||||
|
||||
const onPickerQueryInput = () => {
|
||||
if (pickerDebounce) clearTimeout(pickerDebounce)
|
||||
pickerDebounce = setTimeout(runPickerSearch, 250)
|
||||
}
|
||||
|
||||
const runPickerSearch = async ({ warnIfEmpty = false } = {}) => {
|
||||
const q = pickerQuery.value.trim()
|
||||
if (q.length < 2) { pickerResults.value = []; return }
|
||||
pickerSearching.value = true
|
||||
try {
|
||||
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
|
||||
name: q,
|
||||
TargetStore: targetStoreHash.value || '',
|
||||
})
|
||||
pickerResults.value = (data && data.success) ? (data.data || []) : []
|
||||
if (warnIfEmpty && pickerResults.value.length === 0) {
|
||||
closePicker()
|
||||
modal.open({
|
||||
title: 'Warning',
|
||||
body: `No existing global products found matching "${q}". Try a different name, or fill out this card to create a new product.`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fuzzy search failed:', err)
|
||||
pickerResults.value = []
|
||||
} finally {
|
||||
pickerSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectGlobalProduct = (match) => {
|
||||
if (match.already_in_store) return
|
||||
const i = pickerLeafIndex.value
|
||||
if (i < 0) return
|
||||
products.value[i] = {
|
||||
source: 'existing',
|
||||
product_hash: match.hashkey,
|
||||
linked: {
|
||||
name: match.name,
|
||||
price: match.price,
|
||||
unitname: match.unitname,
|
||||
category: match.category,
|
||||
subcategory: match.subcategory,
|
||||
description: match.description,
|
||||
photourl: match.photourl,
|
||||
},
|
||||
name: match.name,
|
||||
price: 0,
|
||||
available: 0,
|
||||
unitname: match.unitname,
|
||||
description: '',
|
||||
category: match.category || '',
|
||||
subcategory: match.subcategory || '',
|
||||
barcode: '',
|
||||
}
|
||||
closePicker()
|
||||
}
|
||||
|
||||
const unlinkLeaf = (index) => {
|
||||
products.value[index] = makeLeaf()
|
||||
}
|
||||
|
||||
// --- Save -------------------------------------------------------------
|
||||
const saveProducts = async () => {
|
||||
const hasExisting = products.value.some(p => p.source === 'existing')
|
||||
if ((hasExisting || isStoreOwner.value) && !targetStoreHash.value) {
|
||||
modal.open({
|
||||
title: 'Pick a Store',
|
||||
body: isStoreOwner.value
|
||||
? 'Select one of your stores at the top of the page before importing.'
|
||||
: 'Select a target store at the top of the page to import existing products.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < products.value.length; i++) {
|
||||
const p = products.value[i]
|
||||
if (p.source === 'existing') {
|
||||
if (!p.product_hash) {
|
||||
modal.open({ title: 'Validation Error', body: `Row ${i + 1}: existing product link missing.` })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!p.name || p.price < 0 || p.available < 0 || !p.unitname) {
|
||||
modal.open({
|
||||
title: 'Validation Error',
|
||||
body: `Row ${i + 1}: fill in Name, Price, Stock, and Unit.`
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
target_store_hash: targetStoreHash.value || null,
|
||||
products: products.value.map(p => p.source === 'existing'
|
||||
? {
|
||||
source: 'existing',
|
||||
product_hash: p.product_hash,
|
||||
price: p.price,
|
||||
available: p.available,
|
||||
description: p.description,
|
||||
}
|
||||
: {
|
||||
source: 'new',
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
available: p.available,
|
||||
unitname: p.unitname,
|
||||
description: p.description,
|
||||
category: p.category,
|
||||
subcategory: p.subcategory,
|
||||
barcode: p.barcode,
|
||||
photourl: p.photoHash ? [p.photoHash] : [],
|
||||
}),
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/batch/products', payload)
|
||||
if (response.data && response.data.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: `Successfully added ${response.data.count} products.`,
|
||||
onClose: () => navigate({ page: 'ManageProductsAdmin' })
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving batch products:', err)
|
||||
const errorMessage = err.response?.data?.errors
|
||||
? err.response.data.errors.join('<br>')
|
||||
: (err.response?.data?.message || 'Failed to save products.')
|
||||
modal.open({ title: 'Error', body: errorMessage })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchSelectableStores()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-add-page min-vh-100 bg-light pb-5">
|
||||
<header class="header-premium text-white py-4 shadow-sm position-relative overflow-hidden mb-4 bg-primary-gradient">
|
||||
<div class="container position-relative z-2">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
|
||||
<div class="d-flex align-items-center gap-4 animate-fade-in">
|
||||
<div class="display-container position-relative bg-white rounded-circle p-3 shadow">
|
||||
<i class="fas fa-boxes-stacked fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="fw-bold text-white mb-0">Batch Add Products</h2>
|
||||
<p class="text-white-50 small text-uppercase ls-wide mt-1">Add multiple products — each leaf is a complete product</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0 w-100 w-md-auto flex-wrap">
|
||||
<BackButton to="ManageProductsAdmin" />
|
||||
<button @click="navigate({ page: 'ManageProductsAdmin' })" class="btn btn-outline-light btn-sm fw-semibold rounded-pill shadow-sm">
|
||||
<i class="fas fa-list me-2"></i> All Products
|
||||
</button>
|
||||
<button @click="downloadTemplate" :disabled="downloadingTemplate" class="btn btn-outline-light btn-sm fw-semibold rounded-pill shadow-sm">
|
||||
<span v-if="downloadingTemplate"><LoadingSpinner size="small" class="me-1" /></span>
|
||||
<span v-else><i class="fas fa-file-excel me-2"></i></span>
|
||||
Template
|
||||
</button>
|
||||
<button @click="saveProducts" :disabled="saving" class="btn btn-light btn-sm fw-semibold shadow-sm">
|
||||
<span v-if="saving"><LoadingSpinner size="small" class="me-2" /> Saving...</span>
|
||||
<span v-else><i class="fas fa-save me-2"></i> Save All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- Template download card -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4 template-card overflow-hidden">
|
||||
<div class="card-body p-0">
|
||||
<div class="d-flex flex-wrap align-items-center gap-4 p-4">
|
||||
<div class="d-flex align-items-center gap-4 flex-grow-1">
|
||||
<div class="template-icon-wrap rounded-3 d-flex align-items-center justify-content-center flex-shrink-0">
|
||||
<i class="fas fa-file-excel fa-2x text-success"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-1">Excel Template</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
Download the pre-formatted Excel template. Fill in product data and add photos using
|
||||
<strong>Insert → Pictures → This Device</strong> in each Photo cell.
|
||||
Then enter your data here using the cards below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="downloadTemplate"
|
||||
:disabled="downloadingTemplate"
|
||||
class="btn btn-success rounded-pill fw-semibold px-4 shadow-sm w-100 w-md-auto"
|
||||
>
|
||||
<span v-if="downloadingTemplate">
|
||||
<LoadingSpinner size="small" class="me-2" />Downloading…
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-download me-2"></i>Download Template
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="template-steps d-flex gap-0 border-top">
|
||||
<div class="step-item flex-fill text-center py-2 px-2 border-end">
|
||||
<div class="fw-bold small text-primary">① Download</div>
|
||||
<div class="smallest text-muted">Get the .xlsx template</div>
|
||||
</div>
|
||||
<div class="step-item flex-fill text-center py-2 px-2 border-end">
|
||||
<div class="fw-bold small text-primary">② Fill in Excel</div>
|
||||
<div class="smallest text-muted">Name, Price, Stock, Unit…</div>
|
||||
</div>
|
||||
<div class="step-item flex-fill text-center py-2 px-2 border-end">
|
||||
<div class="fw-bold small text-primary">③ Add Photos</div>
|
||||
<div class="smallest text-muted">Insert → Pictures per row</div>
|
||||
</div>
|
||||
<div class="step-item flex-fill text-center py-2 px-2">
|
||||
<div class="fw-bold small text-primary">④ Enter here + 📷</div>
|
||||
<div class="smallest text-muted">Use cards below + camera</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-lg rounded-4 bg-white overflow-hidden mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-4 pb-3 border-bottom">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Target Store (optional for new products, required to import existing)</label>
|
||||
<select v-model="targetStoreHash" class="form-select">
|
||||
<option value="">No store — create global products only</option>
|
||||
<option v-for="store in selectableStores" :key="store.hashkey" :value="store.hashkey">
|
||||
{{ store.name }}<span v-if="store.role"> ({{ store.role }})</span>
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-text smallest">
|
||||
When a store is picked, every leaf (new or imported) is also listed in that store with its price, stock, and description.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button @click="addProduct" class="btn btn-primary fw-bold rounded-pill">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Product
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info border-0 rounded-3 small mb-4">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Each card is a product. Use <strong>Pick existing</strong> to import a global product into the target store with your own price/stock/description.
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div
|
||||
v-for="(product, index) in products"
|
||||
:key="index"
|
||||
class="col-md-6 col-lg-4"
|
||||
>
|
||||
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100" :class="{ 'is-imported': product.source === 'existing' }">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
|
||||
<span v-if="product.source === 'existing'" class="badge bg-success-subtle text-success border border-success-subtle">
|
||||
<i class="fas fa-link me-1"></i>Imported
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button
|
||||
v-if="product.source === 'new'"
|
||||
@click="openPicker(index)"
|
||||
class="btn btn-link btn-sm text-primary p-0 fw-semibold text-decoration-none text-nowrap"
|
||||
title="Pick an existing global product"
|
||||
>
|
||||
<i class="fas fa-search me-1"></i> Pick existing
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="unlinkLeaf(index)"
|
||||
class="btn btn-link btn-sm text-secondary p-0 fw-semibold text-decoration-none text-nowrap"
|
||||
title="Unlink and start fresh"
|
||||
>
|
||||
<i class="fas fa-unlink me-1"></i> Unlink
|
||||
</button>
|
||||
<button
|
||||
@click="removeProduct(index)"
|
||||
class="btn btn-link text-danger p-0 border-0"
|
||||
:disabled="products.length <= 1"
|
||||
title="Remove product"
|
||||
>
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="product.source === 'existing'">
|
||||
<div class="mb-2">
|
||||
<div class="fw-bold">{{ product.linked.name }}</div>
|
||||
<div class="text-muted smallest">
|
||||
<span v-if="product.linked.category">{{ product.linked.category }}<span v-if="product.linked.subcategory"> · {{ product.linked.subcategory }}</span> · </span>
|
||||
<span>Global: ₱{{ product.linked.price }} / {{ product.linked.unitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-7">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Store Price</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white text-muted">₱</span>
|
||||
<input
|
||||
v-model.number="product.price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-control fw-bold"
|
||||
:placeholder="`Default ${product.linked.price}`"
|
||||
>
|
||||
</div>
|
||||
<div class="form-text smallest">Leave 0 to use global ₱{{ product.linked.price }}.</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Stock *</label>
|
||||
<input v-model.number="product.available" type="number" class="form-control form-control-sm fw-bold" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label small fw-bold text-muted mb-1">Store Description</label>
|
||||
<input
|
||||
v-model="product.description"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
:placeholder="product.linked.description ? `Default: ${product.linked.description}` : 'Leave blank to use global default'"
|
||||
>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label class="form-label small fw-bold text-muted mb-1">Product Name *</label>
|
||||
<input
|
||||
v-model="product.name"
|
||||
type="text"
|
||||
class="form-control form-control-sm fw-bold mb-2"
|
||||
placeholder="e.g. Banana"
|
||||
>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-7">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Price *</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white text-muted">₱</span>
|
||||
<input v-model.number="product.price" type="number" step="0.01" class="form-control fw-bold" placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Stock *</label>
|
||||
<input v-model.number="product.available" type="number" class="form-control form-control-sm fw-bold" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Unit *</label>
|
||||
<input v-model="product.unitname" type="text" class="form-control form-control-sm" placeholder="pcs, kg...">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Category</label>
|
||||
<select v-model="product.category" class="form-select form-select-sm">
|
||||
<option value="">— Category —</option>
|
||||
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Subcategory</label>
|
||||
<input v-model="product.subcategory" type="text" class="form-control form-control-sm" placeholder="Optional">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Barcode</label>
|
||||
<input v-model="product.barcode" type="text" class="form-control form-control-sm" placeholder="UPC / EAN">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo (optional) -->
|
||||
<label class="form-label small fw-bold text-muted mb-1 mt-1">Photo</label>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<label
|
||||
v-if="!product.photoHash"
|
||||
class="btn btn-outline-secondary btn-sm rounded-pill flex-grow-1"
|
||||
:class="{ disabled: product.photoUploading }"
|
||||
:for="`photo-input-${index}`"
|
||||
style="cursor:pointer;"
|
||||
>
|
||||
<span v-if="product.photoUploading">
|
||||
<LoadingSpinner size="small" class="me-1" /> Uploading…
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-camera me-1"></i> Add Photo
|
||||
</span>
|
||||
</label>
|
||||
<div v-else class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
<img
|
||||
:src="`/RequestData/File/${product.photoHash}`"
|
||||
class="rounded-2 border"
|
||||
style="width:48px;height:48px;object-fit:cover;"
|
||||
alt="Product photo"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-link btn-sm text-danger p-0"
|
||||
@click="removeLeafPhoto(index)"
|
||||
title="Remove photo"
|
||||
>
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
:id="`photo-input-${index}`"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="d-none"
|
||||
@change="(e) => handleLeafPhoto(index, e)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="form-label small fw-bold text-muted mb-1">Description</label>
|
||||
<input v-model="product.description" type="text" class="form-control form-control-sm" placeholder="Short description">
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button @click="addProduct" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Another Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3 pt-3 border-top">
|
||||
<button @click="saveProducts" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-save me-2" :class="{ 'fa-spin': saving }"></i>
|
||||
{{ saving ? 'Saving...' : 'Save All Products' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showPickerModal" class="bb-modal-backdrop" @click.self="closePicker">
|
||||
<div class="bb-modal">
|
||||
<div class="bb-modal-header">
|
||||
<div class="flex-grow-1 me-2">
|
||||
<h4 class="fw_7 mb-1">Pick an existing global product</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
<span v-if="targetStore">It will be imported into <strong>{{ targetStore.name }}</strong> with your store-specific price, stock, and description.</span>
|
||||
<span v-else class="text-warning"><i class="fas fa-exclamation-triangle me-1"></i> Select a target store at the top of the page first.</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="bb-modal-close" @click="closePicker" aria-label="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bb-modal-body">
|
||||
<input
|
||||
v-model="pickerQuery"
|
||||
@input="onPickerQueryInput"
|
||||
type="text"
|
||||
class="form-control mb-3"
|
||||
placeholder="Search global products by name..."
|
||||
autofocus
|
||||
>
|
||||
<div v-if="pickerSearching" class="text-center text-muted py-3">
|
||||
<LoadingSpinner size="small" /> Searching...
|
||||
</div>
|
||||
<div v-else-if="pickerResults.length === 0 && pickerQuery.trim().length >= 2" class="text-muted text-center py-3">
|
||||
No matching global products found.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="m in pickerResults" :key="m.hashkey" class="match-row d-flex align-items-center justify-content-between gap-2 p-2 border rounded mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw_6">{{ m.name }}</div>
|
||||
<div class="text-muted small">
|
||||
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
|
||||
<span>₱{{ m.price }} / {{ m.unitname }}</span>
|
||||
</div>
|
||||
<div v-if="m.already_in_store" class="text-success smallest mt-1">
|
||||
<i class="fas fa-check-circle me-1"></i> Already in this store
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary rounded-pill flex-shrink-0"
|
||||
:disabled="m.already_in_store || !targetStoreHash"
|
||||
@click="selectGlobalProduct(m)"
|
||||
>
|
||||
<span v-if="m.already_in_store">In Store</span>
|
||||
<span v-else>Use this</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bb-modal-footer">
|
||||
<button class="btn btn-link text-muted" @click="closePicker">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-primary-gradient {
|
||||
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
|
||||
}
|
||||
.leaf-card {
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.leaf-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.leaf-card.is-imported {
|
||||
border-color: #198754 !important;
|
||||
background: linear-gradient(180deg, rgba(25, 135, 84, 0.04) 0%, rgba(255, 255, 255, 0) 60%);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .bg-light {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
:global(.dark-mode) .card {
|
||||
background-color: var(--bg-card);
|
||||
}
|
||||
:global(.dark-mode) .leaf-card {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
:global(.dark-mode) .form-control,
|
||||
:global(.dark-mode) .form-select,
|
||||
:global(.dark-mode) .input-group-text {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.bb-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 16px;
|
||||
}
|
||||
.bb-modal {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.bb-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.bb-modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.bb-modal-body {
|
||||
padding: 16px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.bb-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
:global(.dark-mode) .bb-modal {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
:global(.dark-mode) .bb-modal-header,
|
||||
:global(.dark-mode) .bb-modal-footer {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Template download card */
|
||||
.template-card {
|
||||
border: 1.5px solid #d1fae5 !important;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
|
||||
}
|
||||
.template-icon-wrap {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: #dcfce7;
|
||||
}
|
||||
.template-steps {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.step-item:last-child {
|
||||
border-right: 0 !important;
|
||||
}
|
||||
.smallest {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
:global(.dark-mode) .template-card {
|
||||
background: linear-gradient(135deg, rgba(16,185,129,0.08) 0%, rgba(5,150,105,0.05) 100%);
|
||||
border-color: rgba(16,185,129,0.3) !important;
|
||||
}
|
||||
:global(.dark-mode) .template-icon-wrap {
|
||||
background: rgba(16,185,129,0.15);
|
||||
}
|
||||
:global(.dark-mode) .template-steps {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
:global(.dark-mode) .step-item {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user