Files
BarangaySystem/resources/js/Pages/AssignProductToStore.vue
2026-06-06 18:43:00 +08:00

923 lines
24 KiB
Vue

<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Assign Product To Store');
import { ref, computed, onMounted, watch } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import CardSimple from '../Components/Core/CardSimple.vue'
const props = defineProps({
target: { type: String, default: null },
store_hash: { type: String, default: null },
payload: { type: Object, default: null },
user: { type: Object, default: null },
})
const { navigate } = useNavigate()
const modal = useModal()
// Form state
const productHash = ref(null)
const selectedStoreHash = ref('')
const productData = ref({})
const customPrice = ref(0)
const customStock = ref(0)
// Reset custom fields when product data loaded
watch(productData, (newData) => {
if (newData) {
customPrice.value = newData.price || 0
customStock.value = newData.available || 0
}
})
// Data
const storeList = ref([])
const isAdmin = ref(false)
// Loading state
const isLoading = ref(false)
const isSubmitting = ref(false)
const storesLoading = ref(false)
const successMessage = ref('')
const errorMessage = ref('')
// Computed
const currentUserType = computed(() => {
return props.user?.acct_type?.value || props.user?.acct_type || ''
})
const isUltimate = computed(() => {
return currentUserType.value === 'ult'
})
const selectedStore = computed(() => {
return storeList.value.find(s => s.hashkey === selectedStoreHash.value)
})
const isButtonDisabled = computed(() => {
return !!(isSubmitting.value || successMessage.value || !selectedStoreHash.value || !productHash.value)
})
// Initialize
onMounted(() => {
document.title = 'Assign Product to Store'
// Get product hash from props (passed via URL) or from query params
const urlParams = new URLSearchParams(window.location.search)
productHash.value = props.payload?.product_hashkey || props.payload?.product_hash || props.target || urlParams.get('target') || urlParams.get('product_id') || urlParams.get('id')
// Set store hash if provided
if (props.payload?.store_hashkey || props.payload?.store_hash || props.store_hash) {
selectedStoreHash.value = props.payload?.store_hashkey || props.payload?.store_hash || props.store_hash
}
if (!productHash.value) {
errorMessage.value = 'No product specified. Please select a product first.'
return
}
loadStores()
loadProductData()
})
// Load stores for current user (filtered by ownership/management)
const loadStores = async () => {
storesLoading.value = true
try {
const response = await axios.post('/ListStores/MyStores/data')
if (response.data && Array.isArray(response.data)) {
storeList.value = response.data
isAdmin.value = isUltimate.value || props.user?.acct_type === 'super operator' || props.user?.acct_type === 'operator'
}
} catch (error) {
console.error('Error loading stores:', error)
errorMessage.value = 'Failed to load your stores. Please try again.'
} finally {
storesLoading.value = false
}
}
// Load product data
const loadProductData = async () => {
isLoading.value = true
try {
const response = await axios.post('/View/Product/Details/data', {
target: productHash.value,
})
if (response.data && response.data.success && response.data.data) {
productData.value = response.data.data
} else {
errorMessage.value = 'Product not found or data unavailable.'
}
} catch (error) {
console.error('Error loading product data:', error)
errorMessage.value = 'Failed to load product details.'
} finally {
isLoading.value = false
}
}
// Get role badge
const getRoleBadge = (role) => {
switch (role) {
case 'owner': return { text: 'Owner', class: 'badge-owner' }
case 'manager': return { text: 'Manager', class: 'badge-manager' }
case 'admin': return { text: 'Admin', class: 'badge-admin' }
default: return { text: role, class: 'badge-default' }
}
}
// Show confirmation modal
const showConfirmation = () => {
if (!selectedStoreHash.value) {
modal.open({ title: 'Missing Selection', body: 'Please select a store to assign this product to.', footer: null })
return
}
const storeName = selectedStore.value?.name || 'Selected Store'
const productName = productData.value?.name || 'This product'
modal.yesNoModal({
title: 'Assign Product?',
body: `Are you sure you want to assign <strong>${productName}</strong> to <strong>${storeName}</strong>?`,
onYes: submitAssignment,
yesText: 'Assign',
noText: 'Cancel'
})
}
// Submit assignment
const submitAssignment = async () => {
isSubmitting.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await axios.post('/Products/AssignToStore/', {
TargetStore: selectedStoreHash.value,
target: productHash.value,
price: customPrice.value,
available: customStock.value,
})
if (response.data && response.data.success) {
successMessage.value = 'Product assigned to store successfully!'
// Navigate to the store view after a short delay
setTimeout(() => {
navigate({
page: 'ViewStoreMarket',
props: { target: selectedStoreHash.value }
})
}, 1800)
} else {
errorMessage.value = response.data?.message || 'Failed to assign product to store.'
}
} catch (error) {
console.error('Error assigning product:', error)
errorMessage.value = error.response?.data?.message || 'Failed to assign product. Please try again.'
} finally {
isSubmitting.value = false
}
}
// Cancel and go back
const goBack = () => {
navigate({ page: 'ListProductsMarket' })
}
</script>
<template>
<div class="assign-product-page pb-5">
<!-- Header -->
<div class="tf-container mt-5 mb-4 text-center">
<div class="page-icon-wrapper">
<i class="fas fa-store"></i>
<i class="fas fa-plus icon-overlay"></i>
</div>
<h1 class="fw_8 premium-title">Assign Product to Store</h1>
<p class="text-muted subtitle">Link a product to one of your stores for marketplace visibility</p>
</div>
<!-- Alerts -->
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="errorMessage" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ errorMessage }}
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="tf-container text-center py-5">
<LoadingSpinner size="large" />
<p class="text-muted mt-3">Loading product details...</p>
</div>
<!-- Main Form -->
<div v-else class="tf-container">
<div class="form-grid">
<!-- Left: Store Selection -->
<div class="form-section">
<CardSimple title="Select Store">
<div class="premium-input-group mb-4">
<label for="targetStore" class="form-label">
<i class="fas fa-store me-2"></i>Target Store <span class="required">*</span>
</label>
<div v-if="storesLoading" class="store-loading">
<LoadingSpinner size="small" />
<span class="ms-2 text-muted">Loading stores...</span>
</div>
<select
v-else
id="targetStore"
v-model="selectedStoreHash"
class="premium-select"
:disabled="storeList.length === 0"
>
<option value="" disabled>
{{ storeList.length === 0 ? 'No stores available' : 'Choose a store...' }}
</option>
<option v-for="store in storeList" :key="store.hashkey" :value="store.hashkey">
{{ store.name }} {{ store.category ? `(${store.category})` : '' }}
</option>
</select>
<p v-if="storeList.length === 0 && !storesLoading" class="input-hint text-warning mt-2">
<i class="fas fa-info-circle me-1"></i>
You don't own or manage any stores yet.
</p>
</div>
<!-- Store role indicator -->
<div v-if="selectedStore" class="selected-store-info animate-fade-in">
<div class="store-info-card">
<div class="store-info-header">
<i class="fas fa-store-alt"></i>
<span>{{ selectedStore.name }}</span>
</div>
<div class="store-info-details">
<span :class="['role-badge', getRoleBadge(selectedStore.role).class]">
<i class="fas fa-shield-alt me-1"></i>
{{ getRoleBadge(selectedStore.role).text }}
</span>
<span v-if="selectedStore.category" class="category-tag">
{{ selectedStore.category }}
</span>
</div>
</div>
</div>
<!-- Access Level Info -->
<div class="access-info mt-4">
<div class="access-info-header">
<i class="fas fa-lock me-2"></i>
<span class="fw_6">Your Access Level</span>
</div>
<div class="access-info-body">
<div class="access-item" :class="{ 'active': isUltimate || isAdmin }">
<i class="fas fa-crown"></i>
<span>Admin Access</span>
<i v-if="isUltimate || isAdmin" class="fas fa-check-circle text-success ms-auto"></i>
</div>
<div class="access-item" :class="{ 'active': !isAdmin }">
<i class="fas fa-user-shield"></i>
<span>Owner/Manager Only</span>
<i v-if="!isAdmin && !isUltimate" class="fas fa-check-circle text-success ms-auto"></i>
</div>
</div>
</div>
</CardSimple>
</div>
<!-- Right: Product Preview -->
<div class="form-section">
<CardSimple title="Product Details">
<!-- Product Photo -->
<div v-if="productData.photourl && productData.photourl.length > 0" class="product-photo-preview mb-4">
<img
:src="'/RequestData/File/' + productData.photourl[0]"
alt="Product Photo"
class="product-photo"
@error="$event.target.style.display = 'none'"
>
</div>
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-tag me-2"></i>Product Name
</label>
<input
type="text"
class="premium-input"
:value="productData.name || ''"
disabled
>
</div>
<div class="row">
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-peso-sign me-2"></i>Price
</label>
<input
type="text"
class="premium-input"
:value="productData.price ? `₱${productData.price}` : ''"
disabled
>
</div>
</div>
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-weight-hanging me-2"></i>Unit
</label>
<input
type="text"
class="premium-input"
:value="productData.unitname || ''"
disabled
>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-layer-group me-2"></i>Category
</label>
<input
type="text"
class="premium-input"
:value="productData.category || ''"
disabled
>
</div>
</div>
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-barcode me-2"></i>Barcode
</label>
<input
type="text"
class="premium-input"
:value="productData.barcode || ''"
disabled
>
</div>
</div>
</div>
<div v-if="productData.description" class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-align-left me-2"></i>Description
</label>
<div class="description-preview">
{{ productData.description }}
</div>
</div>
<hr class="my-4 opacity-25">
<div class="pivot-custom-fields animate-fade-in">
<h5 class="fw_7 mb-3 text-primary">
<i class="fas fa-edit me-2"></i>Store Specific Settings
</h5>
<div class="row">
<div class="col-6">
<div class="premium-input-group mb-3">
<label for="customPrice" class="form-label">
<i class="fas fa-coins me-2"></i>Custom Price
</label>
<div class="input-with-icon">
<span class="prefix">₱</span>
<input
id="customPrice"
v-model.number="customPrice"
type="number"
step="0.01"
class="premium-input ps-5"
placeholder="0.00"
>
</div>
</div>
</div>
<div class="col-6">
<div class="premium-input-group mb-3">
<label for="customStock" class="form-label">
<i class="fas fa-cubes me-2"></i>Initial Stock
</label>
<input
id="customStock"
v-model.number="customStock"
type="number"
class="premium-input"
placeholder="0"
>
</div>
</div>
</div>
<p class="text-muted small mt-1">
<i class="fas fa-info-circle me-1"></i> These values will only apply to this store.
</p>
</div>
</CardSimple>
</div>
</div>
<!-- Action Buttons -->
<div class="action-bar mt-5 text-center">
<button
id="assign-product-btn"
@click="showConfirmation"
:disabled="isButtonDisabled"
class="btn-premium-assign"
:class="{ 'btn-loading': isSubmitting }"
>
<span v-if="!isSubmitting">
<i class="fas fa-link me-2"></i>Assign Product to Store
</span>
<LoadingSpinner v-else size="small" color="white" />
</button>
<div class="mt-4">
<button
@click="goBack"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i>Cancel and Return
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.subtitle {
font-size: 0.95rem;
max-width: 460px;
margin: 0 auto;
}
.page-icon-wrapper {
position: relative;
display: inline-block;
font-size: 2.5rem;
color: #3b82f6;
margin-bottom: 16px;
}
.icon-overlay {
position: absolute;
font-size: 0.9rem;
background: #22c55e;
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
bottom: 0;
right: -8px;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.4);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 992px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.85rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input, .premium-select {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus, .premium-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.premium-input:disabled {
background: #f8fafc;
color: #64748b;
cursor: not-allowed;
}
.premium-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
cursor: pointer;
}
.premium-select:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.input-hint {
font-size: 0.8rem;
color: #94a3b8;
}
/* Store Loading */
.store-loading {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px dashed #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
/* Selected Store Info Card */
.selected-store-info {
margin-top: 16px;
}
.store-info-card {
padding: 16px;
border-radius: 12px;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border: 1px solid #bae6fd;
}
.store-info-header {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: #0369a1;
margin-bottom: 10px;
}
.store-info-details {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.role-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-owner {
background: rgba(34, 197, 94, 0.15);
color: #15803d;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.badge-manager {
background: rgba(59, 130, 246, 0.15);
color: #1d4ed8;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.badge-admin {
background: rgba(168, 85, 247, 0.15);
color: #7e22ce;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.badge-default {
background: rgba(100, 116, 139, 0.15);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.3);
}
.category-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
background: rgba(100, 116, 139, 0.1);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.2);
}
/* Access Info */
.access-info {
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.access-info-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 0.85rem;
color: #475569;
}
.access-info-body {
padding: 8px;
}
.access-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
font-size: 0.85rem;
color: #94a3b8;
transition: all 0.2s;
}
.access-item.active {
background: rgba(34, 197, 94, 0.08);
color: #1e293b;
}
.access-item i:first-child {
font-size: 0.9rem;
width: 20px;
text-align: center;
}
/* Product Photo Preview */
.product-photo-preview {
display: flex;
justify-content: center;
padding: 12px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.product-photo {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
object-fit: contain;
}
.description-preview {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 0.9rem;
color: #64748b;
line-height: 1.6;
max-height: 120px;
overflow-y: auto;
}
/* Alerts */
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
/* Buttons */
.btn-premium-assign {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(34, 197, 94, 0.3);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 280px;
}
.btn-premium-assign:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(34, 197, 94, 0.4);
filter: brightness(1.08);
}
.btn-premium-assign:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-loading {
padding: 12px 48px;
background: #16a34a;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
/* Animations */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
/* Dark mode */
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-input:disabled {
background: #0f172a;
color: #94a3b8;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
:global(.dark-mode) .store-info-card {
background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%);
border-color: #0e7490;
}
:global(.dark-mode) .store-info-header {
color: #67e8f9;
}
:global(.dark-mode) .access-info {
border-color: #334155;
background: #1e293b;
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.input-with-icon .prefix {
position: absolute;
left: 16px;
color: #64748b;
font-weight: 600;
}
.ps-5 {
padding-left: 38px !important;
}
.pivot-custom-fields {
background: rgba(59, 130, 246, 0.03);
padding: 16px;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.1);
}
:global(.dark-mode) .pivot-custom-fields {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.2);
}
:global(.dark-mode) .access-info-header {
background: #1e293b;
border-color: #334155;
color: #94a3b8;
}
:global(.dark-mode) .access-item.active {
background: rgba(34, 197, 94, 0.15);
color: #f8fafc;
}
:global(.dark-mode) .description-preview {
background: #1e293b;
border-color: #334155;
color: #94a3b8;
}
:global(.dark-mode) .product-photo-preview {
background: #1e293b;
border-color: #334155;
}
:global(.dark-mode) .store-loading {
background: #1e293b;
border-color: #334155;
}
</style>