775 lines
36 KiB
Vue
775 lines
36 KiB
Vue
<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>
|