614 lines
23 KiB
Vue
614 lines
23 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed, h } from 'vue'
|
|
import axios from 'axios'
|
|
import { useNavigate } from '../composables/Core/useNavigate'
|
|
import { useModal } from '../composables/Core/useModal'
|
|
import { useAuth } from '../composables/Core/useAuth'
|
|
import { usePageTitle } from '../composables/Core/usePageTitle'
|
|
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
|
import FileImage from '../Components/Core/FileImage.vue'
|
|
import { useUserSettings } from '../composables/useUserSettings'
|
|
import SearchableTableWrapper from '../Components/Core/SearchableTableWrapper.vue'
|
|
|
|
const { navigate } = useNavigate()
|
|
import BackButton from '../Components/Core/BackButton.vue'
|
|
const modal = useModal()
|
|
const { isUltimate, isSuperOperator, isOperator, user } = useAuth()
|
|
const { settings, updateSetting } = useUserSettings();
|
|
usePageTitle('Product Management')
|
|
|
|
const products = ref([])
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
const searchQuery = ref('')
|
|
const selectableStores = ref([])
|
|
const selectedProduct = ref(null)
|
|
const assigning = ref(false)
|
|
const loadingStores = ref(false)
|
|
const assignedStoreHashes = ref([])
|
|
const loadingAssigned = ref(false)
|
|
|
|
const canModifyProduct = (product) => {
|
|
if (isUltimate.value || isSuperOperator.value || isOperator.value) return true
|
|
return (Number(product.created_by) === Number(user.value?.id))
|
|
}
|
|
|
|
const firstPhoto = (v) => Array.isArray(v) ? (v[0] || '') : (v || '')
|
|
|
|
const fetchProducts = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const response = await axios.post('/Admin/Products/List')
|
|
if (response.data && response.data.success && Array.isArray(response.data.products)) {
|
|
products.value = response.data.products
|
|
} else {
|
|
error.value = 'Failed to load products'
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching products:', err)
|
|
error.value = 'Failed to load products. Please try again.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const filteredProducts = computed(() => {
|
|
if (!searchQuery.value) return products.value
|
|
const q = searchQuery.value.toLowerCase()
|
|
return products.value.filter(p =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
p.description?.toLowerCase().includes(q) ||
|
|
p.category?.toLowerCase().includes(q) ||
|
|
p.subcategory?.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
const tableDensity = computed({
|
|
get: () => settings.value.table_density || 'comfortable',
|
|
set: (val) => updateSetting('table_density', val)
|
|
});
|
|
|
|
const toggleStatus = async (product) => {
|
|
if (!canModifyProduct(product)) {
|
|
modal.open({
|
|
title: 'Permission Denied',
|
|
body: 'You can only modify products you created.'
|
|
})
|
|
return
|
|
}
|
|
try {
|
|
const response = await axios.post('/Admin/Product/ToggleStatus', {
|
|
target: product.hashkey
|
|
})
|
|
if (response.data && response.data.success) {
|
|
product.is_active = response.data.data.is_active
|
|
}
|
|
} catch (err) {
|
|
console.error('Error toggling product status:', err)
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Failed to update status'
|
|
})
|
|
}
|
|
}
|
|
|
|
const deleteProduct = (product) => {
|
|
if (!canModifyProduct(product)) {
|
|
modal.open({
|
|
title: 'Permission Denied',
|
|
body: 'You can only delete products you created.'
|
|
})
|
|
return
|
|
}
|
|
modal.yesNoModal({
|
|
title: 'Confirm Deletion',
|
|
body: `Are you sure you want to permanently delete "${product.name}"? This action cannot be undone.`,
|
|
yesText: 'Delete',
|
|
noText: 'Cancel',
|
|
yesClass: 'btn-danger',
|
|
onYes: async () => {
|
|
try {
|
|
const response = await axios.post('/Admin/Product/Delete', {
|
|
target: product.hashkey
|
|
})
|
|
if (response.data && response.data.success) {
|
|
products.value = products.value.filter(p => p.hashkey !== product.hashkey)
|
|
modal.open({
|
|
title: 'Success',
|
|
body: 'Product deleted successfully.',
|
|
footer: h('button', { class: 'btn btn-primary', onClick: modal.close }, 'OK')
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Error deleting product:', err)
|
|
modal.open({
|
|
title: 'Error',
|
|
body: 'Failed to delete product: ' + (err.response?.data?.message || err.message),
|
|
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const editProduct = (product) => {
|
|
if (!canModifyProduct(product)) {
|
|
modal.open({
|
|
title: 'Permission Denied',
|
|
body: 'You can only edit products you created.'
|
|
})
|
|
return
|
|
}
|
|
navigate({ page: 'EditProductUltimate', props: { target: product.hashkey } })
|
|
}
|
|
|
|
const createProduct = () => {
|
|
navigate({ page: 'CreateProductUltimate' })
|
|
}
|
|
|
|
const fetchSelectableStores = async () => {
|
|
loadingStores.value = true
|
|
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 fetching selectable stores:', err)
|
|
} finally {
|
|
loadingStores.value = false
|
|
}
|
|
}
|
|
|
|
const fetchAssignedStores = async (product) => {
|
|
loadingAssigned.value = true
|
|
try {
|
|
const response = await axios.post('/Products/AssignedStores/', { target: product.hashkey })
|
|
if (response.data && response.data.success) {
|
|
assignedStoreHashes.value = response.data.data || []
|
|
} else {
|
|
assignedStoreHashes.value = []
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching assigned stores:', err)
|
|
assignedStoreHashes.value = []
|
|
} finally {
|
|
loadingAssigned.value = false
|
|
}
|
|
}
|
|
|
|
const openStoreSelection = (product) => {
|
|
selectedProduct.value = product
|
|
if (selectableStores.value.length === 0) {
|
|
fetchSelectableStores()
|
|
}
|
|
fetchAssignedStores(product)
|
|
|
|
// Create a reactive component for the modal body
|
|
const StoreListBody = {
|
|
render() {
|
|
if (loadingStores.value || loadingAssigned.value) {
|
|
return h('div', { class: 'text-center py-5' }, [
|
|
h(LoadingSpinner, { size: 'small' }),
|
|
h('p', { class: 'small mt-2 text-muted' }, 'Fetching available stores...')
|
|
])
|
|
}
|
|
|
|
if (selectableStores.value.length === 0) {
|
|
return h('div', { class: 'text-center py-5' }, [
|
|
h('div', { class: 'mb-3' }, [
|
|
h('i', { class: 'fas fa-store-slash fa-2x text-muted' })
|
|
]),
|
|
h('p', { class: 'fw_6 mb-1' }, 'No stores available'),
|
|
h('p', { class: 'small text-muted px-4' }, 'You don\'t have any stores that you can assign products to.')
|
|
])
|
|
}
|
|
|
|
return h('div', { class: 'store-selection-modal' }, [
|
|
h('p', { class: 'mb-3 text-muted' }, 'Select a store to list this product in, or unlist it from a store it is already assigned to:'),
|
|
h('div', { class: 'list-group list-group-flush max-vh-50 overflow-auto custom-scrollbar' },
|
|
selectableStores.value.map(store => {
|
|
const isAssigned = assignedStoreHashes.value.includes(store.hashkey)
|
|
return h('div', {
|
|
class: 'list-group-item d-flex justify-content-between align-items-center border-0 rounded-3 mb-2 px-3 py-3 shadow-sm',
|
|
}, [
|
|
h('div', { class: 'flex-grow-1' }, [
|
|
h('div', { class: 'fw_6 text-dark d-flex align-items-center gap-2' }, [
|
|
store.name,
|
|
isAssigned
|
|
? h('span', { class: 'badge bg-soft-success text-success rounded-pill px-2 py-1 smallest' }, 'Listed')
|
|
: h('span', { class: 'badge bg-soft-secondary text-muted rounded-pill px-2 py-1 smallest' }, 'Not listed'),
|
|
]),
|
|
h('div', { class: 'small text-muted' }, store.category || 'General'),
|
|
]),
|
|
h('div', { class: 'd-flex align-items-center gap-2' }, [
|
|
h('span', { class: 'badge bg-soft-info text-info rounded-pill px-3 py-2' }, store.role),
|
|
isAssigned
|
|
? h('button', {
|
|
class: 'btn btn-sm btn-outline-danger rounded-pill px-3',
|
|
disabled: assigning.value,
|
|
onClick: () => handleUnlist(store),
|
|
}, [h('i', { class: 'fas fa-unlink me-1' }), 'Unlist'])
|
|
: h('button', {
|
|
class: 'btn btn-sm btn-outline-success rounded-pill px-3',
|
|
disabled: assigning.value,
|
|
onClick: () => handleStoreSelection(store),
|
|
}, [h('i', { class: 'fas fa-plus me-1' }), 'List']),
|
|
]),
|
|
])
|
|
})
|
|
),
|
|
])
|
|
}
|
|
}
|
|
|
|
modal.open({
|
|
title: `Manage "${product.name}" Listings`,
|
|
body: h(StoreListBody),
|
|
footer: h('button', { class: 'btn btn-secondary px-4', onClick: modal.close }, 'Close')
|
|
})
|
|
}
|
|
|
|
const handleUnlist = (store) => {
|
|
modal.close()
|
|
modal.yesNoModal({
|
|
title: 'Unlist Product',
|
|
body: `Remove "${selectedProduct.value.name}" from "${store.name}"? Customers will no longer see it in that store.`,
|
|
yesText: 'Unlist',
|
|
noText: 'Cancel',
|
|
yesClass: 'btn-danger',
|
|
onYes: () => confirmUnlist(store),
|
|
onNo: () => openStoreSelection(selectedProduct.value),
|
|
})
|
|
}
|
|
|
|
const confirmUnlist = async (store) => {
|
|
assigning.value = true
|
|
try {
|
|
const response = await axios.post('/Products/UnassignFromStore/', {
|
|
target: selectedProduct.value.hashkey,
|
|
TargetStore: store.hashkey,
|
|
})
|
|
if (response.data && response.data.success) {
|
|
assignedStoreHashes.value = assignedStoreHashes.value.filter(h => h !== store.hashkey)
|
|
modal.open({
|
|
title: 'Unlisted',
|
|
body: `"${selectedProduct.value.name}" was removed from "${store.name}".`,
|
|
footer: h('div', { class: 'd-flex gap-2' }, [
|
|
h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close'),
|
|
h('button', { class: 'btn btn-primary', onClick: () => openStoreSelection(selectedProduct.value) }, 'Back to Listings'),
|
|
]),
|
|
})
|
|
} else {
|
|
throw new Error(response.data.message || 'Failed to unlist product')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error unlisting product:', err)
|
|
modal.open({
|
|
title: 'Error',
|
|
body: err.response?.data?.message || err.message || 'Failed to unlist product.',
|
|
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
|
|
})
|
|
} finally {
|
|
assigning.value = false
|
|
}
|
|
}
|
|
|
|
const handleStoreSelection = async (store) => {
|
|
modal.close()
|
|
|
|
// Prompt for price and availability
|
|
modal.open({
|
|
title: 'Listing Details',
|
|
body: h('div', { class: 'p-3' }, [
|
|
h('div', { class: 'mb-4' }, [
|
|
h('label', { class: 'form-label fw_6' }, 'Price in Store (PHP)'),
|
|
h('input', {
|
|
type: 'number',
|
|
class: 'form-control form-control-lg',
|
|
id: 'store-price-input',
|
|
value: selectedProduct.value.price,
|
|
})
|
|
]),
|
|
h('div', { class: 'mb-4' }, [
|
|
h('label', { class: 'form-label fw_6' }, 'Available Stock'),
|
|
h('input', {
|
|
type: 'number',
|
|
class: 'form-control form-control-lg',
|
|
id: 'store-available-input',
|
|
value: selectedProduct.value.available,
|
|
})
|
|
])
|
|
]),
|
|
footer: h('div', { class: 'd-flex gap-2' }, [
|
|
h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Cancel'),
|
|
h('button', {
|
|
class: 'btn btn-primary px-4',
|
|
onClick: async () => {
|
|
const price = document.getElementById('store-price-input').value
|
|
const available = document.getElementById('store-available-input').value
|
|
await confirmAssignment(store, price, available)
|
|
}
|
|
}, 'Confirm Listing')
|
|
])
|
|
})
|
|
}
|
|
|
|
const confirmAssignment = async (store, price, available) => {
|
|
assigning.value = true
|
|
try {
|
|
const response = await axios.post('/Products/AssignToStore/', {
|
|
target: selectedProduct.value.hashkey,
|
|
TargetStore: store.hashkey,
|
|
price: price,
|
|
available: available
|
|
})
|
|
|
|
if (response.data && response.data.success) {
|
|
if (!assignedStoreHashes.value.includes(store.hashkey)) {
|
|
assignedStoreHashes.value.push(store.hashkey)
|
|
}
|
|
modal.open({
|
|
title: 'Success',
|
|
body: `Product successfully added to "${store.name}".`,
|
|
footer: h('div', { class: 'd-flex gap-2' }, [
|
|
h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close'),
|
|
h('button', { class: 'btn btn-primary', onClick: () => openStoreSelection(selectedProduct.value) }, 'Back to Listings'),
|
|
]),
|
|
})
|
|
} else {
|
|
throw new Error(response.data.message || 'Failed to assign product')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error assigning product to store:', err)
|
|
modal.open({
|
|
title: 'Error',
|
|
body: err.response?.data?.message || err.message || 'Failed to assign product to store.',
|
|
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
|
|
})
|
|
} finally {
|
|
assigning.value = false
|
|
}
|
|
}
|
|
|
|
const formatPrice = (price) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(price)
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchProducts()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="manage-products-page pb-5">
|
|
<div class="tf-container mt-4">
|
|
<div class="mb-3">
|
|
<BackButton to="Home" />
|
|
</div>
|
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<h3 class="fw_6 mb-0">Manage Products</h3>
|
|
<button @click="createProduct" class="btn btn-sm btn-primary rounded-pill px-3 py-2 d-flex align-items-center gap-2">
|
|
<i class="fas fa-plus"></i> New Product
|
|
</button>
|
|
<button @click="navigate({ page: 'BatchAddProducts' })" class="btn btn-sm btn-outline-primary rounded-pill px-3 py-2 d-flex align-items-center gap-2">
|
|
<i class="fas fa-file-import"></i> Batch Add
|
|
</button>
|
|
</div>
|
|
<div class="badge bg-soft-primary px-3 py-2 rounded-pill text-primary">
|
|
{{ filteredProducts.length }} Products
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Searchable Table Wrapper -->
|
|
<SearchableTableWrapper
|
|
v-model:searchValue="searchQuery"
|
|
v-model:densityValue="tableDensity"
|
|
:loading="loading"
|
|
:error="error"
|
|
:empty="filteredProducts.length === 0"
|
|
searchPlaceholder="Search by name, category, description..."
|
|
emptyIcon="fas fa-box-open"
|
|
emptyTitle="No products found"
|
|
emptyMessage="Try adjusting your search criteria or create a new product"
|
|
:skeletonRows="10"
|
|
:skeletonColumns="7"
|
|
>
|
|
<template #empty-state>
|
|
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" style="width: 120px; opacity: 0.3;" class="mb-3">
|
|
<h5>No products found</h5>
|
|
<p class="text-muted">Try adjusting your search criteria or create a new product</p>
|
|
<button @click="createProduct" class="btn btn-primary mt-3">Create First Product</button>
|
|
</template>
|
|
|
|
<template #table>
|
|
<thead>
|
|
<tr>
|
|
<th>Image</th>
|
|
<th>Product Info</th>
|
|
<th>Category</th>
|
|
<th>Price / Unit</th>
|
|
<th>Available</th>
|
|
<th>Status</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="product in filteredProducts" :key="product.hashkey">
|
|
<td>
|
|
<div class="product-thumb">
|
|
<FileImage :src="firstPhoto(product.photourl)"
|
|
class="img-fluid rounded" alt="Product" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="fw_6">{{ product.name }}</div>
|
|
<div class="text-muted small text-truncate" style="max-width: 200px;">{{ product.description }}</div>
|
|
</td>
|
|
<td>
|
|
<div class="small">{{ product.category || 'N/A' }}</div>
|
|
<div class="text-muted smallest">{{ product.subcategory || 'N/A' }}</div>
|
|
</td>
|
|
<td>
|
|
<div class="fw_6">{{ formatPrice(product.price) }}</div>
|
|
<div class="text-muted small">per {{ product.unitname }}</div>
|
|
</td>
|
|
<td>
|
|
<span class="badge" :class="product.available > 0 ? 'bg-soft-success text-success' : 'bg-soft-danger text-danger'">
|
|
{{ product.available }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div class="form-check form-switch p-0 d-flex justify-content-center">
|
|
<input class="form-check-input ms-0" type="checkbox" role="switch"
|
|
:checked="product.is_active" @change="toggleStatus(product)"
|
|
:disabled="!canModifyProduct(product)">
|
|
</div>
|
|
<div class="text-center smallest mt-1" :class="product.is_active ? 'text-success' : 'text-danger'">
|
|
{{ product.is_active ? 'Active' : 'Inactive' }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex justify-content-end gap-2">
|
|
<button v-if="canModifyProduct(product)" @click="editProduct(product)" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
<button @click="openStoreSelection(product)" class="btn btn-sm btn-icon btn-outline-success" title="Manage Store Listings">
|
|
<i class="fas fa-store"></i>
|
|
</button>
|
|
<button v-if="canModifyProduct(product)" @click="deleteProduct(product)" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</template>
|
|
</SearchableTableWrapper>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.manage-products-page {
|
|
background: var(--bg-primary);
|
|
min-height: 100vh;
|
|
transition: background 0.3s ease;
|
|
}
|
|
|
|
.product-table-container {
|
|
background: var(--bg-card);
|
|
border-radius: 16px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
|
padding: 1rem;
|
|
}
|
|
|
|
.table {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.table thead th {
|
|
border-top: none;
|
|
background: var(--bg-tertiary);
|
|
color: var(--accent-color);
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.75rem;
|
|
letter-spacing: 0.5px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.table tbody td {
|
|
padding: 1rem;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.product-thumb {
|
|
width: 48px;
|
|
height: 48px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.product-thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.badge.bg-soft-primary {
|
|
background-color: rgba(66, 185, 131, 0.1);
|
|
}
|
|
|
|
.bg-soft-success {
|
|
background-color: rgba(40, 167, 69, 0.1);
|
|
}
|
|
|
|
.bg-soft-danger {
|
|
background-color: rgba(220, 53, 69, 0.1);
|
|
}
|
|
|
|
.bg-soft-info {
|
|
background-color: rgba(23, 162, 184, 0.1);
|
|
}
|
|
|
|
.bg-soft-secondary {
|
|
background-color: rgba(108, 117, 125, 0.1);
|
|
}
|
|
|
|
.smallest {
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.btn-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.form-switch .form-check-input {
|
|
width: 2.5em;
|
|
height: 1.25em;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.form-switch .form-check-input:checked {
|
|
background-color: #42b983;
|
|
border-color: #42b983;
|
|
}
|
|
|
|
.no-results {
|
|
background: var(--bg-card);
|
|
border-radius: 20px;
|
|
border: 2px dashed var(--border-color);
|
|
}
|
|
|
|
/* The global styles in app.js already handle most of the dark mode overrides
|
|
for .table, .card, etc. We only need component-specific tweaks here if any. */
|
|
:global(.dark-mode) .manage-products-page {
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
:global(.dark-mode) .product-table-container {
|
|
background: var(--bg-card);
|
|
}
|
|
|
|
:global(.dark-mode) .table thead th {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
:global(.dark-mode) .table tbody td {
|
|
color: var(--text-primary);
|
|
}
|
|
</style>
|